Decision: keep the hand-rolled psbt/ + taproot/ consensus layer rather than adopting fr.acinq.bitcoin-kmp. Rationale: a deliberately small single-key-path P2TR subset, pinned to authoritative BIP-341/350 test vectors at every layer (sighash, tweak, witness signature bytes, addresses, tx serialization), and consistent with the project's minimal-dependency stance. Recorded in amethyst/plans/2026-05-14-onchain-zaps.md with the consequence spelled out (we own correctness; revisit if scope expands past single-key-path P2TR). The psbt/ and taproot/ packages now carry a pointer back to that decision so a future reader doesn't reflexively swap in a library. Doc-comment + plan-doc only; no logic change.
13 KiB
Onchain Zaps in Amethyst
Date: 2026-05-14 Status: Active
Implementation plan for NIP-BC (kind 8333) onchain Bitcoin zaps in Amethyst Android.
Quartz now ships the OnchainZapEvent data model; this document describes the
rest of the system.
Design decisions
- Wallet model. Non-custodial. The user's Nostr pubkey IS the BIP-341 internal key of a P2TR output, so every account has exactly one onchain address derived from its identity. No seed phrase, no separate wallet creation.
- Signers. v1 supports
NostrSignerInternal(local keypair) andNostrSignerExternal(NIP-55 Android external signer). The NIP-55 path is broken until Amber implementssign_psbt; surface "update your signer" there.NostrSignerRemote(NIP-46) is deferred. - Chain backend. User-configured Esplora-compatible API
(mempool.space, blockstream.info, self-hosted). The configured server sees
the user's UTXO queries — accepted tradeoff for v1. Header-only SPV mode is
a future-phase add. The explorer endpoint is shared with OpenTimestamps via
BitcoinExplorerEndpoint: same user-configured server, same Tor-aware default selection. - Scope. Full send + receive + display loop on Android. Desktop is out of scope for v1.
Architecture decision: hand-rolled Bitcoin consensus code (2026-05-14)
Decision: keep the hand-rolled Bitcoin consensus layer in
quartz/.../nipBCOnchainZaps/{psbt,taproot}/ — the transaction codec,
serialization/txid, BIP-341 sighash, BIP-174 PSBT codec/signer/finalizer, and
BIP-341/350 address derivation. Do not pull in fr.acinq.bitcoin-kmp.
Considered alternative: replace the psbt/ + transaction + sighash layer
with fr.acinq.bitcoin-kmp (mature, same vendor as the secp256k1 binding
already in the build).
Rationale for keeping it hand-rolled:
- It is a deliberately small, constrained subset — single-key-path P2TR only, no script trees, one transaction shape.
- It is pinned to authoritative external test vectors at every layer: BIP-341 sighash (all 7 vectors + ANYONECANPAY), the BIP-341 tweak, the full BIP-341 witness signature bytes, the 7 BIP-341/350 P2TR mainnet addresses, and tx serialization against the genesis coinbase. It is "matches the authoritative vectors," not "trust our code."
- Consistent with the project's stance on minimal dependencies (cf. the
from-scratch
quicmodule). - No new transitive dependencies or version-conflict surface.
Consequence / what this commits us to: we own the correctness of this code
forever. If the scope ever expands beyond single-key-path P2TR (script-path
spends, multisig, PSBT fields we don't model), revisit this decision — at that
point a vetted library is the better trade. The nipBCOnchainZaps/{psbt,taproot}/
packages carry a pointer back to this section.
Architecture
| Layer | Concerns | Location |
|---|---|---|
| Quartz | Address derivation, PSBT codec, Esplora client, NIP-BC verifier, signPsbt on signer hierarchy |
quartz/.../nipBCOnchainZaps/{taproot,psbt,chain,build,verify}/ |
| Commons | Send/wallet ViewModels, shared composables | commons/.../onchain/ |
| Amethyst | OnchainSection in existing WalletScreen, LocalCache.consume(OnchainZapEvent), kind list edits in 8 filter files, NIP-55 intent plumbing |
amethyst/.../ui/onchain/, amethyst/.../model/LocalCache.kt, existing filter files |
Merging into the existing wallet UI
The existing WalletScreen is a NIP-47 NWC multi-wallet manager. Onchain wallet
fits as a separate top section, since it has different semantics (single
deterministic wallet per account, no NWC URI, chain-backed).
WalletScreen
├── TopAppBar
├── BitcoinSection ← NEW, single card
│ └── OnchainWalletCard (balance, bc1p address, tap → OnchainWalletDetailScreen)
└── LightningSection ← existing MultiWalletHomeContent
├── NoWalletSetup
└── NwcWalletCard × N
The "+" Add wallet icon still adds NWC entries only — onchain is implicit.
Section labels: "Bitcoin" and "Lightning".
Subscription edits — extend existing kind lists
No new assemblers. Add OnchainZapEvent.KIND (8333) to the existing
LnZapEvent.KIND (9735) sites:
| File | Edit |
|---|---|
amethyst/.../FilterRepliesAndReactionsToNotes.kt:48-60 |
Add to RepliesAndReactionsKinds (note #e) |
amethyst/.../FilterUserProfileZapReceived.kt:30 |
Add to UserProfileZapReceiverKinds (profile #p) |
amethyst/.../zaps/dal/UserProfileZapsViewModel.kt:55 |
Add to inline kinds = listOf(...) |
amethyst/.../FilterNotificationsToPubkey.kt:58-65 |
Add to SummaryKinds (notifications #p) |
amethyst/.../NotificationFeedFilter.kt:123 |
Add to NOTIFICATION_KINDS |
amethyst/.../NotificationDispatcher.kt:98 |
Add to NOTIFICATION_KINDS |
amethyst/.../FilterMessagesToLiveStream.kt:46 |
Add to live-activity zap kinds (#a) |
amethyst/.../FilterGoalForLiveActivity.kt:58 |
Add to goal-zap kinds (#e) |
Display path — fold into Note.zapsAmount
Today: LocalCache.consume(LnZapEvent) → Note.addZap() → Note.updateZapTotal()
sums lightning amounts into Note.zapsAmount, which ReactionsRow /
ObserveZapAmountText / SlidingAnimationAmount render. We add onchain zap
sats to the same Note.zapsAmount — no UI changes required.
commons/.../model/Note.kt:154-157, 621-632- Add
var onchainZaps = mapOf<...>()(separate map fromzaps). - Extend
updateZapTotal()to add verified onchain sats. Unverified or pending tx amounts are NOT counted.
- Add
amethyst/.../model/LocalCache.kt(after theconsume(LnZapEvent)block ~line 1667)- New
consume(event: OnchainZapEvent)handler. - Reject self-zap.
- Enqueue verification against the configured
OnchainBackend. - On success:
Note.addOnchainZap(event, verifiedSats)on eachrepliesTo. - On failure or zero verified amount: discard.
- Dedupe by
(txid, target).
- New
Quartz additions
nipBCOnchainZaps/taproot/SegwitAddress.kt— bech32m segwit address encoder/decoderTaprootAddress.kt— Nostr pubkey → bc1p P2TR address via BIP-341 key-path-only tweak (usesSecp256k1Instance.pubKeyTweakAdd)
nipBCOnchainZaps/chain/OnchainBackendinterface —getTx,getUtxos,broadcast,tipHeight,feeEstimatesBitcoinTx,BitcoinTxOutput,Utxodata modelsBitcoinTxParser— minimal raw-tx parser (just enough for verification)EsploraBackend(jvmAndroid) — OkHttp impl
nipBCOnchainZaps/verify/OnchainZapVerifier— implements all spec rules: reject self-zap, sum only outputs paying the derived recipient address, dedupe(txid, target), cap claimed amount at verified amount
nipBCOnchainZaps/psbt/(Phase A.2)- Minimal BIP-174 codec
PsbtTaprootKeyPathSigner— BIP-341 TapTweak + Schnorr sign
nipBCOnchainZaps/build/(Phase A.2)OnchainZapBuilder— given (sender, recipient, sats, feeRate, utxos), build a PSBT with change back to sender's Taproot
NostrSigner.signPsbt(Phase A.2)- Internal: signs directly
- External (NIP-55): launches
sign_psbtintent — needs Amber update - Remote (NIP-46):
NotSupportedExceptionstub
Commons additions
OnchainWalletViewModel— derived address, balance, UTXO listOnchainZapSendViewModel— build → sign → broadcast → publish kind 8333OnchainZapSendDialog,OnchainAddressQrCard,FeeRatePicker
Account state
In AccountSettings.kt next to nwcWallets:
val onchainEsploraEndpoint: MutableStateFlow<String> // default mempool.space
val onchainDefaultFeeTier: MutableStateFlow<FeeTier> // SLOW / NORMAL / FAST
Derived Account.onchainBalance: StateFlow<Long?> populated by
OnchainWalletViewModel.
Send-from-note merge
Existing ZapAmountChoicePopup (ReactionsRow.kt:1877-1977) stays as the
Lightning fast path. Two minimal hooks:
- Append
[ ⛓ Onchain… ]row to the popup. Tapping it opensOnchainZapSendDialog(fee picker + confirmation), separate from the instant-tap Lightning UX. - Optional settings toggle "Show onchain zap option" — defaults off until a balance is observed.
Phased delivery
| Phase | Deliverable | Status |
|---|---|---|
| A.1 | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, verifier + tests | Shipped |
| C | Receive + display: OnchainSection in WalletScreen (address + live balance), Esplora sync, LocalCache.consume(OnchainZapEvent), updateZapTotal fold-in, kind-list edits in all 7 in-scope filter files |
Shipped |
| A.2 | Quartz foundation (send side): BIP-174 PSBT codec, BIP-341 sighash, OnchainZapBuilder, signPsbt on the NostrSigner hierarchy. All crypto validated against BIP-341 wallet test vectors (31 tests). |
Shipped |
| D | Send flow: OnchainZapSender orchestrator, Account.sendOnchainZap, OnchainZapSendDialog, "Send" button on the wallet OnchainSection. |
Shipped |
| B | NIP-55 sign_psbt Intent + ContentResolver contract, wired through NostrSignerExternal.signPsbt. Works once the external signer app (Amber etc.) ships sign_psbt support — older signers reply with no result, surfaced as a send failure. |
Shipped |
Phase A.2 — shipped
nipBCOnchainZaps/psbt/:BitcoinIO(LE byte codec + varint),BitcoinTransaction(legacy + segwit serialization, witness-stripped txid),TaprootSigHash(BIP-341 SigMsg + TapSighash, all sighash types),Psbt(BIP-174 subset, unknown-record-preserving),PsbtSigner(key-path signing),PsbtFinalizer.nipBCOnchainZaps/build/OnchainZapBuilder: largest-first coin selection + unsigned-PSBT assembly with dust-aware change.TaprootAddress.tweakSecretKey: BIP-341taproot_tweak_seckey.Secp256k1Instance.privKeyNegate: new primitive (commonMain + 3 actuals).NostrSigner.signPsbt: real impl on internal signer; delegated by the client-tag wrapper;UnsupportedMethodExceptionstubs on NIP-46 / NIP-55.
Phase D — shipped
commons/onchain/OnchainZapSender: stateless orchestrator — load UTXOs →OnchainZapBuilder.build→signer.signPsbt→PsbtFinalizer→OnchainBackend.broadcast→ publish kind:8333 via an injected callback. Per-stageOnchainZapSendResult.Failure; broadcast txid preserved when only the receipt-publish stage fails.Account.sendOnchainZap: binds the account signer,LocalCache.onchainBackend, andsignAndComputeBroadcastinto the orchestrator.OnchainZapSendDialog: recipient npub (or fixed recipient +zappedEventfor a future note-zap-menu entry), amount, fee tier from the backend's estimates, comment; runs the send and shows progress + result.OnchainSection: "Send" button on the wallet-screen Bitcoin card.
Phase B — shipped
CommandType.SIGN_PSBT(sign_psbt) — also usable in NIP-55permslists viaPermission, which wrapsCommandTypedirectly.SignPsbtResultresult type;SignPsbtQuery(background ContentResolver),SignPsbtRequest/SignPsbtResponse(foreground Intent), mirroring thederive_keystring-in/string-out shape — the PSBT hex rides thenostrsigner:URI, the signed PSBT comes back inresult.BackgroundRequestHandler.signPsbt/ForegroundRequestHandler.signPsbt;NostrSignerExternal.signPsbtnow does the real background-then-foreground query instead of throwing. External signers that predatesign_psbtreply with noresult→CouldNotPerformException, surfaced by the send dialog.
What's still pending
- Note-zap-menu entry point.
OnchainZapSendDialogalready acceptsrecipientPubKey+zappedEvent; wiring an "Onchain" option into the existingZapAmountChoicePopupis the remaining UI hook for event zaps. - NIP-46
sign_psbt.NostrSignerRemote.signPsbtstill throwsUnsupportedMethodException— the bunker-side command is not standardized yet.
Risks / open questions
- secp256k1-kmp tweak coverage — pubKeyTweakAdd exists on JVM/Android/JNI but not on the pure-Kotlin native impl. iOS support deferred until upstream ships it or we contribute.
- PSBT correctness — single-key-path-only is a small surface; still needs thorough testing against BIP-174 test vectors before any user funds move.
- Esplora privacy — the configured server sees user UTXO queries. Default to a reputable provider, make user-configurable, consider future Tor option.
- NIP-55 ecosystem — Amber must implement
sign_psbtfor external signer accounts to use this feature. Note.zapsshape — separateonchainZapsmap vs sealed-type fold-in. Leaning separate map for v1.- Verification network calls from
LocalCache—consumeruns on the relay thread; verification needs a coroutine scope +OnchainBackendinstance injected fromAccounton app start.