From 79f1d43581274a64751d4e9afd2fdd2c7c86f58d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 04:34:30 +0000 Subject: [PATCH] feat(quartz): NIP-BC onchain zap receive-side foundation Phase A.1 of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md): the verify/receive path of NIP-BC. Send-side (PSBT, builder, signPsbt) is the next phase. Adds: - nipBCOnchainZaps/taproot/SegwitAddress: BIP-173 / BIP-350 native segwit address encoder/decoder, witness v0..v16, on top of the existing Bech32 util. - nipBCOnchainZaps/taproot/TaprootAddress: BIP-341 key-path-only derivation from a Nostr pubkey. Computes the TapTweak tagged hash, applies the additive tweak via Secp256k1Instance.pubKeyTweakAdd, and encodes as bc1p... bech32m. - Secp256k1Instance: new pubKeyTweakAdd primitive wired through commonMain expect + jvm / android / native actuals. - nipBCOnchainZaps/chain: pluggable OnchainBackend interface plus BitcoinTx, BitcoinTxOutput, Utxo, FeeEstimates data models. - nipBCOnchainZaps/chain/EsploraBackend (jvmAndroid): OkHttp-based Esplora-compatible client (mempool.space / blockstream.info) covering /tx, /address/{addr}/utxo, /tx broadcast, /blocks/tip/height, and /v1/fees/recommended. - nipBCOnchainZaps/verify/OnchainZapVerifier: enforces every NIP-BC client rule -- reject self-zap, fetch tx, derive recipient Taproot scriptPubKey, sum only outputs paying the recipient (excluding change back to sender), cap claimed amount at verified amount, mark unconfirmed as Pending. Tests (commonTest, runs on jvmTest): - SegwitAddressTest: BIP-350 known-good vectors for v0/v16 + round-trip for v1 (P2TR). - TaprootAddressTest: BIP-341 wallet-test-vectors tagged hash + tweaked output key + scriptPubKey, plus address round-trip and prefix/length sanity. - OnchainZapVerifierTest: confirmed / pending / self-zap / missing tx / zero-verified-amount / multi-output-sum / amount-capping with an in-memory FakeBackend wired to real TaprootAddress-derived scripts. Plan: amethyst/plans/2026-05-14-onchain-zaps.md documents the full v1 scope, the merge into the existing NIP-47 wallet UI, the subscription kind-list edits, and the Note.zapsAmount fold-in approach for display. --- amethyst/plans/2026-05-14-onchain-zaps.md | 170 +++++++++++++ .../quartz/utils/Secp256k1Instance.android.kt | 8 + .../nipBCOnchainZaps/chain/BitcoinTx.kt | 82 +++++++ .../nipBCOnchainZaps/chain/OnchainBackend.kt | 53 ++++ .../nipBCOnchainZaps/taproot/SegwitAddress.kt | 131 ++++++++++ .../taproot/TaprootAddress.kt | 114 +++++++++ .../verify/OnchainZapVerifier.kt | 115 +++++++++ .../verify/VerifiedOnchainZap.kt | 82 +++++++ .../quartz/utils/Secp256k1Instance.kt | 15 ++ .../taproot/SegwitAddressTest.kt | 114 +++++++++ .../taproot/TaprootAddressTest.kt | 89 +++++++ .../verify/OnchainZapVerifierTest.kt | 210 ++++++++++++++++ .../nipBCOnchainZaps/chain/EsploraBackend.kt | 228 ++++++++++++++++++ .../quartz/utils/Secp256k1Instance.jvm.kt | 8 + .../quartz/utils/Secp256k1Instance.native.kt | 8 + 15 files changed, 1427 insertions(+) create mode 100644 amethyst/plans/2026-05-14-onchain-zaps.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddress.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifier.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/VerifiedOnchainZap.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/SegwitAddressTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddressTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt 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)) + } }