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.
This commit is contained in:
Claude
2026-05-14 04:34:30 +00:00
parent d095dc790d
commit 79f1d43581
15 changed files with 1427 additions and 0 deletions
+170
View File
@@ -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<String> // default mempool.space
val onchainDefaultFeeTier: MutableStateFlow<FeeTier> // SLOW / NORMAL / FAST
```
Derived `Account.onchainBalance: StateFlow<Long?>` populated by
`OnchainWalletViewModel`.
## Send-from-note merge
Existing `ZapAmountChoicePopup` (`ReactionsRow.kt:1877-1977`) stays as the
Lightning fast path. Two minimal hooks:
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.
@@ -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))
}
}
@@ -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<BitcoinTxOutput>,
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,
)
@@ -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<Utxo>
/** 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)
@@ -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<Byte>(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)
}
}
@@ -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()
}
@@ -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
}
}
@@ -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,
}
}
}
@@ -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
}
@@ -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<IllegalArgumentException> {
SegwitAddress.encodeP2TR(tooShort)
}
}
@Test
fun rejectsInvalidWitnessVersion() {
assertFailsWith<IllegalArgumentException> {
SegwitAddress.encode("bc", 17, ByteArray(32))
}
}
@Test
fun rejectsV0WrongProgramLength() {
assertFailsWith<IllegalArgumentException> {
// v0 must be 20 or 32 bytes — 21 is invalid.
SegwitAddress.encode("bc", 0, ByteArray(21))
}
}
}
@@ -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")
}
}
@@ -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<Utxo> = 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<VerifiedOnchainZap.Confirmed>(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<VerifiedOnchainZap.Confirmed>(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<VerifiedOnchainZap.Rejected>(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<VerifiedOnchainZap.Rejected>(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<VerifiedOnchainZap.Rejected>(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<VerifiedOnchainZap.Pending>(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<VerifiedOnchainZap.Confirmed>(result)
assertEquals(5000L, result.verifiedSats, "verifier must report the on-chain amount, not the claimed amount")
assertTrue(result.verifiedSats < 1_000_000L)
}
}
@@ -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<Utxo> {
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<Utxo> {
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"
}
}
@@ -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))
}
}
@@ -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))
}
}