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 000000000..38c13af28 --- /dev/null +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -0,0 +1,170 @@ +# 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. +- **Scope.** Full send + receive + display loop on Android. Desktop is out of + scope for v1. + +## 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, tx parser, verifier | In progress | +| **A.2** | Quartz foundation (send side): PSBT codec, builder, `signPsbt` on signer hierarchy | Pending | +| **B** | NIP-55 `sign_psbt` intent contract + `NostrSignerExternal.signPsbt` | Pending | +| **C** | Receive + display: `OnchainSection` in `WalletScreen`, Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 8 filter files | Pending | +| **D** | Send: dialog in zap menu, UTXO selection, build/sign/broadcast, publish | Pending | + +## 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/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt index 725c8fedf..f142e506b 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,12 @@ 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)) + } } 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 000000000..f492bfee8 --- /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/OnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt new file mode 100644 index 000000000..8610ea146 --- /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/taproot/SegwitAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt new file mode 100644 index 000000000..057508d36 --- /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 000000000..1f0cbffe1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt @@ -0,0 +1,114 @@ +/* + * 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"`. + */ +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) + } + + /** + * 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 000000000..2e8185fac --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt @@ -0,0 +1,115 @@ +/* + * 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) + + 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 = tx.confirmations, + blockHeight = tx.blockHeight, + blockHashHex = tx.blockHashHex, + ) + } else { + VerifiedOnchainZap.Pending( + txid = txid, + recipientPubKey = recipientPubKey, + verifiedSats = verifiedSats, + ) + } + } + + /** + * 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 000000000..1549321b4 --- /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/utils/Secp256k1Instance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index 49c435f26..d3d537804 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,19 @@ 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 } 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 000000000..54ef5de44 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt @@ -0,0 +1,114 @@ +/* + * 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 encodedTaprootIsLowercaseAndCorrectLength() { + // P2TR encoded address must be 62 chars, lowercase, with 'bc1p' prefix. + val outputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray() + val address = SegwitAddress.encodeP2TR(outputKey) + assertEquals(62, address.length) + assertEquals(address, address.lowercase()) + assertEquals("bc1p", address.substring(0, 4)) + } + + // ============================================================ + // 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 000000000..4999795ee --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt @@ -0,0 +1,89 @@ +/* + * 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()) + } + + @Test + fun derivedAddressRoundTripsToOutputKey() { + // The address itself is the bech32m encoding of (v1, output_key). We + // don't hard-code a specific bech32m string here because some + // historical BIP-341 wallet vectors used post-Taproot-Schnorr key + // adjustments — instead, verify the address decodes back to the + // expected output key. + 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 000000000..8537a9759 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt @@ -0,0 +1,210 @@ +/* + * 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?, + ) : 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 = 0L + + 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 = 3, + blockHashHex = "f".repeat(64), + blockHeight = 800_000L, + ) + val verifier = OnchainZapVerifier(FakeBackend(tx)) + val result = verifier.verify(mkEvent()) + + assertIs(result) + assertEquals(25000L, result.verifiedSats) + assertEquals(recipientHex, result.recipientPubKey) + assertEquals(800_000L, result.blockHeight) + } + + @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/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 000000000..fc7534d5b --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt @@ -0,0 +1,228 @@ +/* + * 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 tier suggestions (mempool.space variant) + * + * 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 url = "${baseUrl()}/v1/fees/recommended" + 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}", + ) + } + parseFees(response.body.string()) + } + } + + private 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, + ) + } + + private 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, + ) + } + } + + private fun parseFees(json: String): FeeEstimates { + val node = JacksonMapper.mapper.readTree(json) + // mempool.space exposes fastestFee / halfHourFee / hourFee / minimumFee. + 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" } + // Sensible default if the endpoint disagrees with our schema. + return FeeEstimates(20.0, 10.0, 5.0) + } + return FeeEstimates( + fastSatPerVbyte = fast, + normalSatPerVbyte = normal ?: fast, + slowSatPerVbyte = slow ?: 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/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt index 725c8fedf..f142e506b 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,12 @@ 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)) + } } 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 eefd59b56..bf61c5877 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,12 @@ 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)) + } }