diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md new file mode 100644 index 0000000000..f61494e015 --- /dev/null +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -0,0 +1,251 @@ +# 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) and + `NostrSignerExternal` (NIP-55 Android external signer). The NIP-55 path is + broken until Amber implements `sign_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 `quic` module). +- 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 from `zaps`). + - Extend `updateZapTotal()` to add **verified** onchain sats. Unverified or + pending tx amounts are NOT counted. +- `amethyst/.../model/LocalCache.kt` (after the `consume(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 each `repliesTo`. + - On failure or zero verified amount: discard. + - Dedupe by `(txid, target)`. + +## Quartz additions + +- `nipBCOnchainZaps/taproot/` + - `SegwitAddress.kt` — bech32m segwit address encoder/decoder + - `TaprootAddress.kt` — Nostr pubkey → bc1p P2TR address via BIP-341 + key-path-only tweak (uses `Secp256k1Instance.pubKeyTweakAdd`) +- `nipBCOnchainZaps/chain/` + - `OnchainBackend` interface — `getTx`, `getUtxos`, `broadcast`, + `tipHeight`, `feeEstimates` + - `BitcoinTx`, `BitcoinTxOutput`, `Utxo` data models + - `BitcoinTxParser` — 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_psbt` intent — needs Amber update + - Remote (NIP-46): `NotSupportedException` stub + +## Commons additions + +- `OnchainWalletViewModel` — derived address, balance, UTXO list +- `OnchainZapSendViewModel` — build → sign → broadcast → publish kind 8333 +- `OnchainZapSendDialog`, `OnchainAddressQrCard`, `FeeRatePicker` + +## Account state + +In `AccountSettings.kt` next to `nwcWallets`: + +```kotlin +val onchainEsploraEndpoint: MutableStateFlow // default mempool.space +val onchainDefaultFeeTier: MutableStateFlow // SLOW / NORMAL / FAST +``` + +Derived `Account.onchainBalance: StateFlow` populated by +`OnchainWalletViewModel`. + +## Send-from-note merge + +Existing `ZapAmountChoicePopup` (`ReactionsRow.kt:1877-1977`) stays as the +Lightning fast path. Two minimal hooks: + +1. Append `[ ⛓ Onchain… ]` row to the popup. Tapping it opens + `OnchainZapSendDialog` (fee picker + confirmation), separate from the + instant-tap Lightning UX. +2. 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-341 `taproot_tweak_seckey`. +- `Secp256k1Instance.privKeyNegate`: new primitive (commonMain + 3 actuals). +- `NostrSigner.signPsbt`: real impl on internal signer; delegated by the + client-tag wrapper; `UnsupportedMethodException` stubs 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-stage `OnchainZapSendResult.Failure`; broadcast txid + preserved when only the receipt-publish stage fails. +- `Account.sendOnchainZap`: binds the account signer, `LocalCache.onchainBackend`, + and `signAndComputeBroadcast` into the orchestrator. +- `OnchainZapSendDialog`: recipient npub (or fixed recipient + `zappedEvent` + for 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-55 `perms` lists + via `Permission`, which wraps `CommandType` directly. +- `SignPsbtResult` result type; `SignPsbtQuery` (background ContentResolver), + `SignPsbtRequest` / `SignPsbtResponse` (foreground Intent), mirroring the + `derive_key` string-in/string-out shape — the PSBT hex rides the + `nostrsigner:` URI, the signed PSBT comes back in `result`. +- `BackgroundRequestHandler.signPsbt` / `ForegroundRequestHandler.signPsbt`; + `NostrSignerExternal.signPsbt` now does the real background-then-foreground + query instead of throwing. External signers that predate `sign_psbt` reply + with no `result` → `CouldNotPerformException`, surfaced by the send dialog. + +### What's still pending + +1. **Note-zap-menu entry point.** `OnchainZapSendDialog` already accepts + `recipientPubKey` + `zappedEvent`; wiring an "Onchain" option into the + existing `ZapAmountChoicePopup` is the remaining UI hook for event zaps. +2. **NIP-46 `sign_psbt`.** `NostrSignerRemote.signPsbt` still throws + `UnsupportedMethodException` — 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_psbt` for external signer + accounts to use this feature. +- **`Note.zaps` shape** — separate `onchainZaps` map vs sealed-type fold-in. + Leaning separate map for v1. +- **Verification network calls from `LocalCache`** — `consume` runs on the + relay thread; verification needs a coroutine scope + `OnchainBackend` + instance injected from `Account` on app start. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index acdad276d9..d05a2935f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever @@ -91,6 +92,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher @@ -99,6 +101,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -330,6 +334,34 @@ class AppModules( // Caches all events in Memory val cache: LocalCache = LocalCache + // NIP-BC onchain zap verification backend. Wired up once at app init so + // LocalCache.consume(OnchainZapEvent) can sum the on-chain output values + // that pay the recipient's derived Taproot address. Wrapped in a caching + // decorator so a feed full of onchain zaps doesn't fan out into one HTTP + // request per event. + // + // The explorer endpoint is shared with OpenTimestamps: it honours the same + // user-configured server (OTS settings) and the same Tor-aware default + // selection, via BitcoinExplorerEndpoint — onchain zaps must not silently + // bypass the user's Tor preference. + init { + cache.onchainBackend = + CachingOnchainBackend( + EsploraBackend( + baseUrl = { + BitcoinExplorerEndpoint.resolveNormalized( + customExplorerUrl = otsPrefs.current.normalizedUrl(), + usingTor = + roleBasedHttpClientBuilder.shouldUseTorForMoneyOperations( + OkHttpBitcoinExplorer.MEMPOOL_API_URL, + ), + ) + }, + client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL), + ), + ) + } + // Provides a relay pool val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 260a2fa0b4..a631b77d21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -36,6 +36,9 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListS import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator @@ -754,6 +757,37 @@ class Account( return zapRequest } + /** + * Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's + * derived Taproot address, sign it, broadcast it, and publish the kind:8333 + * zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or + * leave it null for a profile zap. + */ + suspend fun sendOnchainZap( + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = null, + ): OnchainZapSendResult { + val backend = + cache.onchainBackend + ?: return OnchainZapSendResult.Failure( + OnchainZapSendStage.LOADING_UTXOS, + "Bitcoin chain backend is not configured", + ) + return OnchainZapSender.send( + backend = backend, + signer = signer, + senderPubKey = signer.pubKey, + recipientPubKey = recipientPubKey, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + comment = comment, + zappedEvent = zappedEvent, + ) { template -> signAndComputeBroadcast(template) } + } + suspend fun report( note: Note, type: ReportType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 49cbdd1346..d60787d017 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -245,6 +245,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier +import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase @@ -291,6 +295,15 @@ object LocalCache : ILocalCache, ICacheProvider { val paymentTracker = NwcPaymentTracker() + /** + * Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps + * against the actual on-chain transaction. `null` disables verification (incoming + * onchain zap events still get cached, but no `Note.zapsAmount` contribution). + * Set during account init. + */ + @Volatile + var onchainBackend: OnchainBackend? = null + val relayHints = HintIndexer() val deletionIndex = DeletionIndex() @@ -909,6 +922,18 @@ object LocalCache : ILocalCache, ICacheProvider { (event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList()) } + is OnchainZapEvent -> { + // NIP-BC zaps can target an event id (e), an addressable event (a), + // or just the recipient profile (p). Profile-only zaps have no + // Note target and are surfaced through profile zap queries. + buildList { + event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } } + event.zappedAddress()?.let { coord -> + Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) } + } + } + } + is LnZapRequestEvent -> { event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } @@ -1717,6 +1742,60 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: OnchainZapEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + + if (!(wasVerified || justVerify(event))) return false + + // Anti-spoofing: NIP-BC requires rejecting self-zaps. + val recipient = event.recipient() ?: return false + if (event.pubKey.equals(recipient, ignoreCase = true)) return false + + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) + note.loadEvent(event, author, repliesTo) + refreshNewNoteObservers(note) + + // Verification needs a chain backend. Without one (e.g. before Account + // wires its EsploraBackend) the event is still cached so subscriptions + // and profile zap views see it, but it can't contribute to Note totals. + val backend = onchainBackend ?: return true + val verifier = OnchainZapVerifier(backend) + + Amethyst.instance.applicationIOScope.launch { + try { + when (val result = verifier.verify(event)) { + is VerifiedOnchainZap.Confirmed -> { + repliesTo.forEach { + it.addOnchainZap(result.txid, result.verifiedSats, confirmed = true) + } + } + + is VerifiedOnchainZap.Pending -> { + repliesTo.forEach { + it.addOnchainZap(result.txid, result.verifiedSats, confirmed = false) + } + } + + is VerifiedOnchainZap.Rejected -> { + Log.d("OnchainZap") { + "rejected ${result.txid}: ${result.reason}" + } + } + } + } catch (t: Throwable) { + Log.w("OnchainZap", "verification failed for ${event.id}", t) + } + } + + return true + } + private fun attachZapToLiveActivityChannel( event: LnZapEvent, note: Note, @@ -3170,6 +3249,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is OnchainZapEvent -> { + consume(event, relay, wasVerified) + } + is NIP90StatusEvent -> { consumeRegularEvent(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt new file mode 100644 index 0000000000..a9a118647c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpoint.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip03Timestamp + +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer + +/** + * Picks the Bitcoin block-explorer (Esplora) base URL. + * + * This is the single source of truth shared by two features that both talk to + * the same Esplora API: + * - OpenTimestamps verification ([TorAwareOkHttpOtsResolverBuilder]). + * - NIP-BC onchain zaps (the `EsploraBackend` wired in `AppModules`). + * + * So a user who configures a custom explorer in the OTS settings gets it for + * onchain zaps too, and both honour the same Tor preference: a configured + * [OtsSettings] custom URL always wins; otherwise mempool.space is used when + * Tor is active (it is reachable over Tor) and blockstream.info when it is not. + */ +object BitcoinExplorerEndpoint { + /** + * @param customExplorerUrl A user-configured explorer base URL, or null/blank + * for the automatic Tor-aware default. Typically + * `OtsSettings.normalizedUrl()`. + * @param usingTor Whether Bitcoin/"money" traffic is currently routed over Tor. + */ + fun resolve( + customExplorerUrl: String?, + usingTor: Boolean, + ): String = + customExplorerUrl?.takeIf { it.isNotBlank() } + ?: if (usingTor) { + OkHttpBitcoinExplorer.MEMPOOL_API_URL + } else { + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL + } + + /** + * Same as [resolve] but with any trailing slash stripped, for callers that + * join request paths with a leading `/` (e.g. `"$base/tx/$txid"`). + */ + fun resolveNormalized( + customExplorerUrl: String?, + usingTor: Boolean, + ): String = resolve(customExplorerUrl, usingTor).trimEnd('/') +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt index dcf34bd96a..a2b78880ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt @@ -33,13 +33,7 @@ class TorAwareOkHttpOtsResolverBuilder( val cache: OtsBlockHeightCache, val customExplorerUrl: () -> String? = { null }, ) : OtsResolverBuilder { - fun getAPI(usingTor: Boolean): String = - customExplorerUrl() - ?: if (usingTor) { - OkHttpBitcoinExplorer.MEMPOOL_API_URL - } else { - OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL - } + fun getAPI(usingTor: Boolean): String = BitcoinExplorerEndpoint.resolve(customExplorerUrl(), usingTor) override fun build(): OtsResolver = OtsResolver( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index df17bfe015..cedd7daea0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -96,6 +97,7 @@ class NotificationDispatcher( // Direct-arrival PrivateDmEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ReactionEvent.KIND, TextNoteEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 941b972faf..10cca27dc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent val SummaryKinds = listOf( @@ -62,6 +63,7 @@ val SummaryKinds = RepostEvent.KIND, GenericRepostEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ) val NotificationsPerKeyKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 0160962bb0..f9a5052356 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsKinds = @@ -53,6 +54,7 @@ val RepliesAndReactionsKinds = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index fe6699eb94..484ba1d47b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -149,6 +149,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryRespo import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent +import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -294,6 +295,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.Dispatchers @@ -948,6 +950,10 @@ private fun RenderNoteRow( RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) } + is OnchainZapEvent -> { + RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav) + } + is LiveActivitiesClipEvent -> { RenderChatClip(baseNote, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt new file mode 100644 index 0000000000..a4b6075330 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt @@ -0,0 +1,425 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.NumberFormat + +private const val MEMPOOL_TX_URL = "https://mempool.space/tx/" + +@Composable +fun RenderOnchainZap( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? OnchainZapEvent ?: return + val sender = note.author?.pubkeyHex ?: event.pubKey + val recipient = event.recipient() ?: return + val sats = event.claimedAmountInSats() ?: 0L + val txid = event.txid() + val message = event.content.takeIf { it.isNotBlank() } + + val orange = MaterialTheme.colorScheme.bitcoinColor + + // Async chain lookup so we can show a real "Confirmed at block N" or + // "In mempool…" pill rather than a sender-claimed status. Null = backend + // not configured, or fetch failed — we just show the sender-claimed sats + // and skip the pill. + var tx by remember(txid) { mutableStateOf(null) } + if (txid != null) { + LaunchedEffect(txid) { + val backend = LocalCache.onchainBackend ?: return@LaunchedEffect + runCatching { + withContext(Dispatchers.IO) { backend.getTx(txid) } + }.onSuccess { tx = it } + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background( + Brush.linearGradient( + colors = + listOf( + orange.copy(alpha = 0.16f), + orange.copy(alpha = 0.04f), + ), + ), + ).padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HeaderRow( + sender = sender, + recipient = recipient, + orange = orange, + accountViewModel = accountViewModel, + nav = nav, + ) + + AmountRow(sats = sats, orange = orange) + + ConfirmationPill(tx = tx, hasTxid = txid != null, orange = orange) + + if (txid != null) { + TxidRow(txid = txid, orange = orange) + } + + message?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Composable +private fun HeaderRow( + sender: String, + recipient: String, + orange: Color, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PulsingBitcoinBadge(orange) + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos, + contentDescription = null, + tint = orange, + modifier = Size16Modifier, + ) + UserPicture(recipient, Size25dp, Modifier, accountViewModel, nav) + + Spacer(Modifier.weight(1f)) + + OnchainPill(orange) + } +} + +@Composable +private fun PulsingBitcoinBadge(orange: Color) { + val pulse = rememberInfiniteTransition(label = "btcPulse") + val scale by pulse.animateFloat( + initialValue = 0.92f, + targetValue = 1.08f, + animationSpec = + infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "btcScale", + ) + val glow by pulse.animateFloat( + initialValue = 0.25f, + targetValue = 0.55f, + animationSpec = + infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "btcGlow", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(32.dp) + .drawBehind { + drawCircle( + brush = + Brush.radialGradient( + colors = listOf(orange.copy(alpha = glow), Color.Transparent), + ), + radius = size.minDimension / 2f, + ) + }, + ) { + Box( + modifier = + Modifier + .size(24.dp) + .scale(scale) + .clip(CircleShape) + .background(orange), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = "Bitcoin", + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + } +} + +@Composable +private fun OnchainPill(orange: Color) { + val infinite = rememberInfiniteTransition(label = "pillDash") + val phase by infinite.animateFloat( + initialValue = 0f, + targetValue = 40f, + animationSpec = + infiniteRepeatable( + animation = tween(2500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "pillPhase", + ) + + Box( + modifier = + Modifier + .drawBehind { + val brush = + Brush.sweepGradient( + colors = + listOf( + orange, + orange.copy(alpha = 0.4f), + orange, + ), + ) + drawRoundRect( + brush = brush, + style = + Stroke( + width = 1.4.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f), phase), + ), + cornerRadius = + androidx.compose.ui.geometry + .CornerRadius(10.dp.toPx()), + ) + }.padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = "ON-CHAIN", + style = MaterialTheme.typography.labelSmall, + color = orange, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun AmountRow( + sats: Long, + orange: Color, +) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = NumberFormat.getNumberInstance().format(sats), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + color = orange, + ) + Text( + text = "sats", + style = MaterialTheme.typography.titleSmall, + color = orange, + modifier = Modifier.padding(bottom = 6.dp), + ) + } +} + +@Composable +private fun ConfirmationPill( + tx: BitcoinTx?, + hasTxid: Boolean, + orange: Color, +) { + AnimatedVisibility(visible = hasTxid) { + val confirmations = tx?.confirmations ?: -1 + val (label, color) = + when { + tx == null -> "Verifying on chain…" to orange.copy(alpha = 0.75f) + confirmations <= 0 -> "In mempool — pending confirmation" to orange + confirmations < 6 -> "Confirmed · $confirmations conf" to orange + else -> + "Confirmed · ${tx.blockHeight?.let { "block $it" } ?: "$confirmations conf"}" to + MaterialTheme.colorScheme.primary + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (tx == null || confirmations <= 0) { + PulsingDot(color) + } else { + Icon( + symbol = MaterialSymbols.Block, + contentDescription = null, + tint = color, + modifier = Size16Modifier, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun PulsingDot(color: Color) { + val infinite = rememberInfiniteTransition(label = "dotPulse") + val alpha by infinite.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "dotAlpha", + ) + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(color.copy(alpha = alpha)), + ) +} + +@Composable +private fun TxidRow( + txid: String, + orange: Color, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val short = remember(txid) { "${txid.take(10)}…${txid.takeLast(8)}" } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { + scope.launch { clipboard.setText("$MEMPOOL_TX_URL$txid") } + }.padding(vertical = 2.dp), + ) { + Text( + text = "tx", + style = MaterialTheme.typography.labelSmall, + color = orange, + fontWeight = FontWeight.Bold, + ) + Text( + text = short, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(2.dp)) + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = "Copy tx link", + tint = orange, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.height(0.dp)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 427101e5c3..7880c23d9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent /** * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts @@ -55,7 +56,7 @@ fun filterGoalForLiveActivities( relay = relay, filter = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index c1c47325aa..93c9984855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -44,6 +45,7 @@ fun filterMessagesToLiveActivities( LiveActivitiesRaidEvent.KIND, LiveActivitiesClipEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index c24217b769..d58a7d7c57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -121,6 +122,7 @@ class NotificationFeedFilter( ReactionEvent.KIND, RepostEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, PollEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index d03a3d7ac2..2cc37d239b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -26,8 +26,9 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND) fun filterUserProfileZapsReceived( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 33ebc1b200..6ec8803b4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -28,8 +28,10 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -52,7 +54,7 @@ class UserProfileZapsViewModel( ) : ViewModel() { val zapsToUser = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), ) @@ -95,11 +97,24 @@ class UserProfileZapsViewModel( } } - suspend fun List.sumAmountsByUser(): List { + private fun mapOnchainZap(event: OnchainZapEvent): ZapAmount { + val amountSats = event.claimedAmountInSats() ?: 0L + return ZapAmount( + LocalCache.getOrCreateUser(event.pubKey), + BigDecimal(amountSats), + ) + } + + suspend fun List.sumAmountsByUser(): List { val results = mutableMapOf() this.forEach { zapEvent -> - val zapAmount = mapRequest(zapEvent) + val zapAmount = + when (zapEvent) { + is LnZapEvent -> mapRequest(zapEvent) + is OnchainZapEvent -> mapOnchainZap(zapEvent) + else -> null + } if (zapAmount != null) { val existingAmount = results[zapAmount.user] ?: BigDecimal.ZERO results[zapAmount.user] = existingAmount + zapAmount.amount @@ -112,7 +127,7 @@ class UserProfileZapsViewModel( @OptIn(kotlinx.coroutines.FlowPreview::class) val receivedZapAmountsByUser: StateFlow> = account.cache - .observeEvents(zapsToUser) + .observeEvents(zapsToUser) .sample(500) .map { zapEvents -> zapEvents.sumAmountsByUser() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 6ff45b1f50..d0e69096ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -168,6 +168,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -291,6 +292,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.toImmutableList @@ -679,6 +681,8 @@ private fun FullBleedNoteCompose( DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is LnZapEvent) { RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is OnchainZapEvent) { + RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BlockedRelayListEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt new file mode 100644 index 0000000000..490672ed95 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.NumberFormat + +/** + * "Bitcoin" card on the wallet screen, shown above the lightning NWC wallet + * list. Visually mirrors [WalletCard] so the two payment rails read as the + * same kind of object; bitcoin-orange accent in place of NWC's primary + * (purple) keeps the rails distinct at a glance. + * + * Every account has exactly one Taproot address (derived from its Nostr + * pubkey via NIP-BC / BIP-341), so this is always a single card — not a list. + */ +@Composable +fun OnchainSection( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val pubKey = accountViewModel.account.signer.pubKey + + val address = + remember(pubKey) { + runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() + } + + var balanceSats by remember(pubKey) { mutableStateOf(null) } + var balanceState by remember(pubKey) { mutableStateOf(BalanceState.LOADING) } + + LaunchedEffect(address) { + if (address == null) { + balanceState = BalanceState.UNAVAILABLE + return@LaunchedEffect + } + val backend = LocalCache.onchainBackend + if (backend == null) { + balanceState = BalanceState.UNAVAILABLE + return@LaunchedEffect + } + balanceState = BalanceState.LOADING + try { + val utxos = withContext(Dispatchers.IO) { backend.getUtxosForAddress(address) } + balanceSats = utxos.sumOf { it.valueSats } + balanceState = BalanceState.READY + } catch (t: Throwable) { + balanceState = BalanceState.ERROR + } + } + + val orange = MaterialTheme.colorScheme.bitcoinColor + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(2.dp, orange), + colors = + CardDefaults.cardColors( + containerColor = orange.copy(alpha = 0.12f), + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + HeaderRow( + orange = orange, + balanceState = balanceState, + balanceSats = balanceSats, + ) + + if (address == null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Address derivation unavailable for this account.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Spacer(modifier = Modifier.height(12.dp)) + AddressBlock(address = address) + + Spacer(modifier = Modifier.height(12.dp)) + ActionRow(address = address, accountViewModel = accountViewModel, orange = orange) + } + } + } +} + +private enum class BalanceState { LOADING, READY, ERROR, UNAVAILABLE } + +@Composable +private fun HeaderRow( + orange: Color, + balanceState: BalanceState, + balanceSats: Long?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + BitcoinChip(orange) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Bitcoin", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Onchain · Taproot", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + BalanceBlock(state = balanceState, sats = balanceSats, orange = orange) + } +} + +@Composable +private fun BitcoinChip(orange: Color) { + Box( + modifier = + Modifier + .size(28.dp) + .clip(CircleShape) + .background(color = orange), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun BalanceBlock( + state: BalanceState, + sats: Long?, + orange: Color, +) { + if (state == BalanceState.LOADING && sats == null) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = orange, + ) + return + } + + Column(horizontalAlignment = Alignment.End) { + when (state) { + BalanceState.READY -> { + val formatted = + remember(sats) { + NumberFormat.getIntegerInstance().format(sats ?: 0L) + } + Text( + text = formatted, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = orange, + ) + Text( + text = "sats", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + BalanceState.ERROR -> { + Text( + text = "—", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "unavailable", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + BalanceState.UNAVAILABLE -> { + Text( + text = "—", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "no backend", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + BalanceState.LOADING -> Unit + } + } +} + +@Composable +private fun AddressBlock(address: String) { + Text( + text = "Your Taproot address", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = address, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun ActionRow( + address: String, + accountViewModel: AccountViewModel, + orange: Color, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + var showSendDialog by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { scope.launch { clipboard.setText(address) } }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Copy", style = MaterialTheme.typography.bodySmall) + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { showSendDialog = true }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = orange, + contentColor = Color.White, + ), + ) { + Icon( + symbol = MaterialSymbols.AutoMirrored.Send, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Send", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold) + } + } + + if (showSendDialog) { + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { showSendDialog = false }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt new file mode 100644 index 0000000000..2dc59bc595 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -0,0 +1,708 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.utils.BigDecimal +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.NumberFormat + +private enum class FeeTier( + val label: String, + val etaLabel: String, +) { + SLOW("Slow", "~1 hr"), + NORMAL("Normal", "~30 min"), + FAST("Fast", "~10 min"), +} + +private fun FeeEstimates.rateFor(tier: FeeTier): Double = + when (tier) { + FeeTier.SLOW -> slowSatPerVbyte + FeeTier.NORMAL -> normalSatPerVbyte + FeeTier.FAST -> fastSatPerVbyte + } + +/** + * Modal bottom sheet that drives a NIP-BC onchain zap. + * + * Layout (top to bottom): + * - Title row with close button + * - Recipient picker — search field with inline dropdown, or selected-user chip + * - Amount section — quick-pick chips (reuses [AccountViewModel.zapAmountChoices]) + * and a big sats text field + * - Optional comment + * - Fee priority — three chips with rate + ETA, in a FlowRow that wraps + * - Sticky bottom send button + * + * When [recipientPubKey] is null the user picks a recipient. When provided + * (e.g. from a note's zap menu) the recipient is fixed and [zappedEvent] + * attributes the zap to that event. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnchainZapSendDialog( + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + recipientPubKey: HexKey? = null, + zappedEvent: EventHintBundle? = null, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + + var searchInput by remember { mutableStateOf("") } + var selectedUser by remember { mutableStateOf(null) } + var amountInput by remember { mutableStateOf("") } + var comment by remember { mutableStateOf("") } + var feeTier by remember { mutableStateOf(FeeTier.NORMAL) } + var fees by remember { mutableStateOf(null) } + + var sending by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + val backend = LocalCache.onchainBackend ?: return@LaunchedEffect + fees = + runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() + } + + val presetAmounts = + remember(accountViewModel) { + accountViewModel.zapAmountChoices() + } + + val resolvedRecipient: HexKey? = + recipientPubKey + ?: selectedUser?.pubkeyHex + ?: searchInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } + val amountSats = amountInput.trim().toLongOrNull() + val canSend = + !sending && + result == null && + resolvedRecipient != null && + amountSats != null && + amountSats > 0 && + fees != null + + ModalBottomSheet( + onDismissRequest = { if (!sending) onDismiss() }, + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding(), + ) { + Header(onClose = { if (!sending) onDismiss() }) + + when (val r = result) { + is OnchainZapSendResult.Success -> { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + SuccessBody(r) + } + DoneButton(label = "Done", onClick = onDismiss) + } + + is OnchainZapSendResult.Failure -> { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + FailureBody(r) + } + DoneButton(label = "Close", onClick = onDismiss) + } + + null -> { + if (sending) { + SendingState() + } else { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + RecipientSection( + accountViewModel = accountViewModel, + recipientPubKey = recipientPubKey, + userSuggestions = userSuggestions, + selectedUser = selectedUser, + onSelectUser = { + selectedUser = it + searchInput = "" + userSuggestions.reset() + }, + onClearUser = { + selectedUser = null + searchInput = "" + userSuggestions.reset() + }, + searchInput = searchInput, + onSearchChange = { newValue -> + searchInput = newValue + if (newValue.length > 2) { + userSuggestions.processCurrentWord(newValue) + } else { + userSuggestions.reset() + } + }, + ) + + SectionSpacer() + + AmountSection( + amountInput = amountInput, + onAmountChange = { amountInput = it }, + presetAmounts = presetAmounts, + ) + + SectionSpacer() + + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth(), + ) + + SectionSpacer() + + FeeSection( + feeTier = feeTier, + onFeeTierChange = { feeTier = it }, + fees = fees, + ) + + Spacer(Modifier.height(20.dp)) + } + + SendButton( + enabled = canSend, + amountSats = amountSats, + onClick = { + val recipient = resolvedRecipient ?: return@SendButton + val amount = amountSats ?: return@SendButton + val feeRate = fees?.rateFor(feeTier) ?: return@SendButton + sending = true + scope.launch { + val r = + accountViewModel.account.sendOnchainZap( + recipientPubKey = recipient, + amountSats = amount, + feeRateSatPerVByte = feeRate, + comment = comment.trim(), + zappedEvent = zappedEvent, + ) + sending = false + result = r + } + }, + ) + } + } + } + } + } +} + +@Composable +private fun SectionSpacer() { + Spacer(Modifier.height(16.dp)) +} + +@Composable +private fun Header(onClose: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = MaterialTheme.colorScheme.bitcoinColor, + modifier = Modifier.size(22.dp), + ) + } + Text( + text = "Send onchain zap", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = + Modifier + .weight(1f) + .padding(start = 8.dp), + ) + IconButton(onClick = onClose) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun RecipientSection( + accountViewModel: AccountViewModel, + recipientPubKey: HexKey?, + userSuggestions: UserSuggestionState, + selectedUser: User?, + onSelectUser: (User) -> Unit, + onClearUser: () -> Unit, + searchInput: String, + onSearchChange: (String) -> Unit, +) { + SectionLabel("To") + + if (recipientPubKey != null) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = recipientPubKey, + size = 32.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Text( + text = "Post author", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + return + } + + if (selectedUser != null) { + SelectedRecipientChip(selectedUser, accountViewModel, onClearUser) + return + } + + // Tight column: no extra parent spacing between the field and its + // suggestion dropdown — the dropdown sits flush under the field. + OutlinedTextField( + value = searchInput, + onValueChange = onSearchChange, + label = { Text("Recipient") }, + placeholder = { Text("name, NIP-05 or npub") }, + singleLine = true, + isError = + searchInput.isNotBlank() && + searchInput.length > 50 && + decodePublicKeyAsHexOrNull(searchInput.trim()) == null, + modifier = Modifier.fillMaxWidth(), + ) + + if (searchInput.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = onSelectUser, + accountViewModel = accountViewModel, + modifier = Modifier.heightIn(0.dp, 220.dp), + ) + } +} + +@Composable +private fun SelectedRecipientChip( + user: User, + accountViewModel: AccountViewModel, + onClear: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = user.pubkeyHex, + size = 36.dp, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = user.pubkeyDisplayHex(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onClear) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = "Change recipient", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AmountSection( + amountInput: String, + onAmountChange: (String) -> Unit, + presetAmounts: List, +) { + SectionLabel("Amount") + + OutlinedTextField( + value = amountInput, + onValueChange = { onAmountChange(it.filter(Char::isDigit)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = { Text("0") }, + suffix = { Text("sats", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + modifier = Modifier.fillMaxWidth(), + ) + + if (presetAmounts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + presetAmounts.forEach { amount -> + SuggestionChip( + onClick = { onAmountChange(amount.toString()) }, + label = { Text("⚡ ${showAmount(BigDecimal(amount))}") }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FeeSection( + feeTier: FeeTier, + onFeeTierChange: (FeeTier) -> Unit, + fees: FeeEstimates?, +) { + SectionLabel("Priority") + + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FeeTier.entries.forEach { tier -> + val rate = fees?.rateFor(tier) + FilterChip( + selected = feeTier == tier, + onClick = { onFeeTierChange(tier) }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.bitcoinColor, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + ), + label = { + Column( + modifier = Modifier.padding(vertical = 4.dp), + ) { + Text( + text = tier.label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (rate != null) "${formatRate(rate)} sat/vB · ${tier.etaLabel}" else tier.etaLabel, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + ) + } + } + + if (fees == null) { + Spacer(Modifier.height(4.dp)) + Text( + text = "Loading fee estimates…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp), + ) +} + +@Composable +private fun SendButton( + enabled: Boolean, + amountSats: Long?, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Icon( + symbol = MaterialSymbols.CurrencyBitcoin, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = + if (amountSats != null && amountSats > 0) { + "Send ${NumberFormat.getNumberInstance().format(amountSats)} sats" + } else { + "Send" + }, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun DoneButton( + label: String, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text(label, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun SendingState() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + color = MaterialTheme.colorScheme.bitcoinColor, + ) + Text( + text = "Building, signing and broadcasting…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SuccessBody(result: OnchainZapSendResult.Success) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.bitcoinColor, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.size(8.dp)) + Text( + text = "Onchain zap sent", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + ResultRow("Transaction", result.txid) + ResultRow("Fee", "${NumberFormat.getNumberInstance().format(result.feeSats)} sats") + if (result.changeSats > 0) { + ResultRow("Change", "${NumberFormat.getNumberInstance().format(result.changeSats)} sats") + } + } +} + +@Composable +private fun FailureBody(result: OnchainZapSendResult.Failure) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = result.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + result.broadcastTxid?.let { + Text( + text = "Payment was broadcast (tx $it) but the receipt was not published.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Failed at: ${result.stage.name.lowercase().replace('_', ' ')}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ResultRow( + label: String, + value: String, +) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun formatRate(rate: Double): String = if (rate == rate.toLong().toDouble()) rate.toLong().toString() else ((rate * 10).toLong() / 10.0).toString() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index 971628c5ba..1646694365 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -124,18 +124,24 @@ fun WalletScreen( } }, ) { padding -> - if (!hasWallet) { - NoWalletSetup( - modifier = Modifier.padding(padding), - nav = nav, - ) - } else { - MultiWalletHomeContent( - walletViewModel = walletViewModel, - modifier = Modifier.padding(padding), - listState = listState, - nav = nav, + Column(modifier = Modifier.padding(padding)) { + OnchainSection( + accountViewModel = accountViewModel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) + if (!hasWallet) { + NoWalletSetup( + modifier = Modifier, + nav = nav, + ) + } else { + MultiWalletHomeContent( + walletViewModel = walletViewModel, + modifier = Modifier, + listState = listState, + nav = nav, + ) + } } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt new file mode 100644 index 0000000000..31b26e07da --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip03Timestamp/BitcoinExplorerEndpointTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip03Timestamp + +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer +import org.junit.Assert.assertEquals +import org.junit.Test + +class BitcoinExplorerEndpointTest { + @Test + fun defaultsToMempoolWhenTorIsActive() { + assertEquals( + OkHttpBitcoinExplorer.MEMPOOL_API_URL, + BitcoinExplorerEndpoint.resolve(customExplorerUrl = null, usingTor = true), + ) + } + + @Test + fun defaultsToBlockstreamWhenTorIsInactive() { + assertEquals( + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL, + BitcoinExplorerEndpoint.resolve(customExplorerUrl = null, usingTor = false), + ) + } + + @Test + fun aConfiguredCustomUrlAlwaysWins() { + val custom = "https://my.esplora.example/api/" + assertEquals(custom, BitcoinExplorerEndpoint.resolve(custom, usingTor = true)) + assertEquals(custom, BitcoinExplorerEndpoint.resolve(custom, usingTor = false)) + } + + @Test + fun blankCustomUrlFallsBackToTheDefault() { + assertEquals( + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL, + BitcoinExplorerEndpoint.resolve(customExplorerUrl = " ", usingTor = false), + ) + } + + @Test + fun normalizedFormStripsTrailingSlashForPathJoining() { + assertEquals( + "https://my.esplora.example/api", + BitcoinExplorerEndpoint.resolveNormalized("https://my.esplora.example/api/", usingTor = true), + ) + // The mempool default carries a trailing slash; normalized form drops it + // so callers can safely do "$base/tx/$txid". + assertEquals( + OkHttpBitcoinExplorer.MEMPOOL_API_URL.trimEnd('/'), + BitcoinExplorerEndpoint.resolveNormalized(customExplorerUrl = null, usingTor = true), + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 9566969ca5..d4bf2a061c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -156,6 +156,15 @@ open class Note( var zapsAmount: BigDecimal = BigDecimal.ZERO + /** + * NIP-BC verified onchain zaps targeting this note. + * Key: Bitcoin txid (lowercase 64-char hex). Value: verified satoshis paid to the recipient + * (NOT the sender-claimed amount). Confirmed and pending entries live here together; + * `updateZapTotal` only counts confirmed amounts. + */ + var onchainZaps = mapOf() + private set + var zapPayments = mapOf() private set @@ -318,6 +327,7 @@ open class Note( boosts = listOf() reports = mapOf() zaps = mapOf() + onchainZaps = mapOf() zapPayments = mapOf() zapsAmount = BigDecimal.ZERO relays = listOf() @@ -417,6 +427,36 @@ open class Note( } } + @Synchronized + private fun innerAddOnchainZap( + txid: String, + amount: OnchainZapAmount, + ): Boolean { + val existing = onchainZaps[txid] + // Allow upgrading pending → confirmed but never reduce already-stored confirmations. + if (existing != null && existing.confirmed && !amount.confirmed) return false + if (existing == amount) return false + onchainZaps = onchainZaps + Pair(txid, amount) + return true + } + + /** + * Register a NIP-BC verified onchain zap targeting this note. `verifiedSats` MUST come + * from on-chain verification (sum of outputs paying the recipient's derived Taproot + * address), not the sender-claimed `amount` tag. + */ + fun addOnchainZap( + txid: String, + verifiedSats: Long, + confirmed: Boolean, + ) { + val inserted = innerAddOnchainZap(txid, OnchainZapAmount(verifiedSats, confirmed)) + if (inserted) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + @Synchronized private fun innerAddZapPayment( zapPaymentRequest: Note, @@ -629,6 +669,14 @@ open class Note( } } + // NIP-BC onchain zaps — verified amounts only, confirmed only. + // Pending/unconfirmed entries are tracked but excluded from the total per spec. + onchainZaps.values.forEach { entry -> + if (entry.confirmed) { + sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats) + } + } + zapsAmount = sumOfAmounts } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt new file mode 100644 index 0000000000..d7c4bcb65b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +import androidx.compose.runtime.Immutable + +/** + * Per-(note, txid) verified NIP-BC onchain zap state. + * + * @property verifiedSats Satoshis verified to have paid the recipient's derived Taproot + * address on chain. NEVER the sender-claimed `amount` tag. + * @property confirmed True when the transaction has at least one confirmation. Unconfirmed + * zaps are tracked but excluded from aggregate totals per the NIP-BC + * spec ("Unconfirmed transactions MAY be displayed as pending... + * SHOULD either exclude them from aggregate totals or clearly label + * them as pending"). + */ +@Immutable +data class OnchainZapAmount( + val verifiedSats: Long, + val confirmed: Boolean, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt new file mode 100644 index 0000000000..2b57828fcb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.onchain + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSignatureVerifier +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputTapKeySig +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputTapKeySig +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import kotlin.coroutines.cancellation.CancellationException + +/** The stage a NIP-BC onchain zap send reached before it finished or failed. */ +enum class OnchainZapSendStage { + /** Querying the chain backend for the sender's spendable UTXOs. */ + LOADING_UTXOS, + + /** Selecting coins and assembling the unsigned PSBT. */ + BUILDING, + + /** Signing the PSBT inputs and finalizing the transaction. */ + SIGNING, + + /** Broadcasting the signed transaction to the network. */ + BROADCASTING, + + /** Publishing the kind:8333 zap receipt to relays. */ + PUBLISHING, +} + +/** Outcome of an [OnchainZapSender.send] attempt. */ +sealed interface OnchainZapSendResult { + /** + * The transaction was broadcast and the zap receipt was published. + * + * @property txid The broadcast Bitcoin transaction id. + * @property receiptEventId The id of the published kind:8333 event. + * @property feeSats Miner fee paid. + * @property changeSats Change returned to the sender (0 if none). + */ + data class Success( + val txid: String, + val receiptEventId: HexKey, + val feeSats: Long, + val changeSats: Long, + ) : OnchainZapSendResult + + /** + * The send failed at [stage]. The transaction was NOT broadcast unless + * [stage] is [OnchainZapSendStage.PUBLISHING], in which case the payment + * went through but the receipt could not be published. + */ + data class Failure( + val stage: OnchainZapSendStage, + val message: String, + val cause: Throwable? = null, + /** Non-null when the payment was broadcast but a later stage failed. */ + val broadcastTxid: String? = null, + ) : OnchainZapSendResult +} + +/** + * Orchestrates a NIP-BC onchain zap end to end: + * load UTXOs → build PSBT → sign → finalize → broadcast → publish kind:8333. + * + * Stateless and platform-agnostic — it takes the chain backend, the signer, + * and a publish callback, so the same pipeline works from the Android app, the + * CLI, or tests. Per-stage failures are reported as + * [OnchainZapSendResult.Failure] rather than thrown, except [CancellationException] + * which always propagates. + */ +object OnchainZapSender { + /** + * @param backend Chain data source (UTXOs + broadcast). + * @param signer The sender's signer; must support `signPsbt`. + * @param senderPubKey The sender's x-only Nostr pubkey (hex). + * @param recipientPubKey The recipient's x-only Nostr pubkey (hex). + * @param amountSats Amount to pay the recipient. + * @param feeRateSatPerVByte Target fee rate. + * @param comment Optional human-readable comment for the receipt's content. + * @param zappedEvent The event being zapped, or null for a profile zap. + * @param publish Publishes the signed kind:8333 receipt and returns the event. + */ + suspend fun send( + backend: OnchainBackend, + signer: NostrSigner, + senderPubKey: HexKey, + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + comment: String, + zappedEvent: EventHintBundle?, + publish: suspend (EventTemplate) -> Event, + ): OnchainZapSendResult { + // 1. Load the sender's UTXOs. + val utxos = + try { + val address = TaprootAddress.fromPubKey(senderPubKey) + backend.getUtxosForAddress(address) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.LOADING_UTXOS, "Could not load your Bitcoin balance", e) + } + + // 2. Coin-select and assemble the unsigned PSBT. + val built = + try { + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + availableUtxos = utxos, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BUILDING, e.message ?: "Could not build the transaction", e) + } + + // 3. Sign, verify the signer didn't tamper, and finalize. + val rawTxHex = + try { + val signedHex = signer.signPsbt(built.psbt.toHex()) + val signedPsbt = Psbt.parse(signedHex) + + // Fund-safety: the signer must ONLY contribute signatures. First + // reject anything whose unsigned transaction isn't byte-identical + // to ours — that gives a clear error for the substitution attack. + val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) + val returnedTx = signedPsbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) + if (expectedTx == null || returnedTx == null || !expectedTx.contentEquals(returnedTx)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signer returned a different transaction than the one it was asked to sign", + ) + } + + // Copy ONLY the signatures back onto the PSBT we built. Everything + // else used downstream (witness UTXOs, tap internal keys) stays the + // values WE chose, so a signer can never influence the sighash, the + // verified output keys, or where funds go. + built.psbt.unsignedTx.inputs.indices.forEach { i -> + val sig = + signedPsbt.inputTapKeySig(i) + ?: return fail( + OnchainZapSendStage.SIGNING, + "The signer did not sign every input", + ) + built.psbt.setInputTapKeySig(i, sig) + } + + // Verify every signature is actually valid before money moves — + // catches a broken signer up front instead of a doomed broadcast. + if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(built.psbt)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signed transaction has invalid signatures", + ) + } + PsbtFinalizer.finalizeToHex(built.psbt) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.SIGNING, e.message ?: "Could not sign the transaction", e) + } + + // 4. Broadcast. + val txid = + try { + backend.broadcast(rawTxHex) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BROADCASTING, "Could not broadcast the transaction", e) + } + + // 5. Publish the kind:8333 receipt. The payment is already on-chain at + // this point, so a failure here keeps the txid for retry/diagnostics. + val receiptId = + try { + val template = + if (zappedEvent != null) { + OnchainZapEvent.build(txid, recipientPubKey, amountSats, zappedEvent, comment) + } else { + OnchainZapEvent.buildProfileZap(txid, recipientPubKey, amountSats, comment) + } + publish(template).id + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return OnchainZapSendResult.Failure( + stage = OnchainZapSendStage.PUBLISHING, + message = "Payment sent, but the zap receipt could not be published", + cause = e, + broadcastTxid = txid, + ) + } + + return OnchainZapSendResult.Success( + txid = txid, + receiptEventId = receiptId, + feeSats = built.feeSats, + changeSats = built.changeSats, + ) + } + + private fun fail( + stage: OnchainZapSendStage, + message: String, + cause: Throwable? = null, + ) = OnchainZapSendResult.Failure(stage, message, cause) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt new file mode 100644 index 0000000000..3f00933cd1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.onchain + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OnchainZapSenderTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val recipientPriv = "000000000000000000000000000000000000000000000000000000000000000d" + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val senderSigner = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val senderPubKey = xOnly(senderPriv) + private val recipientPubKey = xOnly(recipientPriv) + + /** Records the broadcast tx and hands back its real txid. */ + private class FakeBackend( + private val utxos: List, + private val broadcastFails: Boolean = false, + ) : OnchainBackend { + var broadcastedHex: String? = null + + override suspend fun getTx(txid: String): BitcoinTx? = null + + override suspend fun getUtxosForAddress(address: String): List = utxos + + override suspend fun broadcast(rawTxHex: String): String { + if (broadcastFails) throw RuntimeException("relay rejected tx") + broadcastedHex = rawTxHex + return BitcoinTransaction.parse(rawTxHex).txid() + } + + override suspend fun tipHeight(): Long = 800_000L + + override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0) + } + + @Test + fun profileZapSuccess() = + runTest { + val backend = FakeBackend(listOf(Utxo("1".repeat(64), 0, 250_000L, 6))) + var publishedTemplate: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate? = null + + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "thanks!", + zappedEvent = null, + ) { template -> + publishedTemplate = template + senderSigner.sign(template) + } + + assertIs(result) + assertTrue(result.feeSats > 0) + + // The broadcast transaction's txid must match what the receipt references. + val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() + assertEquals(broadcastTxid, result.txid) + + // The published kind:8333 receipt must reference the same txid, recipient, amount. + val receipt = senderSigner.sign(publishedTemplate!!) + assertEquals(OnchainZapEvent.KIND, receipt.kind) + assertEquals(broadcastTxid, receipt.txid()) + assertEquals(recipientPubKey, receipt.recipient()) + assertEquals(50_000L, receipt.claimedAmountInSats()) + assertTrue(receipt.isProfileZap()) + } + + @Test + fun insufficientFundsFailsAtBuilding() = + runTest { + val backend = FakeBackend(listOf(Utxo("2".repeat(64), 0, 10_000L, 6))) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 1_000_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + assertIs(result) + assertEquals(OnchainZapSendStage.BUILDING, result.stage) + assertEquals(null, result.broadcastTxid) + } + + @Test + fun broadcastFailureKeepsNoTxid() = + runTest { + val backend = + FakeBackend(listOf(Utxo("3".repeat(64), 0, 250_000L, 6)), broadcastFails = true) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + assertIs(result) + assertEquals(OnchainZapSendStage.BROADCASTING, result.stage) + assertEquals(null, result.broadcastTxid) + } + + @Test + fun publishFailureReportsBroadcastTxid() = + runTest { + val backend = FakeBackend(listOf(Utxo("4".repeat(64), 0, 250_000L, 6))) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { throw RuntimeException("relay down") } + + assertIs(result) + assertEquals(OnchainZapSendStage.PUBLISHING, result.stage) + // The payment went through — the txid is preserved for retry/diagnostics. + val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() + assertEquals(broadcastTxid, result.broadcastTxid) + } + + /** + * A signer that ignores the PSBT it was handed and instead signs a + * completely different transaction of its own — the substitution attack. + */ + private class TamperingSigner( + private val inner: NostrSignerInternal, + private val attackerControlledPubKey: HexKey, + ) : NostrSigner(inner.pubKey) { + override fun isWriteable() = inner.isWriteable() + + override fun hasForegroundSupport() = inner.hasForegroundSupport() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = inner.sign(createdAt, kind, tags, content) + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = inner.nip04Encrypt(plaintext, toPublicKey) + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = inner.nip04Decrypt(ciphertext, fromPublicKey) + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = inner.nip44Encrypt(plaintext, toPublicKey) + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = inner.nip44Decrypt(ciphertext, fromPublicKey) + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event) + + override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + + override suspend fun signPsbt(psbtHex: String): String { + // Discard the requested PSBT entirely; build and sign one that pays + // the attacker instead, then hand it back as if it were the answer. + val malicious = + OnchainZapBuilder.build( + senderPubKey = inner.pubKey, + recipientPubKey = attackerControlledPubKey, + amountSats = 200_000L, + feeRateSatPerVByte = 1.0, + availableUtxos = listOf(Utxo("9".repeat(64), 0, 250_000L, 6)), + ) + PsbtSigner.signKeyPathInputs(malicious.psbt, inner.keyPair.privKey!!) + return malicious.psbt.toHex() + } + } + + @Test + fun rejectsASignerThatReturnsADifferentTransaction() = + runTest { + val backend = FakeBackend(listOf(Utxo("5".repeat(64), 0, 250_000L, 6))) + val tamperingSigner = TamperingSigner(senderSigner, attackerControlledPubKey = recipientPubKey) + + val result = + OnchainZapSender.send( + backend = backend, + signer = tamperingSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + // The substituted transaction must be rejected at the signing stage, + // and nothing must have been broadcast. + assertIs(result) + assertEquals(OnchainZapSendStage.SIGNING, result.stage) + assertEquals(null, backend.broadcastedHex) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 18fd08dbf1..10293574a4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -592,6 +592,8 @@ class NestViewModelTest { override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = error("not used") override suspend fun deriveKey(nonce: String): String = error("not used") + + override suspend fun signPsbt(psbtHex: String): String = error("not used") } companion object { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt index ebe0c9f1fa..78f63195e6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/CommandType.kt @@ -31,6 +31,7 @@ enum class CommandType( GET_PUBLIC_KEY("get_public_key"), DECRYPT_ZAP_EVENT("decrypt_zap_event"), DERIVE_KEY("derive_key"), + SIGN_PSBT("sign_psbt"), ; companion object { @@ -44,6 +45,7 @@ enum class CommandType( GET_PUBLIC_KEY.code -> GET_PUBLIC_KEY DECRYPT_ZAP_EVENT.code -> DECRYPT_ZAP_EVENT DERIVE_KEY.code -> DERIVE_KEY + SIGN_PSBT.code -> SIGN_PSBT else -> null } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt index 89ed53e860..2191d6a903 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt @@ -89,3 +89,7 @@ data class ZapEventDecryptionResult( data class DerivationResult( val newPrivKey: HexKey, ) : IResult + +data class SignPsbtResult( + val signedPsbtHex: String, +) : IResult diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignPsbtQuery.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignPsbtQuery.kt new file mode 100644 index 0000000000..b40d2f8ae8 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/SignPsbtQuery.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query + +/** + * NIP-BC `sign_psbt` background (ContentResolver) query. + * + * Passes the unsigned/partially-signed PSBT (lowercase hex) to the external + * signer app and expects the updated PSBT back in the `result` column. The + * signer signs each input whose `tapInternalKey` matches the user's pubkey; + * it does NOT finalize the PSBT. + */ +class SignPsbtQuery( + val loggedInUser: HexKey, + val packageName: String, + val contentResolver: ContentResolver, +) { + val uri = "content://$packageName.${CommandType.SIGN_PSBT}".toUri() + + fun query(psbtHex: String): SignerResult = + contentResolver.query( + uri, + arrayOf(psbtHex, loggedInUser), + ) { cursor -> + val signedPsbtHex = cursor.getStringByName("result") + if (!signedPsbtHex.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(SignPsbtResult(signedPsbtHex)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignPsbtRequest.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignPsbtRequest.kt new file mode 100644 index 0000000000..dc48835349 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/SignPsbtRequest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests + +import android.content.Intent +import androidx.core.net.toUri +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType + +/** + * NIP-BC `sign_psbt` foreground Intent request. + * + * Carries the PSBT (lowercase hex) as the `nostrsigner:` URI data so the + * signer app can display the inputs/outputs to the user for confirmation. + */ +class SignPsbtRequest { + companion object { + fun assemble( + psbtHex: String, + loggedInUser: HexKey, + packageName: String, + ): Intent { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$psbtHex".toUri()) + intent.`package` = packageName + intent.putExtra("type", CommandType.SIGN_PSBT.code) + intent.putExtra("current_user", loggedInUser) + return intent + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignPsbtResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignPsbtResponse.kt new file mode 100644 index 0000000000..60b820e787 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/SignPsbtResponse.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses + +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult + +/** + * Parses the external signer's `sign_psbt` Intent reply. The `result` field + * carries the updated (signed, not finalized) PSBT as lowercase hex. + */ +class SignPsbtResponse { + companion object { + fun assemble(signedPsbtHex: String): IntentResult = + IntentResult( + result = signedPsbtHex, + ) + + fun parse(intent: IntentResult): SignerResult.RequestAddressed { + if (intent.rejected == true) { + return SignerResult.RequestAddressed.ManuallyRejected() + } + val signedPsbtHex = intent.result + return if (!signedPsbtHex.isNullOrBlank()) { + SignerResult.RequestAddressed.Successful(SignPsbtResult(signedPsbtHex)) + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt index 64551b128b..2da61eba2a 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult @@ -174,6 +175,25 @@ class NostrSignerExternal( throw convertExceptions("Could not decrypt private zap", result) } + /** + * NIP-BC `sign_psbt` over NIP-55. Sends the PSBT (lowercase hex) to the + * external signer app, which signs each input whose `tapInternalKey` + * matches the user's pubkey and returns the updated (not finalized) PSBT. + * + * Signer apps that predate `sign_psbt` support reply with no `result`, + * which surfaces here as [SignerExceptions.CouldNotPerformException] — + * callers should treat that as "update your signer". + */ + override suspend fun signPsbt(psbtHex: String): String { + val result = backgroundQuery.signPsbt(psbtHex) ?: foregroundQuery.signPsbt(psbtHex) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.signedPsbtHex + } + + throw convertExceptions("Could not sign PSBT", result) + } + // always ready override fun hasForegroundSupport() = hasForegroundActivity() diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt index 7016cfdbe0..d6d7cec689 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.DecryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.DerivationResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.EncryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult +import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignPsbtResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult @@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04D import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip04EncryptQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44DecryptQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44EncryptQuery +import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignPsbtQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignQuery import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -53,6 +55,7 @@ class BackgroundRequestHandler( val nip44Decrypt = Nip44DecryptQuery(loggedInUser, packageName, contentResolver) val decryptZap = DecryptZapQuery(loggedInUser, packageName, contentResolver) val deriveKey = DeriveKeyQuery(loggedInUser, packageName, contentResolver) + val signPsbt = SignPsbtQuery(loggedInUser, packageName, contentResolver) fun login() = login.query() as? SignerResult.RequestAddressed @@ -81,4 +84,6 @@ class BackgroundRequestHandler( fun decryptZapEvent(event: LnZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed fun deriveKey(nonce: HexKey) = deriveKey.query(nonce) as? SignerResult.RequestAddressed + + fun signPsbt(psbtHex: String) = signPsbt.query(psbtHex) as? SignerResult.RequestAddressed } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt index b07da3122e..78841f3172 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.reques import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip04EncryptRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44DecryptRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.Nip44EncryptRequest +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.SignPsbtRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.requests.SignRequest import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DecryptZapResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.DeriveKeyResponse @@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.respon import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip04EncryptResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44DecryptResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44EncryptResponse +import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignPsbtResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -95,4 +97,10 @@ class ForegroundRequestHandler( requestIntentBuilder = { DeriveKeyRequest.assemble(nonce, loggedInUser, packageName) }, parser = DeriveKeyResponse::parse, ) + + suspend fun signPsbt(psbtHex: String) = + launcher.launchWaitAndParse( + requestIntentBuilder = { SignPsbtRequest.assemble(psbtHex, loggedInUser, packageName) }, + parser = SignPsbtResponse::parse, + ) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt index 725c8fedfc..efc5276dad 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt @@ -64,4 +64,14 @@ actual object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + actual fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray { + val full = if (pubKey.size == 32) h02 + pubKey else pubKey + return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) + } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1.privKeyNegate(privKey) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index 6b44ea6f83..fd5fd2e0d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -64,6 +64,19 @@ abstract class NostrSigner( abstract suspend fun deriveKey(nonce: HexKey): HexKey + /** + * NIP-BC `sign_psbt`: sign the key-path P2TR inputs of [psbtHex] that this + * signer's key controls and return the updated PSBT as lowercase hex. + * + * The signer adds `PSBT_IN_TAP_KEY_SIG` records; it does NOT finalize the + * PSBT — finalization and broadcast are the client's responsibility. + * + * Throws [SignerExceptions.UnsupportedMethodException] for signer kinds + * that have not implemented the method yet (remote NIP-46 bunkers, NIP-55 + * external signers that predate `sign_psbt` support). + */ + abstract suspend fun signPsbt(psbtHex: String): String + abstract fun hasForegroundSupport(): Boolean suspend fun decrypt( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt index ea1b167c80..b308afbd06 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt @@ -101,4 +101,9 @@ class NostrSignerInternal( runWrapErrors { signerSync.deriveKey(nonce) } + + override suspend fun signPsbt(psbtHex: String): String = + runWrapErrors { + signerSync.signPsbt(psbtHex) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt index dc626ee9b5..9ca2935bf5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner class NostrSignerSync( val keyPair: KeyPair = KeyPair(), @@ -129,6 +131,17 @@ class NostrSignerSync( fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this) + /** + * NIP-BC `sign_psbt`: sign the key-path P2TR inputs of [psbtHex] this key + * controls and return the updated (not finalized) PSBT as lowercase hex. + */ + fun signPsbt(psbtHex: String): String { + val privKey = keyPair.privKey ?: throw SignerExceptions.ReadOnlyException() + val psbt = Psbt.parse(psbtHex) + PsbtSigner.signKeyPathInputs(psbt, privKey) + return psbt.toHex() + } + fun deriveKey(nonce: HexKey): HexKey { if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt index 1e71ccd92b..f3d14320fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt @@ -59,4 +59,10 @@ sealed class SignerExceptions( msg: String, cause: Throwable? = null, ) : SignerExceptions(msg) + + /** The signer cannot perform the requested method (e.g. a bunker that has not shipped `sign_psbt`). */ + class UnsupportedMethodException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg, cause) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 2163b42e8f..7c36acbb8d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -278,6 +278,16 @@ class NostrSignerRemote( TODO("Not yet implemented") } + /** + * NIP-BC `sign_psbt` over NIP-46. The bunker-side command is not yet + * standardized/shipped, so this is intentionally unsupported until the + * remote-signer ecosystem catches up. + */ + override suspend fun signPsbt(psbtHex: String): String = + throw SignerExceptions.UnsupportedMethodException( + "Remote (NIP-46) signers do not support sign_psbt yet", + ) + override fun hasForegroundSupport(): Boolean = true fun convertExceptions( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt index 5856a0ebf3..2da2479daa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -98,6 +98,8 @@ class NostrSignerWithClientTag( override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + override suspend fun signPsbt(psbtHex: String): String = inner.signPsbt(psbtHex) + override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport() private fun appendClientTag(tags: Array>): Array> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt new file mode 100644 index 0000000000..64d969cdd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.builder + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.OutPoint +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxIn +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputTapInternalKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputWitnessUtxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setOutputTapInternalKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlin.math.ceil + +/** + * Assembles the unsigned [Psbt] for a NIP-BC onchain zap. + * + * The sender's whole "wallet" is the single Taproot address derived from their + * Nostr pubkey, so every input spends from — and any change returns to — that + * one address. The recipient output pays the recipient's derived Taproot + * address. + * + * Coin selection is a simple largest-first greedy fill: correct and + * predictable, not privacy- or fee-optimal. The result is an unsigned PSBT + * with `PSBT_IN_WITNESS_UTXO` and `PSBT_IN_TAP_INTERNAL_KEY` populated on + * every input, ready for `NostrSigner.signPsbt`. + */ +object OnchainZapBuilder { + /** P2TR outputs below this are unspendable dust and must not be created. */ + const val DUST_THRESHOLD_SATS = 330L + + /** + * nSequence that opts the transaction into BIP-125 replace-by-fee, so a + * zap stuck at a low fee rate can be bumped instead of being stuck forever. + */ + const val RBF_SEQUENCE = 0xFFFFFFFDL + + // Virtual-size estimates for an all-P2TR-key-path transaction. + private const val OVERHEAD_VBYTES = 10.5 + private const val P2TR_INPUT_VBYTES = 57.5 + private const val P2TR_OUTPUT_VBYTES = 43.0 + + /** + * @property psbt The unsigned PSBT, ready to sign. + * @property selectedUtxos The UTXOs chosen as inputs. + * @property recipientSats Amount paid to the recipient. + * @property changeSats Amount returned to the sender (0 if no change output). + * @property feeSats The miner fee. + */ + data class Result( + val psbt: Psbt, + val selectedUtxos: List, + val recipientSats: Long, + val changeSats: Long, + val feeSats: Long, + ) + + fun estimateVsize( + inputCount: Int, + outputCount: Int, + ): Double = OVERHEAD_VBYTES + inputCount * P2TR_INPUT_VBYTES + outputCount * P2TR_OUTPUT_VBYTES + + fun estimateFee( + inputCount: Int, + outputCount: Int, + feeRateSatPerVByte: Double, + ): Long = ceil(estimateVsize(inputCount, outputCount) * feeRateSatPerVByte).toLong() + + /** + * Build the unsigned onchain-zap PSBT. + * + * @param senderPubKey The sender's 32-byte x-only Nostr pubkey (hex). + * @param recipientPubKey The recipient's 32-byte x-only Nostr pubkey (hex). + * @param amountSats Amount to pay the recipient. + * @param feeRateSatPerVByte Target fee rate. + * @param availableUtxos UTXOs spendable from the sender's Taproot address. + * @param allowUnconfirmed When false (the default), 0-confirmation UTXOs are + * excluded — chaining off an unconfirmed parent risks the whole zap + * being invalidated if that parent is dropped or replaced. + * @throws InsufficientFundsException when the UTXOs can't cover amount + fee. + */ + fun build( + senderPubKey: HexKey, + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + availableUtxos: List, + allowUnconfirmed: Boolean = false, + ): Result { + require(amountSats > 0) { "amount must be positive" } + require(amountSats >= DUST_THRESHOLD_SATS) { "amount is below the dust threshold" } + require(feeRateSatPerVByte > 0) { "fee rate must be positive" } + require(senderPubKey != recipientPubKey) { "cannot zap yourself" } + + val senderXOnly = senderPubKey.hexToByteArray() + require(senderXOnly.size == 32) { "sender pubkey must be 32 bytes" } + val senderScript = TaprootAddress.scriptPubKeyForRecipient(senderPubKey) + val recipientScript = TaprootAddress.scriptPubKeyForRecipient(recipientPubKey) + + // Only spend confirmed UTXOs unless the caller explicitly opts in. + val spendableUtxos = + if (allowUnconfirmed) availableUtxos else availableUtxos.filter { it.confirmations > 0 } + + // Largest-first greedy selection. + val sorted = spendableUtxos.sortedByDescending { it.valueSats } + val selected = ArrayList() + var selectedSum = 0L + var cursor = 0 + + while (true) { + val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= amountSats + feeWithChange) break + + if (cursor >= sorted.size) { + // Last chance: maybe it fits without a change output. + val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= amountSats + feeNoChange) break + throw InsufficientFundsException( + needed = amountSats + estimateFee(selected.size.coerceAtLeast(1), 2, feeRateSatPerVByte), + available = spendableUtxos.sumOf { it.valueSats }, + ) + } + selected.add(sorted[cursor]) + selectedSum += sorted[cursor].valueSats + cursor++ + } + + // Decide whether a change output is worth creating. + val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) + val candidateChange = selectedSum - amountSats - feeWithChange + + val feeSats: Long + val changeSats: Long + if (candidateChange >= DUST_THRESHOLD_SATS) { + feeSats = feeWithChange + changeSats = candidateChange + } else { + // Drop the change output; the leftover (dust + would-be change) is + // absorbed into the fee. + val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) + val leftover = selectedSum - amountSats + if (leftover < feeNoChange) { + throw InsufficientFundsException( + needed = amountSats + feeNoChange, + available = spendableUtxos.sumOf { it.valueSats }, + ) + } + feeSats = leftover + changeSats = 0L + } + + // Assemble the unsigned transaction. + val inputs = + selected.map { utxo -> + TxIn( + outPoint = OutPoint(utxo.txid, utxo.vout.toLong()), + scriptSig = ByteArray(0), + sequence = RBF_SEQUENCE, + ) + } + val outputs = ArrayList(2) + outputs.add(TxOut(amountSats, recipientScript)) + if (changeSats > 0) { + outputs.add(TxOut(changeSats, senderScript)) + } + + val tx = BitcoinTransaction(version = 2L, inputs = inputs, outputs = outputs, lockTime = 0L) + + // Wrap into a PSBT and populate the signing metadata. + val psbt = Psbt.fromUnsignedTx(tx) + selected.forEachIndexed { i, utxo -> + psbt.setInputWitnessUtxo(i, TxOut(utxo.valueSats, senderScript)) + psbt.setInputTapInternalKey(i, senderXOnly) + } + if (changeSats > 0) { + psbt.setOutputTapInternalKey(1, senderXOnly) + } + + return Result( + psbt = psbt, + selectedUtxos = selected, + recipientSats = amountSats, + changeSats = changeSats, + feeSats = feeSats, + ) + } +} + +/** + * Thrown when the available UTXOs cannot cover the requested amount plus fee. + * + * @property needed Total satoshis required (amount + estimated fee). + * @property available Total satoshis available across all UTXOs. + */ +class InsufficientFundsException( + val needed: Long, + val available: Long, +) : RuntimeException("Insufficient funds: need $needed sats, have $available sats") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt new file mode 100644 index 0000000000..f492bfee83 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.chain + +import androidx.compose.runtime.Immutable + +/** + * Minimal view of a confirmed or mempool Bitcoin transaction — just the + * fields NIP-BC verification needs. + * + * @property txid 64-char lowercase hex transaction id + * @property outputs Output list (index = vout) + * @property confirmations 0 if unconfirmed; height-based when known + * @property blockHashHex Hash of the block that confirmed this tx, if any + * @property blockHeight Height of the block that confirmed this tx, if any + */ +@Immutable +data class BitcoinTx( + val txid: String, + val outputs: List, + val confirmations: Int, + val blockHashHex: String? = null, + val blockHeight: Long? = null, +) + +/** + * One output of a Bitcoin transaction. + * + * @property index vout index + * @property valueSats output value in satoshis + * @property scriptPubKeyHex lowercase-hex scriptPubKey bytes + */ +@Immutable +data class BitcoinTxOutput( + val index: Int, + val valueSats: Long, + val scriptPubKeyHex: String, +) + +/** + * An unspent transaction output the wallet can spend. + * + * @property txid 64-char lowercase hex of the funding transaction + * @property vout output index in the funding transaction + * @property valueSats value in satoshis + * @property confirmations confirmation count (0 for mempool) + */ +@Immutable +data class Utxo( + val txid: String, + val vout: Int, + val valueSats: Long, + val confirmations: Int, +) + +/** + * Recommended fee rates in sats per vbyte, as reported by the backend. + */ +@Immutable +data class FeeEstimates( + val fastSatPerVbyte: Double, + val normalSatPerVbyte: Double, + val slowSatPerVbyte: Double, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt new file mode 100644 index 0000000000..3ca48b80e8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.chain + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * An [OnchainBackend] decorator that caches read-only lookups so a feed full + * of NIP-BC zaps doesn't fan out into one HTTP request per event. + * + * Caching policy: + * - `getTx`: a **confirmed** transaction is immutable, so it's cached + * indefinitely; an unconfirmed one is cached only briefly (its confirmation + * status will change). `null` (not-found) is never cached — the tx may + * appear later. The tx cache is bounded ([maxCachedTxs]); when full, the + * oldest entry is evicted so a long session can't leak memory. + * - `tipHeight` / `feeEstimates`: cached with a short TTL. + * - `getUtxosForAddress`: never cached — wallet balance must be fresh. + * - `broadcast`: never cached. + * + * The delegate call is made **outside** the lock, so concurrent lookups still + * run in parallel; a brief window where two callers fetch the same txid is + * accepted (it only wastes a request, never returns wrong data). + */ +class CachingOnchainBackend( + private val delegate: OnchainBackend, + private val unconfirmedTxTtlSeconds: Long = 60, + private val tipHeightTtlSeconds: Long = 60, + private val feeEstimatesTtlSeconds: Long = 60, + private val maxCachedTxs: Int = 512, + private val nowSeconds: () -> Long = { TimeUtils.now() }, +) : OnchainBackend { + private class Stamped( + val value: T, + val fetchedAt: Long, + ) + + private val mutex = Mutex() + private val txCache = mutableMapOf>() + private var tipCache: Stamped? = null + private var feeCache: Stamped? = null + + override suspend fun getTx(txid: String): BitcoinTx? { + mutex.withLock { + val cached = txCache[txid] + if (cached != null) { + val stillFresh = + cached.value.confirmations > 0 || + nowSeconds() - cached.fetchedAt < unconfirmedTxTtlSeconds + if (stillFresh) return cached.value + } + } + + val fetched = delegate.getTx(txid) ?: return null + + mutex.withLock { + if (txCache.size >= maxCachedTxs && !txCache.containsKey(txid)) { + // Evict the oldest entry to keep the cache bounded. + txCache.minByOrNull { it.value.fetchedAt }?.key?.let { txCache.remove(it) } + } + txCache[txid] = Stamped(fetched, nowSeconds()) + } + return fetched + } + + override suspend fun getUtxosForAddress(address: String): List = delegate.getUtxosForAddress(address) + + override suspend fun broadcast(rawTxHex: String): String = delegate.broadcast(rawTxHex) + + override suspend fun tipHeight(): Long { + mutex.withLock { + tipCache?.let { + if (nowSeconds() - it.fetchedAt < tipHeightTtlSeconds) return it.value + } + } + val fetched = delegate.tipHeight() + mutex.withLock { tipCache = Stamped(fetched, nowSeconds()) } + return fetched + } + + override suspend fun feeEstimates(): FeeEstimates { + mutex.withLock { + feeCache?.let { + if (nowSeconds() - it.fetchedAt < feeEstimatesTtlSeconds) return it.value + } + } + val fetched = delegate.feeEstimates() + mutex.withLock { feeCache = Stamped(fetched, nowSeconds()) } + return fetched + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt new file mode 100644 index 0000000000..8610ea146a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.chain + +/** + * Pluggable Bitcoin chain data source. + * + * Implementations talk to an Esplora-compatible HTTP API, a Bitcoin Core + * node, an Electrum server, or a local SPV verifier. + */ +interface OnchainBackend { + /** Fetch a transaction by txid. Returns null if the backend has no record of it. */ + suspend fun getTx(txid: String): BitcoinTx? + + /** Fetch the spendable UTXOs paying `address` (bech32m taproot for NIP-BC). */ + suspend fun getUtxosForAddress(address: String): List + + /** Broadcast a fully signed transaction (lowercase hex). Returns the txid. */ + suspend fun broadcast(rawTxHex: String): String + + /** Current chain tip height. */ + suspend fun tipHeight(): Long + + /** Recommended fee rates from the backend. */ + suspend fun feeEstimates(): FeeEstimates +} + +/** + * Thrown by [OnchainBackend] implementations when the underlying network or + * remote API fails. Wraps the original cause where useful. + */ +class OnchainBackendException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt new file mode 100644 index 0000000000..3e4c3d2777 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.utils.ByteArrayOutputStream + +/** + * Little-endian byte writer for Bitcoin consensus serialization (transactions, + * PSBT maps). All multi-byte integers are written little-endian, matching the + * Bitcoin wire format. + */ +class BitcoinWriter( + initialSize: Int = 256, +) { + private val out = ByteArrayOutputStream(initialSize) + + fun writeByte(value: Int): BitcoinWriter { + out.write((value and 0xFF).toByte()) + return this + } + + fun writeBytes(bytes: ByteArray): BitcoinWriter { + out.write(bytes) + return this + } + + fun writeUInt16LE(value: Int): BitcoinWriter { + out.write((value and 0xFF).toByte()) + out.write(((value ushr 8) and 0xFF).toByte()) + return this + } + + fun writeUInt32LE(value: Long): BitcoinWriter { + out.write((value and 0xFF).toByte()) + out.write(((value ushr 8) and 0xFF).toByte()) + out.write(((value ushr 16) and 0xFF).toByte()) + out.write(((value ushr 24) and 0xFF).toByte()) + return this + } + + fun writeUInt64LE(value: Long): BitcoinWriter { + var v = value + for (i in 0 until 8) { + out.write((v and 0xFF).toByte()) + v = v ushr 8 + } + return this + } + + /** Bitcoin compact-size (varint) encoding. */ + fun writeVarInt(value: Long): BitcoinWriter { + when { + value < 0xFD -> { + writeByte(value.toInt()) + } + + value <= 0xFFFF -> { + writeByte(0xFD) + writeUInt16LE(value.toInt()) + } + + value <= 0xFFFFFFFFL -> { + writeByte(0xFE) + writeUInt32LE(value) + } + + else -> { + writeByte(0xFF) + writeUInt64LE(value) + } + } + return this + } + + /** Length-prefixed (varint) byte string. */ + fun writeVarBytes(bytes: ByteArray): BitcoinWriter { + writeVarInt(bytes.size.toLong()) + writeBytes(bytes) + return this + } + + fun toByteArray(): ByteArray = out.toByteArray() +} + +/** + * Little-endian byte reader, the inverse of [BitcoinWriter]. Throws + * [PsbtParseException] on truncated input. + */ +class BitcoinReader( + private val data: ByteArray, + private var pos: Int = 0, +) { + val remaining: Int get() = data.size - pos + + val isAtEnd: Boolean get() = pos >= data.size + + private fun require(n: Int) { + if (remaining < n) { + throw PsbtParseException("Unexpected end of data: needed $n, have $remaining") + } + } + + fun readByte(): Int { + require(1) + return data[pos++].toInt() and 0xFF + } + + fun readBytes(n: Int): ByteArray { + require(n) + val slice = data.copyOfRange(pos, pos + n) + pos += n + return slice + } + + fun readUInt16LE(): Int { + require(2) + val v = (data[pos].toInt() and 0xFF) or ((data[pos + 1].toInt() and 0xFF) shl 8) + pos += 2 + return v + } + + fun readUInt32LE(): Long { + require(4) + var v = 0L + for (i in 0 until 4) { + v = v or ((data[pos + i].toLong() and 0xFF) shl (8 * i)) + } + pos += 4 + return v + } + + fun readUInt64LE(): Long { + require(8) + var v = 0L + for (i in 0 until 8) { + v = v or ((data[pos + i].toLong() and 0xFF) shl (8 * i)) + } + pos += 8 + return v + } + + fun readVarInt(): Long { + val first = readByte() + return when (first) { + 0xFD -> readUInt16LE().toLong() + 0xFE -> readUInt32LE() + 0xFF -> readUInt64LE() + else -> first.toLong() + } + } + + fun readVarBytes(): ByteArray { + val len = readVarInt() + if (len > Int.MAX_VALUE.toLong()) { + throw PsbtParseException("var-bytes length too large: $len") + } + return readBytes(len.toInt()) + } +} + +/** Thrown when PSBT or transaction bytes cannot be parsed. */ +class PsbtParseException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt new file mode 100644 index 0000000000..c35541f4b4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** Double SHA-256, the Bitcoin hash. */ +internal fun hash256(data: ByteArray): ByteArray = sha256(sha256(data)) + +/** + * A reference to a specific output of a previous transaction. + * + * @property txid Transaction id in display byte order (64-char lowercase hex). + * @property vout Output index within that transaction. + */ +@Immutable +data class OutPoint( + val txid: String, + val vout: Long, +) { + fun write(writer: BitcoinWriter) { + // On the wire the txid is stored in internal (reversed) byte order. + writer.writeBytes(txid.hexToByteArray().reversedArray()) + writer.writeUInt32LE(vout) + } + + companion object { + fun read(reader: BitcoinReader): OutPoint { + val txidInternal = reader.readBytes(32) + val txid = txidInternal.reversedArray().toHexKey() + val vout = reader.readUInt32LE() + return OutPoint(txid, vout) + } + } +} + +/** + * A transaction input. + * + * @property outPoint The previous output being spent. + * @property scriptSig The unlocking script. Empty for segwit inputs. + * @property sequence nSequence value. + * @property witness Witness stack items. Empty for a pre-signing or legacy input. + */ +@Immutable +data class TxIn( + val outPoint: OutPoint, + val scriptSig: ByteArray = ByteArray(0), + val sequence: Long = 0xFFFFFFFFL, + val witness: List = emptyList(), +) { + fun writeWithoutWitness(writer: BitcoinWriter) { + outPoint.write(writer) + writer.writeVarBytes(scriptSig) + writer.writeUInt32LE(sequence) + } + + fun writeWitness(writer: BitcoinWriter) { + writer.writeVarInt(witness.size.toLong()) + witness.forEach { writer.writeVarBytes(it) } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TxIn) return false + return outPoint == other.outPoint && + scriptSig.contentEquals(other.scriptSig) && + sequence == other.sequence && + witness.size == other.witness.size && + witness.indices.all { witness[it].contentEquals(other.witness[it]) } + } + + override fun hashCode(): Int { + var result = outPoint.hashCode() + result = 31 * result + scriptSig.contentHashCode() + result = 31 * result + sequence.hashCode() + result = 31 * result + witness.sumOf { it.contentHashCode() } + return result + } + + companion object { + fun readWithoutWitness(reader: BitcoinReader): TxIn { + val outPoint = OutPoint.read(reader) + val scriptSig = reader.readVarBytes() + val sequence = reader.readUInt32LE() + return TxIn(outPoint, scriptSig, sequence) + } + } +} + +/** + * A transaction output. + * + * @property valueSats Output value in satoshis. + * @property scriptPubKey The locking script. + */ +@Immutable +data class TxOut( + val valueSats: Long, + val scriptPubKey: ByteArray, +) { + fun write(writer: BitcoinWriter) { + writer.writeUInt64LE(valueSats) + writer.writeVarBytes(scriptPubKey) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TxOut) return false + return valueSats == other.valueSats && scriptPubKey.contentEquals(other.scriptPubKey) + } + + override fun hashCode(): Int = 31 * valueSats.hashCode() + scriptPubKey.contentHashCode() + + companion object { + fun read(reader: BitcoinReader): TxOut = TxOut(reader.readUInt64LE(), reader.readVarBytes()) + } +} + +/** + * A Bitcoin transaction. + * + * Supports both legacy and BIP-144 segwit serialization. [txid] is computed + * over the legacy serialization (witness-stripped), per consensus rules. + */ +@Immutable +data class BitcoinTransaction( + val version: Long, + val inputs: List, + val outputs: List, + val lockTime: Long, +) { + val hasWitness: Boolean get() = inputs.any { it.witness.isNotEmpty() } + + /** Legacy (witness-stripped) serialization — the bytes that [txid] hashes. */ + fun serializeForId(): ByteArray { + val w = BitcoinWriter() + w.writeUInt32LE(version) + w.writeVarInt(inputs.size.toLong()) + inputs.forEach { it.writeWithoutWitness(w) } + w.writeVarInt(outputs.size.toLong()) + outputs.forEach { it.write(w) } + w.writeUInt32LE(lockTime) + return w.toByteArray() + } + + /** Full serialization. Uses BIP-144 segwit format when any input carries witness data. */ + fun serialize(): ByteArray { + if (!hasWitness) return serializeForId() + val w = BitcoinWriter() + w.writeUInt32LE(version) + w.writeByte(0x00) // segwit marker + w.writeByte(0x01) // segwit flag + w.writeVarInt(inputs.size.toLong()) + inputs.forEach { it.writeWithoutWitness(w) } + w.writeVarInt(outputs.size.toLong()) + outputs.forEach { it.write(w) } + inputs.forEach { it.writeWitness(w) } + w.writeUInt32LE(lockTime) + return w.toByteArray() + } + + /** Transaction id in display byte order (reversed double-SHA256 of the legacy bytes). */ + fun txid(): String = hash256(serializeForId()).reversedArray().toHexKey() + + companion object { + /** + * Sanity cap on input/output/witness-item counts while parsing. A real + * Bitcoin transaction is bounded by the 4 MWU block weight (~100k + * minimal inputs); this generous limit just stops an attacker-supplied + * varint from triggering a giant pre-allocation or a long spin. + */ + const val MAX_PARSE_ITEMS = 1_000_000L + + fun parse(rawHex: String): BitcoinTransaction = parse(rawHex.hexToByteArray()) + + fun parse(bytes: ByteArray): BitcoinTransaction { + val reader = BitcoinReader(bytes) + val version = reader.readUInt32LE() + + // Detect the BIP-144 segwit marker+flag (0x00 0x01). + var isSegwit = false + val firstByte = reader.readByte() + val inputCount: Long + if (firstByte == 0x00) { + val flag = reader.readByte() + if (flag != 0x01) throw PsbtParseException("Invalid segwit flag $flag") + isSegwit = true + inputCount = reader.readVarInt() + } else { + // firstByte was actually the start of the input-count varint. + inputCount = + when (firstByte) { + 0xFD -> reader.readUInt16LE().toLong() + 0xFE -> reader.readUInt32LE() + 0xFF -> reader.readUInt64LE() + else -> firstByte.toLong() + } + } + requireCount(inputCount, "input") + + val inputs = ArrayList(inputCount.toInt()) + for (i in 0 until inputCount) { + inputs.add(TxIn.readWithoutWitness(reader)) + } + + val outputCount = reader.readVarInt() + requireCount(outputCount, "output") + val outputs = ArrayList(outputCount.toInt()) + for (i in 0 until outputCount) { + outputs.add(TxOut.read(reader)) + } + + if (isSegwit) { + for (i in inputs.indices) { + val itemCount = reader.readVarInt() + requireCount(itemCount, "witness item") + val items = ArrayList(itemCount.toInt()) + for (j in 0 until itemCount) { + items.add(reader.readVarBytes()) + } + inputs[i] = inputs[i].copy(witness = items) + } + } + + val lockTime = reader.readUInt32LE() + return BitcoinTransaction(version, inputs, outputs, lockTime) + } + + private fun requireCount( + count: Long, + label: String, + ) { + if (count < 0 || count > MAX_PARSE_ITEMS) { + throw PsbtParseException("$label count out of range: $count") + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt new file mode 100644 index 0000000000..9ac7c76cef --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * One key-value record inside a PSBT map. [keyType] is the BIP-174 keytype + * compact-size; [keyData] is whatever follows it (empty for all the field + * types NIP-BC uses). + */ +class PsbtRecord( + val keyType: Int, + val keyData: ByteArray, + val value: ByteArray, +) { + val hasEmptyKeyData: Boolean get() = keyData.isEmpty() +} + +/** + * An ordered PSBT key-value map (global, per-input, or per-output). Unknown + * records are preserved verbatim so serialization round-trips. + */ +class PsbtMap( + val records: MutableList = mutableListOf(), +) { + /** First value for [keyType] with empty key data, or null. */ + fun get(keyType: Int): ByteArray? = records.firstOrNull { it.keyType == keyType && it.hasEmptyKeyData }?.value + + /** Insert or replace the (empty-key-data) record for [keyType]. */ + fun put( + keyType: Int, + value: ByteArray, + ) { + val idx = records.indexOfFirst { it.keyType == keyType && it.hasEmptyKeyData } + val record = PsbtRecord(keyType, ByteArray(0), value) + if (idx >= 0) records[idx] = record else records.add(record) + } + + /** Remove the (empty-key-data) record for [keyType], if present. */ + fun remove(keyType: Int) { + records.removeAll { it.keyType == keyType && it.hasEmptyKeyData } + } + + fun write(writer: BitcoinWriter) { + for (record in records) { + val key = BitcoinWriter() + key.writeVarInt(record.keyType.toLong()) + key.writeBytes(record.keyData) + writer.writeVarBytes(key.toByteArray()) + writer.writeVarBytes(record.value) + } + writer.writeByte(0x00) // map separator + } + + companion object { + fun read(reader: BitcoinReader): PsbtMap { + val map = PsbtMap() + while (true) { + val keyLen = reader.readVarInt() + if (keyLen == 0L) break // separator + val keyBytes = reader.readBytes(keyLen.toInt()) + val keyReader = BitcoinReader(keyBytes) + val keyType = keyReader.readVarInt().toInt() + val keyData = keyReader.readBytes(keyReader.remaining) + val value = reader.readVarBytes() + map.records.add(PsbtRecord(keyType, keyData, value)) + } + return map + } + } +} + +/** + * A Partially Signed Bitcoin Transaction ([BIP-174](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)). + * + * This is a deliberately small subset — enough to construct, sign, and + * finalize the single-key-path P2TR spends NIP-BC needs. Unknown records are + * preserved verbatim so the container round-trips even when fields aren't + * modeled. + * + * This `psbt/` package is intentionally hand-rolled rather than delegated to a + * Bitcoin library. That is a recorded architecture decision — see + * `amethyst/plans/2026-05-14-onchain-zaps.md` ("Architecture decision: + * hand-rolled Bitcoin consensus code"). It holds only while the scope stays at + * single-key-path P2TR; expanding past that should revisit the decision. + */ +class Psbt( + val global: PsbtMap, + val inputs: MutableList, + val outputs: MutableList, +) { + /** The unsigned transaction from `PSBT_GLOBAL_UNSIGNED_TX`. */ + val unsignedTx: BitcoinTransaction by lazy { + val raw = + global.get(PSBT_GLOBAL_UNSIGNED_TX) + ?: throw PsbtParseException("PSBT has no unsigned transaction") + BitcoinTransaction.parse(raw) + } + + fun serialize(): ByteArray { + val w = BitcoinWriter() + w.writeBytes(MAGIC) + global.write(w) + inputs.forEach { it.write(w) } + outputs.forEach { it.write(w) } + return w.toByteArray() + } + + fun toHex(): String = serialize().toHexKey() + + companion object { + val MAGIC = byteArrayOf(0x70, 0x73, 0x62, 0x74, 0xFF.toByte()) + + // Global keytypes. + const val PSBT_GLOBAL_UNSIGNED_TX = 0x00 + const val PSBT_GLOBAL_VERSION = 0xFB + + // Per-input keytypes. + const val PSBT_IN_NON_WITNESS_UTXO = 0x00 + const val PSBT_IN_WITNESS_UTXO = 0x01 + const val PSBT_IN_SIGHASH_TYPE = 0x03 + const val PSBT_IN_TAP_KEY_SIG = 0x13 + const val PSBT_IN_TAP_INTERNAL_KEY = 0x17 + + // Per-output keytypes. + const val PSBT_OUT_TAP_INTERNAL_KEY = 0x05 + + fun parse(hex: String): Psbt = parse(hex.hexToByteArray()) + + fun parse(bytes: ByteArray): Psbt { + val reader = BitcoinReader(bytes) + val magic = reader.readBytes(5) + if (!magic.contentEquals(MAGIC)) { + throw PsbtParseException("Not a PSBT: bad magic ${magic.toHexKey()}") + } + + val global = PsbtMap.read(reader) + val rawTx = + global.get(PSBT_GLOBAL_UNSIGNED_TX) + ?: throw PsbtParseException("PSBT global map has no unsigned transaction") + val tx = BitcoinTransaction.parse(rawTx) + + val inputs = ArrayList(tx.inputs.size) + for (i in tx.inputs.indices) { + inputs.add(PsbtMap.read(reader)) + } + + val outputs = ArrayList(tx.outputs.size) + for (i in tx.outputs.indices) { + outputs.add(PsbtMap.read(reader)) + } + + return Psbt(global, inputs, outputs) + } + + /** Build an unsigned PSBT shell from a transaction with empty input/output maps. */ + fun fromUnsignedTx(tx: BitcoinTransaction): Psbt { + require(!tx.hasWitness) { "unsigned tx must not carry witness data" } + val global = PsbtMap() + global.put(PSBT_GLOBAL_UNSIGNED_TX, tx.serializeForId()) + val inputs = MutableList(tx.inputs.size) { PsbtMap() } + val outputs = MutableList(tx.outputs.size) { PsbtMap() } + return Psbt(global, inputs, outputs) + } + } +} + +// --------------------------------------------------------------------------- +// Typed accessors for the fields NIP-BC's key-path P2TR flow touches. +// --------------------------------------------------------------------------- + +/** The witness UTXO (`PSBT_IN_WITNESS_UTXO`) being spent by input [index]. */ +fun Psbt.inputWitnessUtxo(index: Int): TxOut? = inputs[index].get(Psbt.PSBT_IN_WITNESS_UTXO)?.let { TxOut.read(BitcoinReader(it)) } + +fun Psbt.setInputWitnessUtxo( + index: Int, + output: TxOut, +) { + val w = BitcoinWriter() + output.write(w) + inputs[index].put(Psbt.PSBT_IN_WITNESS_UTXO, w.toByteArray()) +} + +/** The 32-byte x-only taproot internal key for input [index]. */ +fun Psbt.inputTapInternalKey(index: Int): ByteArray? = inputs[index].get(Psbt.PSBT_IN_TAP_INTERNAL_KEY) + +fun Psbt.setInputTapInternalKey( + index: Int, + xOnlyPubKey: ByteArray, +) { + require(xOnlyPubKey.size == 32) { "tap internal key must be 32 bytes" } + inputs[index].put(Psbt.PSBT_IN_TAP_INTERNAL_KEY, xOnlyPubKey) +} + +/** The 64- or 65-byte schnorr key-path signature for input [index]. */ +fun Psbt.inputTapKeySig(index: Int): ByteArray? = inputs[index].get(Psbt.PSBT_IN_TAP_KEY_SIG) + +fun Psbt.setInputTapKeySig( + index: Int, + signature: ByteArray, +) { + require(signature.size == 64 || signature.size == 65) { + "taproot key-path signature must be 64 or 65 bytes, got ${signature.size}" + } + inputs[index].put(Psbt.PSBT_IN_TAP_KEY_SIG, signature) +} + +/** The optional BIP-174 sighash type for input [index] (4-byte LE), or null. */ +fun Psbt.inputSighashType(index: Int): Int? = inputs[index].get(Psbt.PSBT_IN_SIGHASH_TYPE)?.let { BitcoinReader(it).readUInt32LE().toInt() } + +fun Psbt.setInputSighashType( + index: Int, + sighashType: Int, +) { + inputs[index].put( + Psbt.PSBT_IN_SIGHASH_TYPE, + BitcoinWriter().writeUInt32LE(sighashType.toLong()).toByteArray(), + ) +} + +/** Optional taproot internal key on a change output. */ +fun Psbt.setOutputTapInternalKey( + index: Int, + xOnlyPubKey: ByteArray, +) { + require(xOnlyPubKey.size == 32) { "tap internal key must be 32 bytes" } + outputs[index].put(Psbt.PSBT_OUT_TAP_INTERNAL_KEY, xOnlyPubKey) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt new file mode 100644 index 0000000000..a64cd4afaf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Turns a fully signed key-path P2TR [Psbt] into a broadcastable transaction. + * + * For a key-path taproot spend the witness is just the single Schnorr + * signature and the scriptSig stays empty, so finalization is purely + * mechanical: move each input's `PSBT_IN_TAP_KEY_SIG` into the transaction's + * witness stack. + */ +object PsbtFinalizer { + /** + * Build the final signed transaction from [psbt]. + * + * @throws PsbtSigningException if any input is still missing its key-path + * signature. + */ + fun finalize(psbt: Psbt): BitcoinTransaction { + val tx = psbt.unsignedTx + val signedInputs = + tx.inputs.mapIndexed { index, input -> + val sig = + psbt.inputTapKeySig(index) + ?: throw PsbtSigningException("input $index is not signed") + input.copy( + scriptSig = ByteArray(0), + witness = listOf(sig), + ) + } + return BitcoinTransaction(tx.version, signedInputs, tx.outputs, tx.lockTime) + } + + /** Convenience: [finalize] then serialize to a broadcast-ready lowercase hex string. */ + fun finalizeToHex(psbt: Psbt): String = finalize(psbt).serialize().toHexKey() + + /** True when every input of [psbt] carries a key-path signature. */ + fun isFullySigned(psbt: Psbt): Boolean = + psbt.unsignedTx.inputs.indices + .all { psbt.inputTapKeySig(it) != null } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifier.kt new file mode 100644 index 0000000000..78e4b19d8f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifier.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance + +/** + * Independently verifies the key-path P2TR signatures inside a [Psbt]. + * + * [PsbtFinalizer.isFullySigned] only checks that a `PSBT_IN_TAP_KEY_SIG` + * record is *present*; it does not check the signature is *valid* nor that it + * commits to *this* transaction. Before broadcasting a transaction that came + * back from an external signer, the client MUST also confirm every signature + * actually verifies — otherwise a buggy or malicious signer could substitute a + * different transaction (with valid signatures over it) and redirect funds. + * + * Each signature is checked as a BIP-340 Schnorr signature over the BIP-341 + * sighash, against the output key derived from the input's + * `PSBT_IN_TAP_INTERNAL_KEY`. Both anchors — the sighash and the tweak — are + * validated against the BIP-341 wallet test vectors, so this check is not + * circular with the signer. + */ +object PsbtSignatureVerifier { + /** + * True iff every input of [psbt] carries a key-path tap signature, every + * input has the witness-UTXO data needed to compute its sighash, and every + * signature is a valid BIP-340 signature over the BIP-341 sighash. + */ + fun verifyAllKeyPathInputs(psbt: Psbt): Boolean { + val tx = psbt.unsignedTx + if (tx.inputs.isEmpty()) return false + + val spentOutputs = + tx.inputs.indices.map { psbt.inputWitnessUtxo(it) ?: return false } + + for (index in tx.inputs.indices) { + val internalKey = psbt.inputTapInternalKey(index) ?: return false + val sig = psbt.inputTapKeySig(index) ?: return false + + // BIP-341: a 64-byte signature implies SIGHASH_DEFAULT; a 65-byte + // signature carries the (non-default) sighash type as its last byte. + val sighashType: Int + val sig64: ByteArray + when (sig.size) { + 64 -> { + sighashType = TaprootSigHash.SIGHASH_DEFAULT + sig64 = sig + } + + 65 -> { + sighashType = sig[64].toInt() and 0xFF + // A 65-byte signature must not encode SIGHASH_DEFAULT. + if (sighashType == TaprootSigHash.SIGHASH_DEFAULT) return false + sig64 = sig.copyOfRange(0, 64) + } + + else -> { + return false + } + } + + val sigHash = + runCatching { TaprootSigHash.compute(tx, index, spentOutputs, sighashType) } + .getOrElse { return false } + val outputKey = + runCatching { TaprootAddress.tweakOutputKey(internalKey) } + .getOrElse { return false } + + if (!Secp256k1Instance.verifySchnorr(sig64, sigHash, outputKey)) return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt new file mode 100644 index 0000000000..6ae4cb4904 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance + +/** + * Signs the key-path P2TR inputs of a [Psbt] with a single private key. + * + * This is the core of `NostrSigner.signPsbt` — pure protocol logic, no UI or + * signer-app dependency. The signer: + * 1. derives the caller's x-only public key, + * 2. for each input whose `PSBT_IN_TAP_INTERNAL_KEY` matches that key and + * which is not already signed, + * 3. computes the BIP-341 sighash over all inputs (so every input must carry + * a `PSBT_IN_WITNESS_UTXO`), + * 4. applies the BIP-341 key-path tweak to the private key, and + * 5. produces a BIP-340 Schnorr signature, stored as `PSBT_IN_TAP_KEY_SIG`. + * + * Inputs the key does not control are left untouched. Finalization (moving the + * signature into the transaction witness) is [PsbtFinalizer]'s job. + */ +object PsbtSigner { + /** + * Sign every key-path input of [psbt] that [privKey] controls, in place. + * + * @return the number of inputs signed by this call. + * @throws PsbtSigningException if a controllable input is missing the + * witness-UTXO data needed to compute its sighash. + */ + fun signKeyPathInputs( + psbt: Psbt, + privKey: ByteArray, + ): Int { + require(privKey.size == 32) { "private key must be 32 bytes" } + + val ourXOnlyPubKey = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) + val tx = psbt.unsignedTx + + // Lazily materialized: every input's spent output, needed for the + // all-inputs commitment in the BIP-341 sighash. + var spentOutputs: List? = null + + var signed = 0 + for (index in tx.inputs.indices) { + val internalKey = psbt.inputTapInternalKey(index) ?: continue + if (!internalKey.contentEquals(ourXOnlyPubKey)) continue + if (psbt.inputTapKeySig(index) != null) continue // already signed + + if (spentOutputs == null) { + spentOutputs = + tx.inputs.indices.map { i -> + psbt.inputWitnessUtxo(i) + ?: throw PsbtSigningException( + "input $i has no witness UTXO; cannot compute sighash", + ) + } + } + + val sighashType = psbt.inputSighashType(index) ?: TaprootSigHash.SIGHASH_DEFAULT + val sigHash = TaprootSigHash.compute(tx, index, spentOutputs, sighashType) + + val tweakedSecKey = TaprootAddress.tweakSecretKey(privKey) + val signature64 = Secp256k1Instance.signSchnorr(sigHash, tweakedSecKey) + + // SIGHASH_DEFAULT → bare 64-byte signature. Any other type → append + // the sighash byte for a 65-byte signature, per BIP-341. + val signature = + if (sighashType == TaprootSigHash.SIGHASH_DEFAULT) { + signature64 + } else { + signature64 + byteArrayOf(sighashType.toByte()) + } + + psbt.setInputTapKeySig(index, signature) + signed++ + } + return signed + } +} + +/** Thrown when a PSBT cannot be signed (e.g. missing witness-UTXO data). */ +class PsbtSigningException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt new file mode 100644 index 0000000000..2bc1b26792 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * BIP-341 taproot signature hash (`SigMsg` + `TapSighash` tagged hash). + * + * Supports key-path spends (`ext_flag = 0`) with all six base sighash types, + * with and without an annex. Script-path spends (`ext_flag = 1`) are out of + * scope for NIP-BC, which only ever spends key-path P2TR outputs. + * + * Reference: + */ +object TaprootSigHash { + const val SIGHASH_DEFAULT = 0x00 + const val SIGHASH_ALL = 0x01 + const val SIGHASH_NONE = 0x02 + const val SIGHASH_SINGLE = 0x03 + const val SIGHASH_ANYONECANPAY = 0x80 + + private val tapSighashTag: ByteArray by lazy { sha256("TapSighash".encodeToByteArray()) } + + /** + * Compute the BIP-341 signature hash for a key-path P2TR spend. + * + * @param tx The transaction being signed. + * @param inputIndex The index of the input whose signature is being produced. + * @param spentOutputs The previous outputs being spent, one per input of [tx], + * in the same order as `tx.inputs`. + * @param hashType A BIP-341 sighash type byte (default `SIGHASH_DEFAULT`). + * @param annex Optional annex bytes (including the `0x50` prefix), or null. + */ + fun compute( + tx: BitcoinTransaction, + inputIndex: Int, + spentOutputs: List, + hashType: Int = SIGHASH_DEFAULT, + annex: ByteArray? = null, + ): ByteArray { + require(spentOutputs.size == tx.inputs.size) { + "spentOutputs (${spentOutputs.size}) must match inputs (${tx.inputs.size})" + } + require(inputIndex in tx.inputs.indices) { "inputIndex $inputIndex out of range" } + + val anyoneCanPay = (hashType and SIGHASH_ANYONECANPAY) != 0 + val outputType = hashType and 0x03 + require( + hashType == SIGHASH_DEFAULT || + outputType == SIGHASH_ALL || + outputType == SIGHASH_NONE || + outputType == SIGHASH_SINGLE, + ) { "invalid sighash type $hashType" } + + val ss = BitcoinWriter() + + // Sighash epoch. + ss.writeByte(0x00) + + // Common signature message fields. + ss.writeByte(hashType) + ss.writeUInt32LE(tx.version) + ss.writeUInt32LE(tx.lockTime) + + if (!anyoneCanPay) { + ss.writeBytes(shaPrevouts(tx)) + ss.writeBytes(shaAmounts(spentOutputs)) + ss.writeBytes(shaScriptPubKeys(spentOutputs)) + ss.writeBytes(shaSequences(tx)) + } + + if (outputType != SIGHASH_NONE && outputType != SIGHASH_SINGLE) { + ss.writeBytes(shaOutputs(tx)) + } + + // spend_type = ext_flag * 2 + annex_present. ext_flag = 0 for key-path. + val annexPresent = if (annex != null) 1 else 0 + ss.writeByte(annexPresent) + + if (anyoneCanPay) { + tx.inputs[inputIndex].outPoint.write(ss) + ss.writeUInt64LE(spentOutputs[inputIndex].valueSats) + ss.writeVarBytes(spentOutputs[inputIndex].scriptPubKey) + ss.writeUInt32LE(tx.inputs[inputIndex].sequence) + } else { + ss.writeUInt32LE(inputIndex.toLong()) + } + + if (annex != null) { + val a = BitcoinWriter() + a.writeVarBytes(annex) + ss.writeBytes(sha256(a.toByteArray())) + } + + if (outputType == SIGHASH_SINGLE) { + require(inputIndex < tx.outputs.size) { + "SIGHASH_SINGLE with no matching output at index $inputIndex" + } + val o = BitcoinWriter() + tx.outputs[inputIndex].write(o) + ss.writeBytes(sha256(o.toByteArray())) + } + + return taggedHash(tapSighashTag, ss.toByteArray()) + } + + /** BIP-340 tagged hash: `SHA256(SHA256(tag) || SHA256(tag) || msg)`. */ + private fun taggedHash( + tagHash: ByteArray, + msg: ByteArray, + ): ByteArray { + val buf = ByteArray(tagHash.size * 2 + msg.size) + tagHash.copyInto(buf, 0) + tagHash.copyInto(buf, tagHash.size) + msg.copyInto(buf, tagHash.size * 2) + return sha256(buf) + } + + private fun shaPrevouts(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.inputs.forEach { it.outPoint.write(w) } + return sha256(w.toByteArray()) + } + + private fun shaAmounts(spentOutputs: List): ByteArray { + val w = BitcoinWriter() + spentOutputs.forEach { w.writeUInt64LE(it.valueSats) } + return sha256(w.toByteArray()) + } + + private fun shaScriptPubKeys(spentOutputs: List): ByteArray { + val w = BitcoinWriter() + spentOutputs.forEach { w.writeVarBytes(it.scriptPubKey) } + return sha256(w.toByteArray()) + } + + private fun shaSequences(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.inputs.forEach { w.writeUInt32LE(it.sequence) } + return sha256(w.toByteArray()) + } + + private fun shaOutputs(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.outputs.forEach { it.write(w) } + return sha256(w.toByteArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt new file mode 100644 index 0000000000..057508d365 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 + +/** + * BIP-173 / BIP-350 segwit native address encoder/decoder. + * + * NIP-BC uses witness version 1 (taproot) with a 32-byte program, encoded as + * bech32m with HRP `bc` (Bitcoin mainnet only). + */ +object SegwitAddress { + /** Mainnet human-readable prefix. */ + const val HRP_MAINNET = "bc" + + /** Taproot witness version. */ + const val TAPROOT_WITNESS_VERSION = 1 + + /** + * Encode a witness program to a segwit address. + * + * @param hrp Human-readable prefix (`bc` for mainnet). + * @param witnessVersion Witness version (0 for v0, 1 for taproot). + * @param program Witness program bytes (32 bytes for v1 taproot). + */ + fun encode( + hrp: String, + witnessVersion: Int, + program: ByteArray, + ): String { + require(witnessVersion in 0..16) { "invalid witness version $witnessVersion" } + require(program.size in 2..40) { "invalid witness program length ${program.size}" } + if (witnessVersion == 0) { + require(program.size == 20 || program.size == 32) { + "witness v0 program must be 20 or 32 bytes (got ${program.size})" + } + } + + val data = ArrayList(1 + program.size * 2) + data.add(witnessVersion.toByte()) + data.addAll(Bech32.eight2five(program)) + + val encoding = + if (witnessVersion == 0) Bech32.Encoding.Bech32 else Bech32.Encoding.Bech32m + + return Bech32.encode(hrp, ArrayList(data), encoding) + } + + /** Encode a taproot (witness v1) output key as a `bc1p...` address. */ + fun encodeP2TR( + outputKey: ByteArray, + hrp: String = HRP_MAINNET, + ): String { + require(outputKey.size == 32) { + "taproot output key must be 32 bytes (got ${outputKey.size})" + } + return encode(hrp, TAPROOT_WITNESS_VERSION, outputKey) + } + + /** + * Decoded segwit address. + * + * @property hrp Human-readable prefix. + * @property witnessVersion Witness version (0-16). + * @property program Witness program bytes. + */ + data class Decoded( + val hrp: String, + val witnessVersion: Int, + val program: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Decoded) return false + return hrp == other.hrp && + witnessVersion == other.witnessVersion && + program.contentEquals(other.program) + } + + override fun hashCode(): Int { + var result = hrp.hashCode() + result = 31 * result + witnessVersion + result = 31 * result + program.contentHashCode() + return result + } + } + + /** Decode a segwit address, validating HRP, version, encoding, and program length. */ + fun decode(address: String): Decoded { + val (hrp, data, encoding) = Bech32.decode(address) + require(data.isNotEmpty()) { "empty data" } + + val witnessVersion = data[0].toInt() and 0x1f + require(witnessVersion in 0..16) { "invalid witness version $witnessVersion" } + + val expectedEncoding = + if (witnessVersion == 0) Bech32.Encoding.Bech32 else Bech32.Encoding.Bech32m + require(encoding == expectedEncoding) { + "wrong checksum encoding for witness version $witnessVersion" + } + + val program = Bech32.five2eight(data, 1) + require(program.size in 2..40) { "invalid witness program length ${program.size}" } + if (witnessVersion == 0) { + require(program.size == 20 || program.size == 32) { + "witness v0 program must be 20 or 32 bytes" + } + } + + return Decoded(hrp, witnessVersion, program) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt new file mode 100644 index 0000000000..638f2bdbc5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * BIP-341 key-path-only taproot address derivation from a Nostr public key. + * + * NIP-BC uses the Nostr pubkey directly as the BIP-341 internal key with no + * script tree. The output key is the tweaked internal key, encoded as a + * bech32m P2TR address on Bitcoin mainnet. + * + * `Q = lift_x(P) + int(hashTapTweak(bytes(P)))·G` + * + * where `bytes(P)` is the 32-byte x-only Nostr pubkey and `hashTapTweak` is + * the BIP-340 tagged hash with tag `"TapTweak"`. + * + * This `taproot/` package is intentionally hand-rolled rather than delegated to + * a Bitcoin library — a recorded architecture decision, see + * `amethyst/plans/2026-05-14-onchain-zaps.md` ("Architecture decision: + * hand-rolled Bitcoin consensus code"). + */ +object TaprootAddress { + private const val TAP_TWEAK_TAG = "TapTweak" + + private val tapTweakTagHash: ByteArray by lazy { + sha256(TAP_TWEAK_TAG.encodeToByteArray()) + } + + /** + * Compute the BIP-341 tagged hash `tagged_hash("TapTweak", data)`. + * + * Tagged hash is defined as + * `SHA256(SHA256(tag) || SHA256(tag) || data)`. + */ + fun tapTweakHash(internalKey: ByteArray): ByteArray { + require(internalKey.size == 32) { + "internal key must be 32 bytes (got ${internalKey.size})" + } + val buf = ByteArray(64 + internalKey.size) + tapTweakTagHash.copyInto(buf, 0) + tapTweakTagHash.copyInto(buf, 32) + internalKey.copyInto(buf, 64) + return sha256(buf) + } + + /** + * Apply the BIP-341 key-path-only tweak to an internal key. + * + * @return the 32-byte x-only output key (Q.x). + */ + fun tweakOutputKey(internalKey: ByteArray): ByteArray { + require(internalKey.size == 32) { + "internal key must be 32 bytes (got ${internalKey.size})" + } + val tweak = tapTweakHash(internalKey) + // Returns compressed (33-byte) point; drop the parity byte for the x-only output. + val tweaked = Secp256k1Instance.pubKeyTweakAdd(internalKey, tweak) + require(tweaked.size == 33) { "expected compressed tweaked point" } + return tweaked.copyOfRange(1, 33) + } + + /** + * BIP-341 `taproot_tweak_seckey` for a key-path-only spend (no script tree). + * + * Produces the private key that controls the P2TR output derived from + * [internalSecKey]'s public key. The internal key is first normalized to + * the even-y representation BIP-341 hashes over, then the TapTweak scalar + * is added. + * + * @param internalSecKey 32-byte internal private key. + * @return 32-byte tweaked private key suitable for a BIP-340 Schnorr + * signature over a [TaprootSigHash]-style key-path sighash. + */ + fun tweakSecretKey(internalSecKey: ByteArray): ByteArray { + require(internalSecKey.size == 32) { + "internal secret key must be 32 bytes (got ${internalSecKey.size})" + } + // P = internalSecKey · G, as a 33-byte compressed point. + val compressedPubKey = Secp256k1Instance.compressedPubKeyFor(internalSecKey) + val xOnly = compressedPubKey.copyOfRange(1, 33) + + // If P has odd y, BIP-341 negates the secret key so it corresponds to + // the even-y lift used when computing the TapTweak hash. + val evenYParity = compressedPubKey[0].toInt() == 0x02 + val normalizedSecKey = + if (evenYParity) internalSecKey else Secp256k1Instance.privKeyNegate(internalSecKey) + + val tweak = tapTweakHash(xOnly) + return Secp256k1Instance.privateKeyAdd(normalizedSecKey, tweak) + } + + /** + * Derive the Bitcoin mainnet taproot address (`bc1p...`) for a Nostr + * public key. The pubkey is used directly as the BIP-341 internal key. + */ + fun fromPubKey(pubKey: HexKey): String { + val bytes = pubKey.hexToByteArray() + require(bytes.size == 32) { "Nostr pubkey must be 32 bytes" } + return SegwitAddress.encodeP2TR(tweakOutputKey(bytes)) + } + + /** Derive the Bitcoin mainnet taproot address from a 32-byte x-only key. */ + fun fromPubKey(pubKey: ByteArray): String { + require(pubKey.size == 32) { "x-only pubkey must be 32 bytes" } + return SegwitAddress.encodeP2TR(tweakOutputKey(pubKey)) + } + + /** Produce the BIP-341 P2TR scriptPubKey for the given output key: `OP_1 <32-byte-x-only>`. */ + fun outputKeyToScriptPubKey(outputKey: ByteArray): ByteArray { + require(outputKey.size == 32) { + "taproot output key must be 32 bytes (got ${outputKey.size})" + } + val out = ByteArray(34) + out[0] = 0x51 // OP_1 + out[1] = 0x20 // push 32 bytes + outputKey.copyInto(out, 2) + return out + } + + /** Produce the BIP-341 scriptPubKey for the recipient of a Nostr pubkey. */ + fun scriptPubKeyForRecipient(pubKey: HexKey): ByteArray = outputKeyToScriptPubKey(tweakOutputKey(pubKey.hexToByteArray())) + + /** Hex of the BIP-341 scriptPubKey for the recipient — convenient for tx-output matching. */ + fun scriptPubKeyHexForRecipient(pubKey: HexKey): String = scriptPubKeyForRecipient(pubKey).toHexKey() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt new file mode 100644 index 0000000000..43dd9406ab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.verify + +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent + +/** + * NIP-BC verifier — validates a [OnchainZapEvent] against the configured + * chain backend and returns a [VerifiedOnchainZap] result. + * + * Implements the spec's verification rules: + * 1. Parse the txid from the `i` tag. + * 2. Fetch the transaction from the backend. + * 3. Derive the recipient's expected Taproot scriptPubKey from the `p` tag. + * 4. Sum the values of all outputs matching that scriptPubKey. Change outputs + * paying back to the sender's own derived script MUST NOT be counted. + * 5. If verified amount is 0 → discard. + * 6. Self-zaps (sender == recipient) → discard. + * 7. Pending (unconfirmed) txs are returned as [VerifiedOnchainZap.Pending]; + * callers SHOULD exclude them from aggregate totals. + * + * Deduplication by `(txid, target)` is the caller's responsibility — typically + * done in `LocalCache`. + */ +class OnchainZapVerifier( + private val backend: OnchainBackend, +) { + suspend fun verify(event: OnchainZapEvent): VerifiedOnchainZap { + val txid = + event.txid() + ?: return VerifiedOnchainZap.Rejected("", VerifiedOnchainZap.Rejected.Reason.MISSING_TXID) + + val recipientPubKey = + event.recipient() + ?: return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.MISSING_RECIPIENT) + + // Anti-spoofing rule: self-zaps contribute nothing meaningful. + if (event.pubKey.equals(recipientPubKey, ignoreCase = true)) { + return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.SELF_ZAP) + } + + val tx = + backend.getTx(txid) + ?: return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) + + // Defensive: the backend must have returned the transaction we asked for. + if (!tx.txid.equals(txid, ignoreCase = true)) { + return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) + } + + val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase() + val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(event.pubKey).lowercase() + + val verifiedSats = sumOutputsToRecipient(tx, recipientScriptHex, senderScriptHex) + + if (verifiedSats == 0L) { + return VerifiedOnchainZap.Rejected( + txid, + VerifiedOnchainZap.Rejected.Reason.ZERO_VERIFIED_AMOUNT, + ) + } + + return if (tx.confirmations > 0) { + VerifiedOnchainZap.Confirmed( + txid = txid, + recipientPubKey = recipientPubKey, + verifiedSats = verifiedSats, + confirmations = realConfirmations(tx), + blockHeight = tx.blockHeight, + blockHashHex = tx.blockHashHex, + ) + } else { + VerifiedOnchainZap.Pending( + txid = txid, + recipientPubKey = recipientPubKey, + verifiedSats = verifiedSats, + ) + } + } + + /** + * Resolve the real confirmation depth. Backends often report only a binary + * confirmed/unconfirmed flag (as `confirmations` 1 or 0), so when a block + * height is available we compute `tip - height + 1` against the chain tip. + * Falls back to the backend-reported value if the tip can't be fetched. + */ + private suspend fun realConfirmations(tx: BitcoinTx): Int { + val height = tx.blockHeight ?: return tx.confirmations + val tip = runCatching { backend.tipHeight() }.getOrNull() ?: return tx.confirmations + return (tip - height + 1).coerceAtLeast(1).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + + /** + * Sum the value of outputs paying [recipientScriptHex]. Outputs paying + * back to [senderScriptHex] are change and MUST NOT be counted. + */ + private fun sumOutputsToRecipient( + tx: BitcoinTx, + recipientScriptHex: String, + senderScriptHex: String, + ): Long { + var sum = 0L + for (out in tx.outputs) { + val script = out.scriptPubKeyHex.lowercase() + if (script == recipientScriptHex && script != senderScriptHex) { + sum += out.valueSats + } + } + return sum + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt new file mode 100644 index 0000000000..1549321b45 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.verify + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Result of verifying a NIP-BC onchain zap event against the chain. + * + * Clients SHOULD display [verifiedSats], not the sender-claimed amount. + */ +@Immutable +sealed interface VerifiedOnchainZap { + val txid: String + + /** The event is valid and the on-chain transaction pays the recipient. */ + @Immutable + data class Confirmed( + override val txid: String, + val recipientPubKey: HexKey, + val verifiedSats: Long, + val confirmations: Int, + val blockHeight: Long?, + val blockHashHex: String?, + ) : VerifiedOnchainZap + + /** The transaction exists but is not yet confirmed. */ + @Immutable + data class Pending( + override val txid: String, + val recipientPubKey: HexKey, + val verifiedSats: Long, + ) : VerifiedOnchainZap + + /** + * The event failed verification and SHOULD be discarded. + * + * @property reason Why the event was rejected; useful for debugging only — + * do not surface to users. + */ + @Immutable + data class Rejected( + override val txid: String, + val reason: Reason, + ) : VerifiedOnchainZap { + enum class Reason { + /** Sender equals recipient (self-zap). */ + SELF_ZAP, + + /** Transaction does not exist on the configured backend. */ + TX_NOT_FOUND, + + /** Transaction exists but pays the recipient zero satoshis. */ + ZERO_VERIFIED_AMOUNT, + + /** Event has no `i` tag or it's malformed. */ + MISSING_TXID, + + /** Event has no `p` tag identifying the recipient. */ + MISSING_RECIPIENT, + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt new file mode 100644 index 0000000000..a4ac489043 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEvent.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.toETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-BC: Onchain Zaps. + * + * Kind 8333 event that attributes a Bitcoin onchain payment to a Nostr event or profile. + * The recipient's Nostr pubkey is used directly as the internal key of a BIP-341 P2TR + * output, so every Nostr pubkey has exactly one corresponding mainnet Taproot address. + * + * Mainnet only. + */ +@Immutable +class OnchainZapEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + /** The Bitcoin transaction id (64-char lowercase hex) parsed from the `i` tag. */ + fun txid() = tags.txid() + + /** Sender-claimed amount in satoshis. Must be verified against the on-chain transaction. */ + fun claimedAmountInSats() = tags.amountInSats() + + /** The hex-encoded pubkey of the recipient (the author being paid). */ + fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey) + + /** The event being zapped, if any. */ + fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId) + + /** The addressable event being zapped, if any. */ + fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + /** Optional block tag with hash + height enabling SPV verification. */ + fun block() = tags.block() + + /** Optional inline SPV proof (raw tx hex + merkle proof hex). */ + fun proof() = tags.proof() + + /** True when neither `e` nor `a` is present — the zap targets the recipient's profile. */ + fun isProfileZap() = zappedEvent() == null && zappedAddress() == null + + companion object { + const val KIND = 8333 + + /** NIP-31 human-readable fallback. Includes the amount, as in the NIP-BC example. */ + fun altDescription(amountInSats: Long) = "Onchain zap: $amountInSats sats" + + /** + * Build an onchain zap that targets a specific event. + */ + fun build( + txid: String, + recipientPubKey: HexKey, + amountInSats: Long, + zappedEvent: EventHintBundle, + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(altDescription(amountInSats)) + txid(txid) + recipient(recipientPubKey) + amountInSats(amountInSats) + if (zappedEvent.event is AddressableEvent) { + zappedAddress(zappedEvent.toATag()) + } + zappedEvent(zappedEvent.toETag()) + zappedKind(zappedEvent.event.kind) + initializer() + } + + /** + * Build an onchain zap that targets a recipient's profile (no event / address). + */ + fun buildProfileZap( + txid: String, + recipientPubKey: HexKey, + amountInSats: Long, + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(altDescription(amountInSats)) + txid(txid) + recipient(recipientPubKey) + amountInSats(amountInSats) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..f07b56a51d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayBuilderExt.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.AmountTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BitcoinTxIdTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BlockTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.ProofTag + +fun TagArrayBuilder.txid(txid: String) = addUnique(BitcoinTxIdTag.assemble(txid)) + +fun TagArrayBuilder.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null)) + +fun TagArrayBuilder.amountInSats(amountInSats: Long) = addUnique(AmountTag.assemble(amountInSats)) + +fun TagArrayBuilder.zappedEvent(tag: ETag) = addUnique(tag.toTagArray()) + +fun TagArrayBuilder.zappedAddress(tag: ATag) = addUnique(tag.toATagArray()) + +fun TagArrayBuilder.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind)) + +fun TagArrayBuilder.block( + blockHashHex: String, + height: Long, +) = addUnique(BlockTag.assemble(blockHashHex, height)) + +fun TagArrayBuilder.block(block: BlockTag) = addUnique(block.toTagArray()) + +fun TagArrayBuilder.proof( + rawTxHex: String, + merkleProofHex: String, +) = addUnique(ProofTag.assemble(rawTxHex, merkleProofHex)) + +fun TagArrayBuilder.proof(proof: ProofTag) = addUnique(proof.toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayExt.kt new file mode 100644 index 0000000000..78c3a2efae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/TagArrayExt.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.AmountTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BitcoinTxIdTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BlockTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.ProofTag + +fun TagArray.txid() = firstNotNullOfOrNull(BitcoinTxIdTag::parse) + +fun TagArray.amountInSats() = firstNotNullOfOrNull(AmountTag::parse) + +fun TagArray.block() = firstNotNullOfOrNull(BlockTag::parse) + +fun TagArray.proof() = firstNotNullOfOrNull(ProofTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/AmountTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/AmountTag.kt new file mode 100644 index 0000000000..cff5db482f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/AmountTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class AmountTag { + companion object { + const val TAG_NAME = "amount" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(amountInSats: Long) = arrayOf(TAG_NAME, amountInSats.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BitcoinTxIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BitcoinTxIdTag.kt new file mode 100644 index 0000000000..b4972fc327 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BitcoinTxIdTag.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +class BitcoinTxIdTag { + companion object { + const val TAG_NAME = "i" + const val PREFIX = "bitcoin:tx:" + const val TXID_LENGTH = 64 + + fun isTag(tag: Array) = + tag.has(1) && + tag[0] == TAG_NAME && + tag[1].startsWith(PREFIX) && + tag[1].length == PREFIX.length + TXID_LENGTH + + fun isTagged( + tag: Array, + txid: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == assembleScope(txid) + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].startsWith(PREFIX)) { return null } + + val txid = tag[1].substring(PREFIX.length) + ensure(txid.length == TXID_LENGTH) { return null } + ensure(txid == txid.lowercase()) { return null } + ensure(Hex.isHex(txid)) { return null } + + return txid + } + + fun parseScope(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].startsWith(PREFIX)) { return null } + return tag[1] + } + + fun assembleScope(txid: String) = PREFIX + txid.lowercase() + + fun assemble(txid: String) = arrayOf(TAG_NAME, assembleScope(txid)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BlockTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BlockTag.kt new file mode 100644 index 0000000000..a2e5fec2a9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/BlockTag.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class BlockTag( + val blockHashHex: String, + val height: Long, +) { + fun toTagArray() = assemble(blockHashHex, height) + + companion object { + const val TAG_NAME = "block" + const val BLOCK_HASH_LENGTH = 64 + + fun isTag(tag: Array) = + tag.has(2) && + tag[0] == TAG_NAME && + tag[1].length == BLOCK_HASH_LENGTH && + tag[2].isNotEmpty() + + fun parse(tag: Array): BlockTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == BLOCK_HASH_LENGTH) { return null } + ensure(Hex.isHex(tag[1])) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val height = tag[2].toLongOrNull() ?: return null + ensure(height >= 0) { return null } + + return BlockTag(tag[1].lowercase(), height) + } + + fun assemble( + blockHashHex: String, + height: Long, + ) = arrayOf(TAG_NAME, blockHashHex.lowercase(), height.toString()) + + fun assemble(block: BlockTag) = assemble(block.blockHashHex, block.height) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/ProofTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/ProofTag.kt new file mode 100644 index 0000000000..8586fc8836 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/tags/ProofTag.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class ProofTag( + val rawTxHex: String, + val merkleProofHex: String, +) { + fun toTagArray() = assemble(rawTxHex, merkleProofHex) + + companion object { + const val TAG_NAME = "proof" + + fun isTag(tag: Array) = + tag.has(2) && + tag[0] == TAG_NAME && + tag[1].isNotEmpty() + + fun parse(tag: Array): ProofTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(Hex.isHex(tag[1])) { return null } + ensure(Hex.isHex(tag[2])) { return null } + + return ProofTag(tag[1].lowercase(), tag[2].lowercase()) + } + + fun assemble( + rawTxHex: String, + merkleProofHex: String, + ) = arrayOf(TAG_NAME, rawTxHex.lowercase(), merkleProofHex.lowercase()) + + fun assemble(proof: ProofTag) = assemble(proof.rawTxHex, proof.merkleProofHex) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index d3b80ffe76..993c414635 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -286,6 +286,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -517,6 +518,7 @@ class EventFactory { NIP90EventPublishScheduleResponseEvent.KIND -> NIP90EventPublishScheduleResponseEvent(id, pubKey, createdAt, tags, content, sig) NIP90EventPowDelegationRequestEvent.KIND -> NIP90EventPowDelegationRequestEvent(id, pubKey, createdAt, tags, content, sig) NIP90EventPowDelegationResponseEvent.KIND -> NIP90EventPowDelegationResponseEvent(id, pubKey, createdAt, tags, content, sig) + OnchainZapEvent.KIND -> OnchainZapEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index 49c435f260..e993a83eed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -58,4 +58,27 @@ expect object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray + + /** + * BIP-341 / BIP-32 style additive tweak. + * + * Returns `pubKey + tweak·G` as a 33-byte compressed point. + * + * @param pubKey 32-byte x-only public key (the input is assumed to have the + * implicit even-y parity used by BIP-341 internal keys), or a + * 33-byte compressed public key. + * @param tweak 32-byte scalar. + */ + fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray + + /** + * Negate a private key: returns `(n - d) mod n` as 32 bytes. + * + * Used by the BIP-341 `taproot_tweak_seckey` algorithm when the internal + * key's public point has odd y. + */ + fun privKeyNegate(privKey: ByteArray): ByteArray } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt new file mode 100644 index 0000000000..776b99b100 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TaprootSigHash +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputTapKeySig +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Tests that `NostrSigner.signPsbt` is wired correctly across the hierarchy. */ +class NostrSignerPsbtTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val recipientPriv = "000000000000000000000000000000000000000000000000000000000000000d".hexToByteArray() + + private fun xOnly(priv: ByteArray) = Secp256k1Instance.compressedPubKeyFor(priv).copyOfRange(1, 33).toHexKey() + + @Test + fun internalSignerSignsAndFinalizes() = + runTest { + val signer = NostrSignerInternal(KeyPair(senderPriv)) + val senderPubKey = xOnly(senderPriv) + val recipientPubKey = xOnly(recipientPriv) + + val built = + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 4.0, + availableUtxos = listOf(Utxo("1".repeat(64), 0, 250_000L, 6)), + ) + + val signedHex = signer.signPsbt(built.psbt.toHex()) + val signedPsbt = Psbt.parse(signedHex) + + assertTrue(PsbtFinalizer.isFullySigned(signedPsbt)) + + val finalTx = PsbtFinalizer.finalize(signedPsbt) + val senderScript = TaprootAddress.scriptPubKeyForRecipient(senderPubKey) + val senderOutputKey = TaprootAddress.tweakOutputKey(senderPubKey.hexToByteArray()) + val spent = built.selectedUtxos.map { TxOut(it.valueSats, senderScript) } + + finalTx.inputs.forEachIndexed { i, input -> + val sigHash = TaprootSigHash.compute(finalTx, i, spent, TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(input.witness[0], sigHash, senderOutputKey), + "input $i must verify after NostrSigner.signPsbt", + ) + } + } + + @Test + fun signPsbtIsIdempotentlySafeOnAlreadySignedInputs() = + runTest { + val signer = NostrSignerInternal(KeyPair(senderPriv)) + val built = + OnchainZapBuilder.build( + xOnly(senderPriv), + xOnly(recipientPriv), + 20_000L, + 3.0, + listOf(Utxo("2".repeat(64), 1, 100_000L, 3)), + ) + val once = signer.signPsbt(built.psbt.toHex()) + val twice = signer.signPsbt(once) + // Re-signing must not clobber or duplicate the existing signature. + assertEquals(once, twice) + assertEquals(64, Psbt.parse(twice).inputTapKeySig(0)!!.size) + } + + @Test + fun readOnlySignerCannotSignPsbt() = + runTest { + // A key-less (watch-only) keypair. + val pubOnly = Secp256k1Instance.compressedPubKeyFor(senderPriv).copyOfRange(1, 33) + val signer = NostrSignerInternal(KeyPair(pubKey = pubOnly)) + val built = + OnchainZapBuilder.build( + xOnly(senderPriv), + xOnly(recipientPriv), + 20_000L, + 3.0, + listOf(Utxo("3".repeat(64), 0, 100_000L, 3)), + ) + assertFailsWith { + signer.signPsbt(built.psbt.toHex()) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt new file mode 100644 index 0000000000..3de8ede56a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.builder + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TaprootSigHash +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class OnchainZapBuilderTest { + private val senderPrivKey = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val senderPubKey = Secp256k1Instance.compressedPubKeyFor(senderPrivKey).copyOfRange(1, 33).toHexKey() + private val recipientPubKey = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000b".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(senderPubKey).lowercase() + private val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase() + + private fun utxo( + valueSats: Long, + index: Int, + confirmations: Int = 6, + ) = Utxo(txid = index.toString().padStart(64, '0'), vout = 0, valueSats = valueSats, confirmations = confirmations) + + @Test + fun buildsZapWithChangeAndBalances() { + val utxos = listOf(utxo(100_000L, 1), utxo(50_000L, 2)) + val result = + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 25_000L, + feeRateSatPerVByte = 5.0, + availableUtxos = utxos, + ) + + // One UTXO of 100k covers 25k + change + fee — greedy picks the largest first. + assertEquals(1, result.selectedUtxos.size) + assertEquals(25_000L, result.recipientSats) + assertTrue(result.changeSats > 0, "should have a change output") + assertTrue(result.feeSats > 0) + + // inputs == outputs + fee + val inputSum = result.selectedUtxos.sumOf { it.valueSats } + assertEquals(inputSum, result.recipientSats + result.changeSats + result.feeSats) + + val tx = result.psbt.unsignedTx + assertEquals(2, tx.outputs.size) + assertEquals(25_000L, tx.outputs[0].valueSats) + assertEquals(recipientScriptHex, tx.outputs[0].scriptPubKey.toHexKey()) + assertEquals(result.changeSats, tx.outputs[1].valueSats) + assertEquals(senderScriptHex, tx.outputs[1].scriptPubKey.toHexKey()) + } + + @Test + fun signedTransactionVerifies() { + val utxos = listOf(utxo(200_000L, 1)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 40_000L, 8.0, utxos) + + val signed = PsbtSigner.signKeyPathInputs(result.psbt, senderPrivKey) + assertEquals(result.selectedUtxos.size, signed) + assertTrue(PsbtFinalizer.isFullySigned(result.psbt)) + + val finalTx = PsbtFinalizer.finalize(result.psbt) + + // Every input's witness signature must verify against the sender's + // taproot output key over the BIP-341 sighash. + val senderOutputKey = TaprootAddress.tweakOutputKey(senderPubKey.hexToByteArray()) + val spentOutputs = result.selectedUtxos.map { TxOut(it.valueSats, senderScriptHex.hexToByteArray()) } + finalTx.inputs.forEachIndexed { i, input -> + assertEquals(1, input.witness.size) + val sigHash = TaprootSigHash.compute(finalTx, i, spentOutputs, TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(input.witness[0], sigHash, senderOutputKey), + "input $i signature must verify", + ) + } + + // Broadcast hex parses back to the same txid. + val rebroadcast = + com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction + .parse(PsbtFinalizer.finalizeToHex(result.psbt)) + assertEquals(finalTx.txid(), rebroadcast.txid()) + } + + @Test + fun dropsChangeWhenItWouldBeDust() { + // 30_000 utxo, pay 29_800: after the ~154-sat with-change fee the + // leftover change (~46 sats) is below the 330-sat dust threshold, so + // the builder must fold it into the fee instead of creating dust. + val utxos = listOf(utxo(30_000L, 1)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 29_800L, 1.0, utxos) + assertEquals(0L, result.changeSats, "tiny leftover must be absorbed into the fee") + assertEquals(1, result.psbt.unsignedTx.outputs.size, "no change output") + assertEquals(30_000L, result.recipientSats + result.feeSats) + } + + @Test + fun combinesMultipleUtxosWhenNeeded() { + val utxos = listOf(utxo(20_000L, 1), utxo(20_000L, 2), utxo(20_000L, 3)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 45_000L, 2.0, utxos) + assertTrue(result.selectedUtxos.size >= 3, "needs all three 20k UTXOs to cover 45k + fee") + } + + @Test + fun throwsOnInsufficientFunds() { + val utxos = listOf(utxo(10_000L, 1)) + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 1_000_000L, 5.0, utxos) + } + } + + @Test + fun rejectsSelfZap() { + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, senderPubKey, 25_000L, 5.0, listOf(utxo(100_000L, 1))) + } + } + + @Test + fun rejectsDustAmount() { + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 100L, 5.0, listOf(utxo(100_000L, 1))) + } + } + + @Test + fun excludesUnconfirmedUtxosByDefault() { + // Only a 0-conf UTXO is available — by default it must not be spent, + // so there are effectively no funds. + val unconfirmed = listOf(utxo(250_000L, 1, confirmations = 0)) + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 25_000L, 5.0, unconfirmed) + } + } + + @Test + fun spendsUnconfirmedUtxosOnlyWhenOptedIn() { + val unconfirmed = listOf(utxo(250_000L, 1, confirmations = 0)) + val result = + OnchainZapBuilder.build( + senderPubKey, + recipientPubKey, + 25_000L, + 5.0, + unconfirmed, + allowUnconfirmed = true, + ) + assertEquals(1, result.selectedUtxos.size) + assertEquals(0, result.selectedUtxos[0].confirmations) + } + + @Test + fun prefersConfirmedUtxosAndSkipsUnconfirmedOnes() { + // A large unconfirmed UTXO is ignored; the build falls back to the + // smaller confirmed ones. + val utxos = + listOf( + utxo(1_000_000L, 1, confirmations = 0), + utxo(30_000L, 2, confirmations = 3), + utxo(30_000L, 3, confirmations = 3), + ) + val result = OnchainZapBuilder.build(senderPubKey, recipientPubKey, 25_000L, 2.0, utxos) + assertTrue(result.selectedUtxos.all { it.confirmations > 0 }, "must not select the 0-conf UTXO") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt new file mode 100644 index 0000000000..e7ca67e7c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.chain + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CachingOnchainBackendTest { + /** Counts every delegate call so the cache's hit/miss behaviour is observable. */ + private class CountingBackend( + var txByTxid: (String) -> BitcoinTx? = { null }, + ) : OnchainBackend { + var getTxCalls = 0 + var tipCalls = 0 + var feeCalls = 0 + + override suspend fun getTx(txid: String): BitcoinTx? { + getTxCalls++ + return txByTxid(txid) + } + + override suspend fun getUtxosForAddress(address: String): List = emptyList() + + override suspend fun broadcast(rawTxHex: String): String = "broadcast" + + override suspend fun tipHeight(): Long { + tipCalls++ + return 800_000L + } + + override suspend fun feeEstimates(): FeeEstimates { + feeCalls++ + return FeeEstimates(20.0, 10.0, 5.0) + } + } + + private fun confirmedTx(txid: String) = BitcoinTx(txid = txid, outputs = emptyList(), confirmations = 1, blockHeight = 799_000L) + + private fun mempoolTx(txid: String) = BitcoinTx(txid = txid, outputs = emptyList(), confirmations = 0) + + @Test + fun confirmedTransactionIsCachedIndefinitely() = + runTest { + val delegate = CountingBackend { confirmedTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, nowSeconds = { clock }) + + cache.getTx("aa") + clock += 100_000 // way past any TTL + cache.getTx("aa") + + assertEquals(1, delegate.getTxCalls, "a confirmed tx must be served from cache forever") + } + + @Test + fun unconfirmedTransactionIsRefetchedAfterTtl() = + runTest { + val delegate = CountingBackend { mempoolTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, unconfirmedTxTtlSeconds = 60, nowSeconds = { clock }) + + cache.getTx("bb") + clock += 30 // within TTL + cache.getTx("bb") + assertEquals(1, delegate.getTxCalls, "still fresh — served from cache") + + clock += 60 // now past TTL + cache.getTx("bb") + assertEquals(2, delegate.getTxCalls, "stale unconfirmed tx must be re-fetched") + } + + @Test + fun notFoundIsNeverCached() = + runTest { + val delegate = CountingBackend { null } + val cache = CachingOnchainBackend(delegate, nowSeconds = { 1_000L }) + + assertNull(cache.getTx("cc")) + assertNull(cache.getTx("cc")) + assertEquals(2, delegate.getTxCalls, "a not-found result must not be cached") + } + + @Test + fun tipHeightAndFeesAreCachedWithinTtl() = + runTest { + val delegate = CountingBackend() + var clock = 1_000L + val cache = + CachingOnchainBackend( + delegate, + tipHeightTtlSeconds = 60, + feeEstimatesTtlSeconds = 60, + nowSeconds = { clock }, + ) + + cache.tipHeight() + cache.feeEstimates() + cache.tipHeight() + cache.feeEstimates() + assertEquals(1, delegate.tipCalls) + assertEquals(1, delegate.feeCalls) + + clock += 61 + cache.tipHeight() + cache.feeEstimates() + assertEquals(2, delegate.tipCalls, "tip height must be re-fetched after its TTL") + assertEquals(2, delegate.feeCalls, "fee estimates must be re-fetched after its TTL") + } + + @Test + fun txCacheIsBoundedAndEvictsTheOldest() = + runTest { + val delegate = CountingBackend { confirmedTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, maxCachedTxs = 2, nowSeconds = { clock }) + + cache.getTx("t1") // fetched at 1000 + clock += 1 + cache.getTx("t2") // fetched at 1001 + clock += 1 + cache.getTx("t3") // fetched at 1002 → cache full (2), evicts oldest (t1) + + // t1 was evicted → re-fetch. + cache.getTx("t1") + // t3 is still cached → no re-fetch. + cache.getTx("t3") + + assertEquals(4, delegate.getTxCalls, "t1/t2/t3 + a re-fetch of evicted t1") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt new file mode 100644 index 0000000000..7cc9252075 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BitcoinTransactionTest { + // The Bitcoin genesis block coinbase transaction — a well-known legacy tx. + private val genesisCoinbaseHex = + "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff" + + "4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72" + + "206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff" + + "0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f" + + "61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000" + private val genesisCoinbaseTxid = + "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" + + // BIP-341 wallet-test-vectors keyPathSpending: the raw unsigned transaction. + private val bip341UnsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + @Test + fun computesGenesisCoinbaseTxid() { + val tx = BitcoinTransaction.parse(genesisCoinbaseHex) + assertEquals(genesisCoinbaseTxid, tx.txid()) + } + + @Test + fun genesisCoinbaseRoundTrips() { + val tx = BitcoinTransaction.parse(genesisCoinbaseHex) + assertEquals(genesisCoinbaseHex, tx.serialize().toHexKey()) + assertEquals(1, tx.inputs.size) + assertEquals(1, tx.outputs.size) + assertEquals(5_000_000_000L, tx.outputs[0].valueSats) + } + + @Test + fun parsesBip341UnsignedTransaction() { + val tx = BitcoinTransaction.parse(bip341UnsignedTxHex) + assertEquals(2L, tx.version) + assertEquals(9, tx.inputs.size) + assertEquals(2, tx.outputs.size) + assertEquals(1_000_000_000L, tx.outputs[0].valueSats) + assertEquals(3_410_000_000L, tx.outputs[1].valueSats) + // No witness on the unsigned tx → legacy serialization round-trips exactly. + assertEquals(bip341UnsignedTxHex, tx.serialize().toHexKey()) + } + + @Test + fun segwitSerializationAddsMarkerFlagAndWitness() { + val base = BitcoinTransaction.parse(bip341UnsignedTxHex) + val withWitness = + base.copy( + inputs = + base.inputs.mapIndexed { i, input -> + if (i == 0) input.copy(witness = listOf(ByteArray(64) { 0x11 })) else input + }, + ) + val serialized = withWitness.serialize().toHexKey() + // version(4 bytes = 8 hex) then segwit marker+flag 0001 + assertTrue(serialized.substring(8, 12) == "0001", "expected segwit marker+flag") + // txid is witness-stripped, so it is unchanged by adding a witness. + assertEquals(base.txid(), withWitness.txid()) + } + + @Test + fun varIntRoundTrips() { + val values = listOf(0L, 1L, 0xFCL, 0xFDL, 0xFFFFL, 0x10000L, 0xFFFFFFFFL, 0x100000000L) + for (v in values) { + val bytes = BitcoinWriter().writeVarInt(v).toByteArray() + assertEquals(v, BitcoinReader(bytes).readVarInt(), "varint round-trip failed for $v") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt new file mode 100644 index 0000000000..767e54bc61 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PsbtSignatureVerifierTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val senderPubKey = Secp256k1Instance.compressedPubKeyFor(senderPriv).copyOfRange(1, 33).toHexKey() + private val recipientPubKey = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000b".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private fun signedPsbt(): Psbt { + val built = + OnchainZapBuilder.build( + senderPubKey, + recipientPubKey, + 40_000L, + 5.0, + listOf(Utxo("1".repeat(64), 0, 250_000L, 6)), + ) + PsbtSigner.signKeyPathInputs(built.psbt, senderPriv) + return built.psbt + } + + @Test + fun acceptsAProperlySignedPsbt() { + assertTrue(PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt())) + } + + @Test + fun rejectsWhenAnInputIsUnsigned() { + val psbt = signedPsbt() + psbt.inputs[0].remove(Psbt.PSBT_IN_TAP_KEY_SIG) + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt)) + } + + @Test + fun rejectsAGarbageSignature() { + val psbt = signedPsbt() + psbt.setInputTapKeySig(0, ByteArray(64) { 0x11 }) + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt)) + } + + @Test + fun rejectsSignaturesOverATamperedTransaction() { + // Sign tx A, then graft A's signed input maps onto a PSBT whose + // transaction has a mutated output. The signatures no longer commit to + // the transaction being verified — exactly the substitution attack the + // verifier exists to catch. + val signed = signedPsbt() + val original = signed.unsignedTx + val tamperedTx = + original.copy( + outputs = + original.outputs.mapIndexed { i, o -> + if (i == 0) o.copy(valueSats = o.valueSats + 10_000L) else o + }, + ) + val tamperedGlobal = PsbtMap() + tamperedGlobal.put(Psbt.PSBT_GLOBAL_UNSIGNED_TX, tamperedTx.serializeForId()) + val tamperedPsbt = Psbt(tamperedGlobal, signed.inputs, signed.outputs) + + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(tamperedPsbt)) + } + + @Test + fun rejectsWhenWitnessUtxoMissing() { + val psbt = signedPsbt() + psbt.inputs[0].remove(Psbt.PSBT_IN_WITNESS_UTXO) + assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt new file mode 100644 index 0000000000..ae35029efc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * End-to-end signing tests for the PSBT pipeline. Anchored on the BIP-341 + * `keyPathSpending` test vector (input 0) so the BIP-341 tweak, the BIP-341 + * sighash, and the BIP-340 signature are all validated against an authoritative + * source. + */ +class PsbtSignerTest { + private val unsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + private val spentOutputs = + listOf( + TxOut(420_000_000L, "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()), + TxOut(462_000_000L, "5120147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3".hexToByteArray()), + TxOut(294_000_000L, "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac".hexToByteArray()), + TxOut(504_000_000L, "5120e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e".hexToByteArray()), + TxOut(630_000_000L, "512091b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605".hexToByteArray()), + TxOut(378_000_000L, "00147dd65592d0ab2fe0d0257d571abf032cd9db93dc".hexToByteArray()), + TxOut(672_000_000L, "512075169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831".hexToByteArray()), + TxOut(546_000_000L, "5120712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5".hexToByteArray()), + TxOut(588_000_000L, "512077e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220".hexToByteArray()), + ) + + // BIP-341 keyPathSpending input 0. + private val internalPrivKey0 = "6b973d88838f27366ed61c9ad6367663045cb456e28335c109e30717ae0c6baa" + private val internalPubKey0 = "d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d" + private val tweakedPrivKey0 = "2405b971772ad26915c8dcdf10f238753a9b837e5f8e6a86fd7c0cce5b7296d9" + private val outputKey0 = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" + private val sigHashSingle0 = "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555" + + // BIP-341 keyPathSpending input 0 — the full expected witness (64-byte + // signature + the SIGHASH_SINGLE 0x03 byte). + private val expectedWitness0 = + "ed7c1647cb97379e76892be0cacff57ec4a7102aa24296ca39af7541246d8ff1" + + "4d38958d4cc1e2e478e4d4a764bbfd835b16d4e314b72937b29833060b87276c03" + + @Test + fun producesExactBip341VectorSignature() { + // Pins the full BIP-340 signing pipeline (and its nonce determinism) to + // the authoritative BIP-341 wallet test vector: signing the vector's + // sighash with the vector's tweaked key must reproduce the vector's + // witness signature byte-for-byte. + val sig = + Secp256k1Instance.signSchnorr( + sigHashSingle0.hexToByteArray(), + tweakedPrivKey0.hexToByteArray(), + ) + // Witness is the 64-byte signature; the trailing 0x03 is the sighash type + // appended by the PSBT signer, not part of the BIP-340 signature itself. + assertEquals(expectedWitness0.substring(0, 128), sig.toHexKey()) + } + + private fun psbtForVectorTx(): Psbt { + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) } + psbt.setInputTapInternalKey(0, internalPubKey0.hexToByteArray()) + return psbt + } + + @Test + fun tweakSecretKeyMatchesBip341Vector() { + val tweaked = TaprootAddress.tweakSecretKey(internalPrivKey0.hexToByteArray()) + assertEquals(tweakedPrivKey0, tweaked.toHexKey()) + } + + @Test + fun signsAndProducesVerifiableSignature_sighashSingle() { + val psbt = psbtForVectorTx() + psbt.setInputSighashType(0, TaprootSigHash.SIGHASH_SINGLE) + + val count = PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + assertEquals(1, count, "exactly input 0 should be signed") + + val sig = psbt.inputTapKeySig(0)!! + // Non-default sighash → 65-byte signature with the type byte appended. + assertEquals(65, sig.size) + assertEquals(TaprootSigHash.SIGHASH_SINGLE, sig[64].toInt()) + + // The 64-byte BIP-340 signature must verify against the vector's exact + // SIGHASH_SINGLE sighash and the input's taproot output key. + val ok = + Secp256k1Instance.verifySchnorr( + sig.copyOfRange(0, 64), + sigHashSingle0.hexToByteArray(), + outputKey0.hexToByteArray(), + ) + assertTrue(ok, "signature must verify against the BIP-341 vector sighash + output key") + } + + @Test + fun signsDefaultSighashHappyPath() { + // The path NIP-BC actually uses: SIGHASH_DEFAULT, bare 64-byte signature. + val psbt = psbtForVectorTx() + PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + + val sig = psbt.inputTapKeySig(0)!! + assertEquals(64, sig.size, "SIGHASH_DEFAULT → bare 64-byte signature") + + val expectedSigHash = + TaprootSigHash.compute(psbt.unsignedTx, 0, spentOutputs, TaprootSigHash.SIGHASH_DEFAULT) + val ok = Secp256k1Instance.verifySchnorr(sig, expectedSigHash, outputKey0.hexToByteArray()) + assertTrue(ok, "default-sighash signature must verify against the output key") + } + + @Test + fun skipsInputsTheKeyDoesNotControl() { + // A wrong internal key on input 0 → nothing to sign. + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) } + psbt.setInputTapInternalKey(0, "ab".repeat(32).hexToByteArray()) + + val count = PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + assertEquals(0, count) + } + + @Test + fun failsWhenWitnessUtxoMissing() { + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + psbt.setInputTapInternalKey(0, internalPubKey0.hexToByteArray()) + // No witness UTXOs set → sighash cannot be computed. + assertFailsWith { + PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + } + } + + @Test + fun finalizeMovesSignatureIntoWitness() { + // A minimal self-contained 1-in / 1-out taproot spend. + val privKey = "0000000000000000000000000000000000000000000000000000000000000003".hexToByteArray() + val xOnly = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) + val outputKey = TaprootAddress.tweakOutputKey(xOnly) + val prevScript = TaprootAddress.outputKeyToScriptPubKey(outputKey) + + val tx = + BitcoinTransaction( + version = 2L, + inputs = listOf(TxIn(OutPoint("a".repeat(64), 0L), sequence = 0xFFFFFFFFL)), + outputs = listOf(TxOut(90_000L, prevScript)), + lockTime = 0L, + ) + val psbt = Psbt.fromUnsignedTx(tx) + psbt.setInputWitnessUtxo(0, TxOut(100_000L, prevScript)) + psbt.setInputTapInternalKey(0, xOnly) + + assertTrue(!PsbtFinalizer.isFullySigned(psbt)) + PsbtSigner.signKeyPathInputs(psbt, privKey) + assertTrue(PsbtFinalizer.isFullySigned(psbt)) + + val finalTx = PsbtFinalizer.finalize(psbt) + assertEquals(1, finalTx.inputs[0].witness.size) + assertEquals(64, finalTx.inputs[0].witness[0].size) + assertTrue(finalTx.hasWitness) + // txid is witness-stripped — unchanged by finalization. + assertEquals(tx.txid(), finalTx.txid()) + + // The signature in the witness must verify. + val sigHash = TaprootSigHash.compute(tx, 0, listOf(TxOut(100_000L, prevScript)), TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(finalTx.inputs[0].witness[0], sigHash, outputKey), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt new file mode 100644 index 0000000000..7fcb8026a2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PsbtTest { + private fun sampleTx() = + BitcoinTransaction( + version = 2L, + inputs = + listOf( + TxIn(OutPoint("a".repeat(64), 0L), sequence = 0xFFFFFFFFL), + TxIn(OutPoint("b".repeat(64), 3L), sequence = 0xFFFFFFFFL), + ), + outputs = + listOf( + TxOut(25_000L, "5120${"c".repeat(64)}".hexToByteArray()), + TxOut(99_000L, "5120${"d".repeat(64)}".hexToByteArray()), + ), + lockTime = 0L, + ) + + @Test + fun emptyPsbtSerializesToExpectedBytes() { + // version 2, 0 inputs, 0 outputs, locktime 0 → 10-byte tx. + val tx = BitcoinTransaction(2L, emptyList(), emptyList(), 0L) + val psbt = Psbt.fromUnsignedTx(tx) + // magic | keylen 01 | keytype 00 | valuelen 0a | <10-byte tx> | global separator 00 + assertEquals("70736274ff01000a0200000000000000000000", psbt.toHex()) + } + + @Test + fun rejectsBadMagic() { + assertFailsWith { + Psbt.parse("00112233445566778899") + } + } + + @Test + fun roundTripsWithTypedFields() { + val tx = sampleTx() + val psbt = Psbt.fromUnsignedTx(tx) + + // Input 0: witness utxo + tap internal key + sighash type. + psbt.setInputWitnessUtxo(0, TxOut(120_000L, "5120${"e".repeat(64)}".hexToByteArray())) + psbt.setInputTapInternalKey(0, "f".repeat(64).hexToByteArray()) + psbt.setInputSighashType(0, TaprootSigHash.SIGHASH_DEFAULT) + + // Input 1: a different witness utxo. + psbt.setInputWitnessUtxo(1, TxOut(80_000L, "0014${"1".repeat(40)}".hexToByteArray())) + + // Output 0: change-style tap internal key. + psbt.setOutputTapInternalKey(0, "2".repeat(64).hexToByteArray()) + + val reparsed = Psbt.parse(psbt.serialize()) + + assertEquals(tx.txid(), reparsed.unsignedTx.txid()) + assertEquals(120_000L, reparsed.inputWitnessUtxo(0)!!.valueSats) + assertEquals("f".repeat(64), reparsed.inputTapInternalKey(0)!!.toHexKey()) + assertEquals(TaprootSigHash.SIGHASH_DEFAULT, reparsed.inputSighashType(0)) + assertEquals(80_000L, reparsed.inputWitnessUtxo(1)!!.valueSats) + assertNull(reparsed.inputTapInternalKey(1)) + assertNull(reparsed.inputTapKeySig(0)) + // Exact byte round-trip. + assertEquals(psbt.toHex(), reparsed.toHex()) + } + + @Test + fun keySigAccessorEnforcesLength() { + val psbt = Psbt.fromUnsignedTx(sampleTx()) + assertFailsWith { + psbt.setInputTapKeySig(0, ByteArray(32)) + } + psbt.setInputTapKeySig(0, ByteArray(64) { 0x07 }) + assertTrue(psbt.inputTapKeySig(0)!!.size == 64) + } + + @Test + fun preservesUnknownRecords() { + val psbt = Psbt.fromUnsignedTx(sampleTx()) + // An unrecognized global keytype must survive a round-trip untouched. + psbt.global.records.add(PsbtRecord(0x7E, ByteArray(0), byteArrayOf(0x01, 0x02, 0x03))) + val reparsed = Psbt.parse(psbt.serialize()) + val unknown = reparsed.global.records.firstOrNull { it.keyType == 0x7E } + assertTrue(unknown != null && unknown.value.contentEquals(byteArrayOf(0x01, 0x02, 0x03))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt new file mode 100644 index 0000000000..5e55edc11d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Validates [TaprootSigHash] against the BIP-341 `wallet-test-vectors.json` + * `keyPathSpending` cases — all seven spendable inputs, covering every base + * sighash type and both ANYONECANPAY variants. + */ +class TaprootSigHashTest { + private val unsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + // utxosSpent from the vector, in input order. + private val spentOutputs = + listOf( + TxOut(420_000_000L, "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()), + TxOut(462_000_000L, "5120147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3".hexToByteArray()), + TxOut(294_000_000L, "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac".hexToByteArray()), + TxOut(504_000_000L, "5120e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e".hexToByteArray()), + TxOut(630_000_000L, "512091b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605".hexToByteArray()), + TxOut(378_000_000L, "00147dd65592d0ab2fe0d0257d571abf032cd9db93dc".hexToByteArray()), + TxOut(672_000_000L, "512075169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831".hexToByteArray()), + TxOut(546_000_000L, "5120712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5".hexToByteArray()), + TxOut(588_000_000L, "512077e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220".hexToByteArray()), + ) + + private val tx = BitcoinTransaction.parse(unsignedTxHex) + + private fun check( + inputIndex: Int, + hashType: Int, + expected: String, + ) { + val sigHash = TaprootSigHash.compute(tx, inputIndex, spentOutputs, hashType) + assertEquals(expected, sigHash.toHexKey(), "sighash mismatch for input $inputIndex hashType $hashType") + } + + @Test + fun input4_sighashDefault() = check(4, 0x00, "4f900a0bae3f1446fd48490c2958b5a023228f01661cda3496a11da502a7f7ef") + + @Test + fun input3_sighashAll() = check(3, 0x01, "bf013ea93474aa67815b1b6cc441d23b64fa310911d991e713cd34c7f5d46669") + + @Test + fun input6_sighashNone() = check(6, 0x02, "15f25c298eb5cdc7eb1d638dd2d45c97c4c59dcaec6679cfc16ad84f30876b85") + + @Test + fun input0_sighashSingle() = check(0, 0x03, "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555") + + @Test + fun input8_sighashAllAnyoneCanPay() = check(8, 0x81, "cccb739eca6c13a8a89e6e5cd317ffe55669bbda23f2fd37b0f18755e008edd2") + + @Test + fun input7_sighashNoneAnyoneCanPay() = check(7, 0x82, "cd292de50313804dabe4685e83f923d2969577191a3e1d2882220dca88cbeb10") + + @Test + fun input1_sighashSingleAnyoneCanPay() = check(1, 0x83, "325a644af47e8a5a2591cda0ab0723978537318f10e6a63d4eed783b96a71a4d") +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt new file mode 100644 index 0000000000..3b07618eca --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Tests for the segwit (BIP-173 / BIP-350) address encoder. + * + * Vectors come from BIP-350 and BIP-341. + */ +class SegwitAddressTest { + // ============================================================ + // BIP-350 / BIP-173 known-good vectors + // ============================================================ + + @Test + fun encodeMainnetWitnessV0_20Byte() { + // bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 + val program = "751e76e8199196d454941c45d1b3a323f1433bd6".hexToByteArray() + val address = SegwitAddress.encode("bc", 0, program) + assertEquals("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", address) + } + + @Test + fun encodeMainnetWitnessV16_2Byte() { + // BIP-350 short address: BC1SW50QGDZ25J (lowercased) + val program = "751e".hexToByteArray() + val address = SegwitAddress.encode("bc", 16, program) + assertEquals("bc1sw50qgdz25j", address) + } + + @Test + fun encodesP2trAgainstBip341WalletTestVectors() { + // All seven `scriptPubKey` entries from the BIP-341 wallet-test-vectors: + // (tweaked output key, expected bip350Address). + val vectors = + listOf( + "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" to + "bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5", + "147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3" to + "bc1pz37fc4cn9ah8anwm4xqqhvxygjf9rjf2resrw8h8w4tmvcs0863sa2e586", + "e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e" to + "bc1punvppl2stp38f7kwv2u2spltjuvuaayuqsthe34hd2dyy5w4g58qqfuag5", + "712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5" to + "bc1pwyjywgrd0ffr3tx8laflh6228dj98xkjj8rum0zfpd6h0e930h6saqxrrm", + "77e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220" to + "bc1pwl3s54fzmk0cjnpl3w9af39je7pv5ldg504x5guk2hpecpg2kgsqaqstjq", + "91b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605" to + "bc1pjxmy65eywgafs5tsunw95ruycpqcqnev6ynxp7jaasylcgtcxczs6n332e", + "75169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831" to + "bc1pw5tf7sqp4f50zka7629jrr036znzew70zxyvvej3zrpf8jg8hqcssyuewe", + ) + for ((outputKey, address) in vectors) { + assertEquals(address, SegwitAddress.encodeP2TR(outputKey.hexToByteArray())) + // And it must decode back to the same output key. + assertEquals(outputKey, SegwitAddress.decode(address).program.toHexKey()) + } + } + + // ============================================================ + // Round-trip + // ============================================================ + + @Test + fun roundTripTaproot() { + val outputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray() + val address = SegwitAddress.encodeP2TR(outputKey) + val decoded = SegwitAddress.decode(address) + assertEquals("bc", decoded.hrp) + assertEquals(1, decoded.witnessVersion) + assertEquals(outputKey.toHexKey(), decoded.program.toHexKey()) + } + + @Test + fun roundTripV0_20Byte() { + val program = "751e76e8199196d454941c45d1b3a323f1433bd6".hexToByteArray() + val address = SegwitAddress.encode("bc", 0, program) + val decoded = SegwitAddress.decode(address) + assertEquals(0, decoded.witnessVersion) + assertEquals(program.toHexKey(), decoded.program.toHexKey()) + } + + // ============================================================ + // Validation + // ============================================================ + + @Test + fun rejectsTaprootProgramOfWrongLength() { + val tooShort = ByteArray(31) + assertFailsWith { + SegwitAddress.encodeP2TR(tooShort) + } + } + + @Test + fun rejectsInvalidWitnessVersion() { + assertFailsWith { + SegwitAddress.encode("bc", 17, ByteArray(32)) + } + } + + @Test + fun rejectsV0WrongProgramLength() { + assertFailsWith { + // v0 must be 20 or 32 bytes — 21 is invalid. + SegwitAddress.encode("bc", 0, ByteArray(21)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt new file mode 100644 index 0000000000..9dc199f4c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.taproot + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * BIP-341 key-path-only taproot derivation tests. + * + * Vectors come from the BIP-341 wallet-test-vectors (`keyPathSpending` cases + * with `scriptTree=null`). + */ +class TaprootAddressTest { + // ============================================================ + // BIP-341 key-path-only (no script tree) vector + // ============================================================ + + private val internalKey = "d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d" + private val expectedTweak = "b86e7be8f39bab32a6f2c0443abbc210f0edac0e2c53d501b36b64437d9c6c70" + private val expectedOutputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" + private val expectedScriptPubKey = + "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" + + @Test + fun computesTaggedHash() { + val tweak = TaprootAddress.tapTweakHash(internalKey.hexToByteArray()) + assertEquals(expectedTweak, tweak.toHexKey()) + } + + @Test + fun tweaksToOutputKey() { + val outputKey = TaprootAddress.tweakOutputKey(internalKey.hexToByteArray()) + assertEquals(expectedOutputKey, outputKey.toHexKey()) + } + + @Test + fun computesScriptPubKey() { + val script = TaprootAddress.scriptPubKeyForRecipient(internalKey) + assertEquals(expectedScriptPubKey, script.toHexKey()) + } + + // BIP-341 wallet-test-vectors `scriptPubKey` entry 1 (scriptTree = null): + // internalPubkey d6889cb0… → bip350Address. + private val expectedAddress = "bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5" + + @Test + fun derivesExactBip350Address() { + // Full key-path-only derivation pinned to the BIP-341 wallet test vector. + assertEquals(expectedAddress, TaprootAddress.fromPubKey(internalKey)) + assertEquals(expectedAddress, TaprootAddress.fromPubKey(internalKey.hexToByteArray())) + } + + @Test + fun derivedAddressRoundTripsToOutputKey() { + val address = TaprootAddress.fromPubKey(internalKey) + val decoded = SegwitAddress.decode(address) + assertEquals(1, decoded.witnessVersion) + assertEquals(expectedOutputKey, decoded.program.toHexKey()) + } + + // ============================================================ + // Sanity: address prefix + length + // ============================================================ + + @Test + fun derivedAddressHasTaprootPrefix() { + // Use a different x-only key; output should still be a bc1p taproot address. + val pubKey = "8b34731183a85a4fc1a3ea9e8caa14e72ff31716bf6dd0d2f8c93f5b14e44f5d" + val address = TaprootAddress.fromPubKey(pubKey) + assertTrue(address.startsWith("bc1p"), "expected bc1p..., got $address") + assertEquals(62, address.length, "P2TR address must be 62 chars") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt new file mode 100644 index 0000000000..d6fab46b71 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.verify + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTxOutput +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.AmountTag +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.tags.BitcoinTxIdTag +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Verification logic tests using an in-memory fake [OnchainBackend]. + * + * Crypto-derived recipient scripts come from the real `TaprootAddress` + * implementation so the tests cover the full sum-matching-outputs path. + */ +class OnchainZapVerifierTest { + // Two arbitrary x-only Nostr pubkeys. + private val senderHex = "d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d" + private val recipientHex = "8b34731183a85a4fc1a3ea9e8caa14e72ff31716bf6dd0d2f8c93f5b14e44f5d" + + private val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientHex).lowercase() + private val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(senderHex).lowercase() + + private val txid = "a".repeat(64) + + private fun mkEvent( + sender: HexKey = senderHex, + recipient: HexKey = recipientHex, + amountSats: Long = 25000L, + txidValue: String = txid, + ): OnchainZapEvent { + val tags = + arrayOf( + BitcoinTxIdTag.assemble(txidValue), + arrayOf("p", recipient), + AmountTag.assemble(amountSats), + arrayOf("alt", "Onchain zap"), + ) + return OnchainZapEvent( + id = "b".repeat(64), + pubKey = sender, + createdAt = 0L, + tags = tags, + content = "", + sig = "c".repeat(128), + ) + } + + private class FakeBackend( + private val tx: BitcoinTx?, + private val tip: Long = 800_006L, + ) : OnchainBackend { + override suspend fun getTx(txid: String): BitcoinTx? = if (tx?.txid == txid) tx else null + + override suspend fun getUtxosForAddress(address: String): List = emptyList() + + override suspend fun broadcast(rawTxHex: String): String = throw UnsupportedOperationException() + + override suspend fun tipHeight(): Long = tip + + override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0) + } + + @Test + fun confirmedZap() = + runTest { + val tx = + BitcoinTx( + txid = txid, + outputs = + listOf( + BitcoinTxOutput(0, 25000L, recipientScriptHex), + BitcoinTxOutput(1, 99000L, senderScriptHex), // change + ), + confirmations = 1, + blockHashHex = "f".repeat(64), + blockHeight = 800_000L, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx, tip = 800_006L)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(25000L, result.verifiedSats) + assertEquals(recipientHex, result.recipientPubKey) + assertEquals(800_000L, result.blockHeight) + // Real depth computed from the chain tip: 800006 - 800000 + 1 = 7. + assertEquals(7, result.confirmations) + } + + @Test + fun sumsMultipleOutputsToSameRecipient() = + runTest { + val tx = + BitcoinTx( + txid = txid, + outputs = + listOf( + BitcoinTxOutput(0, 10000L, recipientScriptHex), + BitcoinTxOutput(1, 15000L, recipientScriptHex), + BitcoinTxOutput(2, 50000L, senderScriptHex), + ), + confirmations = 1, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(25000L, result.verifiedSats) + } + + @Test + fun rejectsSelfZap() = + runTest { + val verifier = OnchainZapVerifier(FakeBackend(tx = null)) + val result = verifier.verify(mkEvent(sender = recipientHex, recipient = recipientHex)) + + assertIs(result) + assertEquals(VerifiedOnchainZap.Rejected.Reason.SELF_ZAP, result.reason) + } + + @Test + fun rejectsMissingTransaction() = + runTest { + val verifier = OnchainZapVerifier(FakeBackend(tx = null)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND, result.reason) + } + + @Test + fun rejectsZeroVerifiedAmount() = + runTest { + // TX exists but pays no output to the recipient. + val tx = + BitcoinTx( + txid = txid, + outputs = + listOf( + BitcoinTxOutput(0, 99000L, senderScriptHex), + ), + confirmations = 5, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(VerifiedOnchainZap.Rejected.Reason.ZERO_VERIFIED_AMOUNT, result.reason) + } + + @Test + fun pendingWhenUnconfirmed() = + runTest { + val tx = + BitcoinTx( + txid = txid, + outputs = listOf(BitcoinTxOutput(0, 7000L, recipientScriptHex)), + confirmations = 0, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent(amountSats = 7000L)) + + assertIs(result) + assertEquals(7000L, result.verifiedSats) + } + + @Test + fun capsClaimedAmountAtVerified() = + runTest { + // Sender claims 1,000,000 but only 5,000 went to the recipient. + val tx = + BitcoinTx( + txid = txid, + outputs = listOf(BitcoinTxOutput(0, 5000L, recipientScriptHex)), + confirmations = 1, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent(amountSats = 1_000_000L)) + + assertIs(result) + assertEquals(5000L, result.verifiedSats, "verifier must report the on-chain amount, not the claimed amount") + assertTrue(result.verifiedSats < 1_000_000L) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEventTest.kt new file mode 100644 index 0000000000..6919e20b53 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/zap/OnchainZapEventTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.zap + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Asserts the on-the-wire tag structure of kind:8333 events against the + * NIP-BC spec, so cross-app interoperability doesn't drift. + */ +class OnchainZapEventTest { + private val txid = "a".repeat(64) + private val recipient = "8b34731183a85a4fc1a3ea9e8caa14e72ff31716bf6dd0d2f8c93f5b14e44f5d" + + private fun Array>.tag(name: String) = firstOrNull { it.isNotEmpty() && it[0] == name } + + @Test + fun profileZapHasExactlyTheSpecTags() { + val template = OnchainZapEvent.buildProfileZap(txid, recipient, 25_000L) + + assertEquals(OnchainZapEvent.KIND, template.kind) + val tags = template.tags + + // Required NIP-BC tags, exact values. + assertEquals(listOf("alt", "Onchain zap: 25000 sats"), tags.tag("alt")?.toList()) + assertEquals(listOf("i", "bitcoin:tx:$txid"), tags.tag("i")?.toList()) + assertEquals(listOf("p", recipient), tags.tag("p")?.toList()) + assertEquals(listOf("amount", "25000"), tags.tag("amount")?.toList()) + + // A profile zap targets no event — no e / a / k tags. + assertNull(tags.tag("e"), "profile zap must not carry an e tag") + assertNull(tags.tag("a"), "profile zap must not carry an a tag") + assertNull(tags.tag("k"), "profile zap must not carry a k tag") + } + + @Test + fun eventZapCarriesEventAndKindTags() { + val zapped = + Event( + id = "b".repeat(64), + pubKey = "c".repeat(64), + createdAt = 1_700_000_000L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = "d".repeat(128), + ) + val template = + OnchainZapEvent.build(txid, recipient, 21_000L, EventHintBundle(zapped)) + val tags = template.tags + + assertEquals(listOf("i", "bitcoin:tx:$txid"), tags.tag("i")?.toList()) + assertEquals(listOf("p", recipient), tags.tag("p")?.toList()) + assertEquals(listOf("amount", "21000"), tags.tag("amount")?.toList()) + assertEquals("Onchain zap: 21000 sats", tags.tag("alt")?.get(1)) + + // The zapped event is referenced by an `e` tag and its kind by a `k` tag. + val eTag = tags.tag("e") + assertTrue(eTag != null && eTag[1] == "b".repeat(64), "e tag must reference the zapped event id") + assertEquals(listOf("k", "1"), tags.tag("k")?.toList()) + // Kind 1 is not addressable — no a tag. + assertNull(tags.tag("a")) + } + + @Test + fun parsedBackEventExposesTheSameFields() { + // The receipt must round-trip through the event accessors used by the + // verifier and feed code. + val template = OnchainZapEvent.buildProfileZap(txid, recipient, 25_000L) + val event = + OnchainZapEvent( + id = "e".repeat(64), + pubKey = "f".repeat(64), + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "0".repeat(128), + ) + assertEquals(txid, event.txid()) + assertEquals(recipient, event.recipient()) + assertEquals(25_000L, event.claimedAmountInSats()) + assertTrue(event.isProfileZap()) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt new file mode 100644 index 0000000000..d2058cb737 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.chain + +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.utils.Log +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync + +/** + * [OnchainBackend] implementation that talks to an Esplora-compatible HTTP + * API (mempool.space, blockstream.info, self-hosted). + * + * The API surface used: + * - `GET /tx/{txid}` — transaction JSON + * - `GET /address/{addr}/utxo` — UTXO list for a derived address + * - `POST /tx` — broadcast (body = raw tx hex) + * - `GET /blocks/tip/height` — chain tip + * - `GET /v1/fees/recommended` — fee tiers (mempool.space); falls back to + * `GET /fee-estimates` (the standard Esplora target→rate map) on 404 + * + * The `baseUrl` is supplied as a function so callers can hot-swap endpoints + * from `AccountSettings` without rebuilding the backend. + * + * @param baseUrl Function returning the base URL with trailing slash stripped + * (e.g. "https://mempool.space/api"). + * @param client Shared OkHttp client. + */ +class EsploraBackend( + private val baseUrl: () -> String, + private val client: OkHttpClient, +) : OnchainBackend { + private val logTag = "EsploraBackend" + + override suspend fun getTx(txid: String): BitcoinTx? { + val url = "${baseUrl()}/tx/$txid" + val request = + Request + .Builder() + .header("Accept", "application/json") + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + when { + response.code == 404 -> null + + response.isSuccessful -> parseTx(response.body.string()) + + else -> throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + } + } + + override suspend fun getUtxosForAddress(address: String): List { + val url = "${baseUrl()}/address/$address/utxo" + val request = + Request + .Builder() + .header("Accept", "application/json") + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + parseUtxoList(response.body.string()) + } + } + + override suspend fun broadcast(rawTxHex: String): String { + val url = "${baseUrl()}/tx" + val request = + Request + .Builder() + .url(url) + .post(rawTxHex.toRequestBody("text/plain".toMediaType())) + .build() + return client.newCall(request).executeAsync().use { response -> + val body = response.body.string() + if (!response.isSuccessful) { + throw OnchainBackendException( + "POST $url failed: ${response.code} ${response.message} body=$body", + ) + } + body.trim() + } + } + + override suspend fun tipHeight(): Long { + val url = "${baseUrl()}/blocks/tip/height" + val request = + Request + .Builder() + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + response.body + .string() + .trim() + .toLong() + } + } + + override suspend fun feeEstimates(): FeeEstimates { + // mempool.space-style recommended-fees endpoint. + val recommendedUrl = "${baseUrl()}/v1/fees/recommended" + val recommended = + Request + .Builder() + .header("Accept", "application/json") + .url(recommendedUrl) + .get() + .build() + client.newCall(recommended).executeAsync().use { response -> + when { + response.isSuccessful -> { + return parseRecommendedFees(response.body.string()) + } + + // Standard Esplora servers (blockstream.info, self-hosted) don't + // expose /v1/fees/recommended — fall back to /fee-estimates. + response.code == 404 -> { + Unit + } + + else -> { + throw OnchainBackendException( + "GET $recommendedUrl failed: ${response.code} ${response.message}", + ) + } + } + } + + val estimatesUrl = "${baseUrl()}/fee-estimates" + val estimates = + Request + .Builder() + .header("Accept", "application/json") + .url(estimatesUrl) + .get() + .build() + return client.newCall(estimates).executeAsync().use { response -> + if (!response.isSuccessful) { + throw OnchainBackendException( + "GET $estimatesUrl failed: ${response.code} ${response.message}", + ) + } + parseFeeEstimates(response.body.string()) + } + } + + internal fun parseTx(json: String): BitcoinTx { + val node = JacksonMapper.mapper.readTree(json) + val txid = node["txid"].asText() + val status = node["status"] + val confirmed = status?.get("confirmed")?.asBoolean() == true + val blockHeight = status?.get("block_height")?.asLong() + val blockHash = status?.get("block_hash")?.asText() + + val outputs = + node["vout"].mapIndexed { idx, vout -> + BitcoinTxOutput( + index = idx, + valueSats = vout["value"].asLong(), + scriptPubKeyHex = vout["scriptpubkey"].asText().lowercase(), + ) + } + + val confirmations = + if (confirmed && blockHeight != null) { + // Esplora returns confirmations only via /tx/{txid}/status sometimes; + // we conservatively report 1 here and let callers re-query tip if they + // need a precise count. + 1 + } else { + 0 + } + + return BitcoinTx( + txid = txid, + outputs = outputs, + confirmations = confirmations, + blockHashHex = blockHash, + blockHeight = blockHeight, + ) + } + + internal fun parseUtxoList(json: String): List { + val node = JacksonMapper.mapper.readTree(json) + return node.map { utxo -> + val confirmed = utxo["status"]?.get("confirmed")?.asBoolean() == true + Utxo( + txid = utxo["txid"].asText(), + vout = utxo["vout"].asInt(), + valueSats = utxo["value"].asLong(), + confirmations = if (confirmed) 1 else 0, + ) + } + } + + /** mempool.space `/v1/fees/recommended`: `{ fastestFee, halfHourFee, hourFee, minimumFee }`. */ + internal fun parseRecommendedFees(json: String): FeeEstimates { + val node = JacksonMapper.mapper.readTree(json) + val fast = node["fastestFee"]?.asDouble() + val normal = node["halfHourFee"]?.asDouble() ?: fast + val slow = node["hourFee"]?.asDouble() ?: node["minimumFee"]?.asDouble() ?: normal + + if (fast == null) { + Log.w(logTag) { "fee response missing 'fastestFee': $json" } + return FeeEstimates(20.0, 10.0, 5.0) + } + return FeeEstimates( + fastSatPerVbyte = fast, + normalSatPerVbyte = normal ?: fast, + slowSatPerVbyte = slow ?: fast, + ) + } + + /** + * Standard Esplora `/fee-estimates`: a JSON object mapping a confirmation + * target (in blocks, as a string key) to the estimated sat/vB. We map the + * 2-block / 6-block / 144-block targets to the fast / normal / slow tiers. + */ + internal fun parseFeeEstimates(json: String): FeeEstimates { + val node = JacksonMapper.mapper.readTree(json) + + fun rate(vararg targets: String): Double? = targets.firstNotNullOfOrNull { node[it]?.asDouble() } + + val fast = rate("1", "2") + val normal = rate("4", "6", "3") + val slow = rate("144", "1008", "10") + + if (fast == null) { + Log.w(logTag) { "fee-estimates response missing block targets: $json" } + return FeeEstimates(20.0, 10.0, 5.0) + } + return FeeEstimates( + fastSatPerVbyte = fast, + normalSatPerVbyte = normal ?: fast, + slowSatPerVbyte = slow ?: normal ?: fast, + ) + } + + companion object { + const val MEMPOOL_API_URL = "https://mempool.space/api" + const val BLOCKSTREAM_API_URL = "https://blockstream.info/api" + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt new file mode 100644 index 0000000000..77b41dabb3 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.chain + +import okhttp3.OkHttpClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Tests the JSON-parsing layer of [EsploraBackend] against representative + * mempool.space / blockstream.info response bodies. The parse functions are + * pure (string in, model out) so no HTTP client is exercised. + */ +class EsploraBackendTest { + private val backend = EsploraBackend({ "https://example.test/api" }, OkHttpClient()) + + @Test + fun parsesConfirmedTransaction() { + val json = + """ + { + "txid": "7e3ab0f...not validated here", + "version": 2, + "locktime": 0, + "vin": [], + "vout": [ + { "scriptpubkey": "512053A1F6E454DF1AA2776A2814A721372D6258050DE330B3C6D10EE8F4E0DDA343", + "scriptpubkey_type": "v1_p2tr", "value": 25000 }, + { "scriptpubkey": "0014abababababababababababababababababababab", "value": 99000 } + ], + "status": { + "confirmed": true, + "block_height": 800000, + "block_hash": "00000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_time": 1690000000 + } + } + """.trimIndent() + + val tx = backend.parseTx(json) + assertEquals(2, tx.outputs.size) + assertEquals(25000L, tx.outputs[0].valueSats) + // scriptPubKey is normalized to lowercase for byte-comparison with our own. + assertEquals( + "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343", + tx.outputs[0].scriptPubKeyHex, + ) + assertEquals(0, tx.outputs[0].index) + assertEquals(1, tx.outputs[1].index) + assertEquals(99000L, tx.outputs[1].valueSats) + assertEquals(800000L, tx.blockHeight) + assertEquals(1, tx.confirmations, "a confirmed tx must report ≥1 confirmation") + } + + @Test + fun parsesUnconfirmedTransaction() { + val json = + """ + { "txid": "abc", "version": 2, "locktime": 0, "vin": [], + "vout": [ { "scriptpubkey": "5120ff", "value": 1000 } ], + "status": { "confirmed": false } } + """.trimIndent() + val tx = backend.parseTx(json) + assertEquals(0, tx.confirmations, "an unconfirmed tx must report 0 confirmations") + assertNull(tx.blockHeight) + } + + @Test + fun parsesUtxoList() { + val json = + """ + [ + { "txid": "1111111111111111111111111111111111111111111111111111111111111111", + "vout": 0, "value": 100000, + "status": { "confirmed": true, "block_height": 799000 } }, + { "txid": "2222222222222222222222222222222222222222222222222222222222222222", + "vout": 3, "value": 50000, + "status": { "confirmed": false } } + ] + """.trimIndent() + + val utxos = backend.parseUtxoList(json) + assertEquals(2, utxos.size) + assertEquals(100000L, utxos[0].valueSats) + assertEquals(0, utxos[0].vout) + assertEquals(1, utxos[0].confirmations) + assertEquals(50000L, utxos[1].valueSats) + assertEquals(3, utxos[1].vout) + assertEquals(0, utxos[1].confirmations, "unconfirmed UTXO must report 0 confirmations") + } + + @Test + fun parsesMempoolSpaceRecommendedFees() { + // mempool.space /v1/fees/recommended + val json = + """{ "fastestFee": 25, "halfHourFee": 15, "hourFee": 10, "economyFee": 5, "minimumFee": 1 }""" + val fees = backend.parseRecommendedFees(json) + assertEquals(25.0, fees.fastSatPerVbyte) + assertEquals(15.0, fees.normalSatPerVbyte) + assertEquals(10.0, fees.slowSatPerVbyte) + } + + @Test + fun parsesBlockstreamFeeEstimates() { + // blockstream.info / standard Esplora /fee-estimates: block target → sat/vB. + val json = + """ + { "1": 87.0, "2": 87.0, "3": 81.0, "4": 76.0, "6": 68.0, + "10": 50.0, "144": 1.027, "504": 1.0, "1008": 1.0 } + """.trimIndent() + val fees = backend.parseFeeEstimates(json) + assertEquals(87.0, fees.fastSatPerVbyte, "fast = 1-block target") + assertEquals(76.0, fees.normalSatPerVbyte, "normal = 4-block target") + assertEquals(1.027, fees.slowSatPerVbyte, "slow = 144-block target") + } + + @Test + fun feeParsersFallBackWhenSchemaUnexpected() { + val recommended = backend.parseRecommendedFees("""{ "unexpected": true }""") + assertEquals(20.0, recommended.fastSatPerVbyte) + val estimates = backend.parseFeeEstimates("""{ "unexpected": true }""") + assertEquals(20.0, estimates.fastSatPerVbyte) + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt index 725c8fedfc..efc5276dad 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt @@ -64,4 +64,14 @@ actual object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray = secp256k1.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + actual fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray { + val full = if (pubKey.size == 32) h02 + pubKey else pubKey + return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) + } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1.privKeyNegate(privKey) } diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt index eefd59b567..7a3473a476 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt @@ -63,4 +63,14 @@ actual object Secp256k1Instance { pubKey: ByteArray, privateKey: ByteArray, ): ByteArray = secp256k1Ref.pubKeyTweakMul(h02 + pubKey, privateKey).copyOfRange(1, 33) + + actual fun pubKeyTweakAdd( + pubKey: ByteArray, + tweak: ByteArray, + ): ByteArray { + val full = if (pubKey.size == 32) h02 + pubKey else pubKey + return secp256k1Ref.pubKeyCompress(secp256k1Ref.pubKeyTweakAdd(full, tweak)) + } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1Ref.privKeyNegate(privKey) }