Merge pull request #2944 from vitorpamplona/claude/nip88-polls-quartz-p5fBa

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