mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+64
@@ -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('/')
|
||||
}
|
||||
+1
-7
@@ -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(
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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 =
|
||||
|
||||
+2
@@ -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))
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+19
-4
@@ -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()
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
+349
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
+708
@@ -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()
|
||||
+17
-11
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -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,
|
||||
)
|
||||
+240
@@ -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)
|
||||
}
|
||||
+269
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -89,3 +89,7 @@ data class ZapEventDecryptionResult(
|
||||
data class DerivationResult(
|
||||
val newPrivKey: HexKey,
|
||||
) : IResult
|
||||
|
||||
data class SignPsbtResult(
|
||||
val signedPsbtHex: String,
|
||||
) : IResult
|
||||
|
||||
+59
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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()
|
||||
|
||||
|
||||
+5
@@ -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>
|
||||
}
|
||||
|
||||
+8
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+10
@@ -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)
|
||||
}
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
+5
@@ -101,4 +101,9 @@ class NostrSignerInternal(
|
||||
runWrapErrors {
|
||||
signerSync.deriveKey(nonce)
|
||||
}
|
||||
|
||||
override suspend fun signPsbt(psbtHex: String): String =
|
||||
runWrapErrors {
|
||||
signerSync.signPsbt(psbtHex)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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()
|
||||
|
||||
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+2
@@ -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>> {
|
||||
|
||||
+220
@@ -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")
|
||||
+82
@@ -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,
|
||||
)
|
||||
+111
@@ -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
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
+183
@@ -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)
|
||||
+260
@@ -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)
|
||||
}
|
||||
+62
@@ -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 }
|
||||
}
|
||||
+92
@@ -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
|
||||
}
|
||||
}
|
||||
+105
@@ -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)
|
||||
+167
@@ -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())
|
||||
}
|
||||
}
|
||||
+131
@@ -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)
|
||||
}
|
||||
}
|
||||
+149
@@ -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()
|
||||
}
|
||||
+132
@@ -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
|
||||
}
|
||||
}
|
||||
+82
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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())
|
||||
+35
@@ -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)
|
||||
+41
@@ -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())
|
||||
}
|
||||
}
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
+60
@@ -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
|
||||
}
|
||||
|
||||
+120
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -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")
|
||||
}
|
||||
}
|
||||
+150
@@ -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")
|
||||
}
|
||||
}
|
||||
+106
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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))
|
||||
}
|
||||
}
|
||||
+204
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+112
@@ -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)))
|
||||
}
|
||||
}
|
||||
+93
@@ -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")
|
||||
}
|
||||
+132
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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")
|
||||
}
|
||||
}
|
||||
+213
@@ -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)
|
||||
}
|
||||
}
|
||||
+107
@@ -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())
|
||||
}
|
||||
}
|
||||
+281
@@ -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"
|
||||
}
|
||||
}
|
||||
+143
@@ -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)
|
||||
}
|
||||
|
||||
+10
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user