mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #3685 from vitorpamplona/claude/nip-2421-pr-review-6znvdd
Add NIP-XX BOLT12 zap support with validation and UI integration
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
# NWC BOLT12 payments (nostr-wallet-connect/nwc#2)
|
||||
|
||||
Status: **scoping** — no code yet. Depends on NIP-2421 (this branch) and the
|
||||
unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47).
|
||||
|
||||
## Ecosystem status (2026-07-25) — the wallet gap for real testing
|
||||
|
||||
A real NWC-321 (`pay`/`receive`) service exists —
|
||||
[benthecarman/nostr-wallet-connect-lnd](https://github.com/benthecarman/nostr-wallet-connect-lnd)
|
||||
— which confirms the protocol we built (Phase 0) is real and adopted. **But it is
|
||||
LND-backed:** its `pay` "selects and pays a BOLT11 `lightning` instruction from a
|
||||
BIP-321 URI" and "BOLT12 `lno` instructions are not supported by this LND backend."
|
||||
So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-2421
|
||||
zap loop. The blocker is the **node backend**, not the protocol: a CLN- or
|
||||
LDK-backed NWC-321 service (CLN has full BOLT12 and leads the payer-proof draft
|
||||
bolts#1346) would support `lno` + return `payer_proof`. Until one exists, the
|
||||
self-consistent interop harness (`cli/tests/bolt12/`, TODO) is the only way to
|
||||
exercise the full send → verify → count loop.
|
||||
|
||||
## What nwc#2 adds
|
||||
|
||||
Two generalized methods replace the bolt11-only `pay_invoice`:
|
||||
|
||||
- **`pay`** — params `{ payment: "bitcoin:?lno=lno1…", amount?, payer_note?, metadata? }`.
|
||||
`payment` is a BIP321 URI (so `bitcoin:?lno=` — exactly what
|
||||
`payViaBolt12Intent` already builds — or `lightning=`/on-chain). `amount`
|
||||
(msats) is required only when the instruction has no amount. Result:
|
||||
`{ transaction_id, state, instruction_type, amount, fees_paid, payment_hash,
|
||||
preimage, payer_proof: "lnp1…", txid, failure_reason, created_at, settled_at }`.
|
||||
- **`receive`** — params `{ amount?, description?, metadata? }` → result
|
||||
`{ bip321: "bitcoin:?lightning=lnbc…&lno=lno1…", transaction_id }`. Lets a
|
||||
wallet mint our own unified offer; ties to the kind-10058 editor (auto-fill
|
||||
offers) — later phase.
|
||||
|
||||
New errors: `UNSUPPORTED_PAYMENT_INSTRUCTION`, `UNSUPPORTED_NETWORK`. Wallets
|
||||
advertise support in their kind-13194 info event + `get_info.methods`.
|
||||
|
||||
## Two capabilities this unlocks
|
||||
|
||||
**A. In-app payment of an offer** — the "extra payment instruction" half. Today
|
||||
`Bolt12PayButton` fires a `bitcoin:?lno=` intent to an external wallet. With `pay`
|
||||
we can settle the offer over the user's already-configured NWC connection, no app
|
||||
switch. No Nostr receipt.
|
||||
|
||||
**B. Sending real BOLT12 zaps** — the half deferred since the start of the branch.
|
||||
The `pay` result carries **`payer_proof: "lnp1…"`**, which is precisely the input
|
||||
`Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey)` needs. So NWC `pay`
|
||||
is the payment rail that produces the proof for a kind-9736 zap. This is the
|
||||
strategic reason to do this now.
|
||||
|
||||
## Existing NIP-47 infra we reuse (from the code map)
|
||||
|
||||
The send/await/correlate plumbing is **method-agnostic** — it keys on request id
|
||||
and dispatches the decrypted `Response`, so a new method needs no changes there:
|
||||
|
||||
- `NwcSignerState.sendNwcRequestToWallet(uri, request, onResponse)`
|
||||
(`amethyst/…/model/nip47WalletConnect/NwcSignerState.kt`) — builds the 23194,
|
||||
synchronous REQ-before-EVENT via `NWCPaymentFilterAssembler`, 60 s timeout,
|
||||
decrypt-on-arrival.
|
||||
- `NwcPaymentTracker` (`commons/…/service/nwc/`) — request↔response match with the
|
||||
author-spoof gate.
|
||||
- `LocalCache.consume(LnZapPaymentResponseEvent)` — routes 23195 back to the callback.
|
||||
- Wallet storage: `AccountSettings.nwcWallets` + `defaultPaymentSourceId`;
|
||||
`PaymentSourceResolver`.
|
||||
- Pay rail entry: `ZapPaymentHandler.zap()` → `payViaNWC()` (the `PaymentSource.Nwc`
|
||||
branch).
|
||||
|
||||
Capability discovery exists (`NwcInfoEvent.supportsMethod`, `GetInfoResult.methods`)
|
||||
but is **not** wired into the pay path — we'd add the gate ourselves.
|
||||
|
||||
## Work breakdown
|
||||
|
||||
### Phase 0 — quartz protocol (`nip47WalletConnect`)
|
||||
- `NwcMethod.PAY = "pay"`, `NwcMethod.RECEIVE = "receive"`.
|
||||
- `rpc/Request.kt`: `PayMethod` + `PayParams(payment, amount, payerNote, metadata)`
|
||||
with `create(…)`; `ReceiveMethod`/`ReceiveParams`.
|
||||
- `rpc/Response.kt`: `PaySuccessResponse` (all result fields above, `payerProof`
|
||||
nullable) and `ReceiveSuccessResponse(bip321, transactionId)`.
|
||||
- `rpc/NwcErrorCode.kt`: add the two new codes.
|
||||
- Serializer branches in **both** `Nip47RequestKSerializer` / `Nip47ResponseKSerializer`
|
||||
**and** the jvmAndroid Jackson variants.
|
||||
- Optional `Nip47Client.pay(...)` builder.
|
||||
- Tests: request/response round-trip; a real captured `pay` result fixture.
|
||||
- No new third-party deps (pure protocol) → licensing clean.
|
||||
|
||||
### Phase 1 — in-app offer payment (capability A)
|
||||
- `Account.sendNwcPayRequest(payment, amount, payerNote, onResponse)` wrapper
|
||||
(mirrors `sendZapPaymentRequestFor`, reuses `NwcSignerState`).
|
||||
- In `Bolt12PayButton`'s dialog: when an NWC wallet is configured, add a "Pay with
|
||||
connected wallet" action (amount-entry sheet, since offers are often amountless)
|
||||
→ `pay` with `payment=bitcoin:?lno=<offer>`. Keep the external-intent path as
|
||||
fallback.
|
||||
- Surface `UNSUPPORTED_PAYMENT_INSTRUCTION`/`PAYMENT_FAILED`.
|
||||
|
||||
### Phase 2 — send BOLT12 zaps (capability B)
|
||||
- New `Bolt12ZapSender` (or extend `ZapPaymentHandler`): when the recipient has a
|
||||
kind-10058 offer and the wallet supports `pay`, offer a BOLT12 zap.
|
||||
1. Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content).
|
||||
2. `pay` with `payment=bitcoin:?lno=<offer>`, `amount`,
|
||||
`payer_note="nostr:nipXX:<intent-id>"`.
|
||||
3. On success with a `payer_proof`, `Bolt12ZapEvent.build(intent, payerProof,
|
||||
payerPubKey = own | null for anon)`, sign, publish to the recipient's inbox
|
||||
relays.
|
||||
4. Our own `LocalCache` consumes the 9736 and counts it.
|
||||
- Anonymous vs attributed (`P` tag) toggle, mirroring lightning-zap anonymity.
|
||||
|
||||
### Phase 3 — gating + receive (later)
|
||||
- Read `NwcInfoEvent.supportsMethod("pay")` / `get_info.methods` to show the NWC
|
||||
BOLT12 options only when supported; else fall back to the intent.
|
||||
- `receive` to mint the user's own offer and pre-fill the kind-10058 editor.
|
||||
|
||||
## Risks / open questions (decide before Phase 2)
|
||||
|
||||
1. **`payer_note` → `invreq_payer_note` is NOT guaranteed by nwc#2.** The spec only
|
||||
says "if `payer_note` is not empty, the selected instruction MUST support
|
||||
payer-provided messages" — it never states the note lands in the BOLT12
|
||||
`invreq_payer_note`. NIP-2421 binds the zap to the intent through exactly that
|
||||
field (`invreq_payer_note == nostr:nipXX:<intent-id>`). If a wallet routes
|
||||
`payer_note` elsewhere, the returned `payer_proof` fails our validator and the
|
||||
9736 is worthless. **Phase 2 feasibility hinges on this** — needs confirmation
|
||||
in the nwc thread / a reference wallet, or a follow-up to nwc#2 to nail it down.
|
||||
2. **`payer_proof` is best-effort** (`"optional if unavailable"`, no wallet mandate).
|
||||
A wallet may settle the offer and return no proof → payment succeeds but we
|
||||
can't publish a zap. Phase 1 is unaffected; Phase 2 must degrade gracefully
|
||||
("paid, but no zap receipt available").
|
||||
3. ~~**Our verifier can't check compressed proofs yet.**~~ **Resolved.** The
|
||||
compressed-proof merkle reconstruction shipped and is validated byte-for-byte
|
||||
against the lightning/bolts#1346 conformance vectors (see
|
||||
`quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet
|
||||
(selective-disclosure) proofs now reconstruct and verify, so a bound zap counts
|
||||
locally as `cryptoVerified = true`.
|
||||
4. **Maturity.** Both nwc#2 and NIP-2421 are unmerged; few/no wallets implement
|
||||
`pay` today. Gate hard on capability (Phase 3) and keep the intent fallback.
|
||||
|
||||
## Resolution of risks #1/#2 (nwc#2 maintainer, 2026-07-24)
|
||||
|
||||
Asked on the nwc#2 thread. Maintainer confirmed:
|
||||
|
||||
- **`payer_note` → `invreq_payer_note`**: "that is the intention" for BOLT12 (the
|
||||
field doubles as a general memo). So our zap binding
|
||||
(`invreq_payer_note == nostr:nipXX:<intent-id>`) is the intended target.
|
||||
- **`payer_proof`**: "should be returned for successful bolt12 payments"; the
|
||||
"optional if unavailable" wording only covers non-BOLT12 instruction types.
|
||||
|
||||
Both are informal maintainer intent, not yet spec text, so a non-conforming wallet
|
||||
is still possible. That's fine: after a `pay` returns, we run the returned
|
||||
`payer_proof` through `Bolt12ZapValidator` **before** publishing a kind:9736. A
|
||||
wallet that misroutes the note fails the binding check and we publish nothing —
|
||||
Phase 2 fails safe, never emitting an invalid receipt.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Phase 0 (done) and Phase 1 (done) shipped. **Phase 2 is now unblocked.** Build it
|
||||
with the validate-before-publish gate above; degrade to "paid, no zap receipt"
|
||||
when the proof is absent or fails validation.
|
||||
|
||||
## Phase 2 as shipped (full zap-button integration, BOLT12-first)
|
||||
|
||||
Chosen: full integration into the zap pipeline, preferring BOLT12 when the
|
||||
recipient offers it.
|
||||
|
||||
- `Account.sendBolt12Zap(...)` — signs a 9737 intent (ephemeral key for anonymous),
|
||||
pays over NWC with `payer_note = nostr:nipXX:<intent-id>`, and only on a proof
|
||||
that passes `Bolt12ZapValidator` self-consumes + publishes the 9736 via
|
||||
`computeRelayListToBroadcast`. No proof / invalid proof → "paid, no receipt".
|
||||
- `ZapPaymentHandler.zap()` resolves each recipient's kind:10058 offer and
|
||||
**partitions** recipients into a BOLT12 lane (offer present AND an NWC wallet is
|
||||
configured) and the existing lightning lane. Split weights are summed across both
|
||||
lanes (`totalWeight` threaded into `signAllZapRequests`/`assembleAllInvoices`) so
|
||||
mixed splits stay proportional. Anonymous/public follows the account zap type.
|
||||
- `Bolt12ZapBuilderTest` proves the send-side assembly round-trips to a
|
||||
validator-accepted, crypto-verified zap (attributed and anonymous).
|
||||
|
||||
## Phase 3 as shipped (capability gate + lightning fallback)
|
||||
|
||||
- `Account.defaultWalletSupportsBolt12Pay()` reads the default wallet's cached
|
||||
kind:13194 info event via `NwcSignerState.infoCache` (added on `main` for
|
||||
encryption negotiation; it already refreshes on wallet change) and checks
|
||||
`supportsMethod("pay")`. A missing/unfetched info event reads as false. (An earlier
|
||||
cut fetched `get_info.methods` into its own state; unified onto the 13194 cache on
|
||||
the `main` merge to avoid a redundant fetch — the info event is the canonical
|
||||
capability advertisement.)
|
||||
- The zap path's
|
||||
`canBolt12` now requires it, so a recipient with an offer but a wallet that can't
|
||||
`pay` **falls back to lightning** via the existing partition instead of erroring.
|
||||
- `AccountViewModel.canPayBolt12ViaNwc()` gates the profile "pay with wallet" action
|
||||
on the same signal.
|
||||
|
||||
Residual (acceptable): capabilities are empty for the first moment after launch
|
||||
until `get_info` returns, so a very early zap can miss the BOLT12 rail and use
|
||||
lightning; and a wallet that doesn't populate `get_info.methods` never gets the
|
||||
BOLT12 rail even if it supports `pay`. Both fail safe toward lightning.
|
||||
|
||||
Known limitations (follow-ons, not blockers):
|
||||
|
||||
- ~~**Compressed proofs aren't locally counted.**~~ **Resolved.** Compressed
|
||||
(selective-disclosure) proofs now reconstruct and verify, so `updateZapTotal`
|
||||
counts a bound zap locally. Validated against the lightning/bolts#1346
|
||||
conformance vectors — see `quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`.
|
||||
@@ -70,6 +70,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -211,6 +212,7 @@ private object PrefKeys {
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
|
||||
const val LATEST_BOLT12_OFFERS = "latestBolt12Offers"
|
||||
const val LATEST_CASHU_WALLET = "latestCashuWallet"
|
||||
const val LATEST_NUTZAP_INFO = "latestNutzapInfo"
|
||||
}
|
||||
@@ -588,6 +590,7 @@ object LocalPreferences {
|
||||
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
|
||||
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
|
||||
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
|
||||
putOrRemove(PrefKeys.LATEST_BOLT12_OFFERS, settings.backupBolt12Offers)
|
||||
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
|
||||
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
|
||||
|
||||
@@ -777,6 +780,7 @@ object LocalPreferences {
|
||||
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
|
||||
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
|
||||
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
|
||||
val latestBolt12OffersStr = getString(PrefKeys.LATEST_BOLT12_OFFERS, null)
|
||||
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
|
||||
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
|
||||
val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null)
|
||||
@@ -840,6 +844,7 @@ object LocalPreferences {
|
||||
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
|
||||
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
|
||||
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
|
||||
val latestBolt12Offers = async { parseEventOrNull<Bolt12OfferListEvent>(latestBolt12OffersStr) }
|
||||
val latestCashuWallet =
|
||||
async {
|
||||
parseEventOrNull<com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent>(latestCashuWalletStr)
|
||||
@@ -895,6 +900,7 @@ object LocalPreferences {
|
||||
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
|
||||
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
|
||||
val latestPaymentTargetsResolved = latestPaymentTargets.await()
|
||||
val latestBolt12OffersResolved = latestBolt12Offers.await()
|
||||
val latestCashuWalletResolved = latestCashuWallet.await()
|
||||
val latestNutzapInfoResolved = latestNutzapInfo.await()
|
||||
|
||||
@@ -997,6 +1003,7 @@ object LocalPreferences {
|
||||
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
|
||||
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
|
||||
backupBolt12Offers = latestBolt12OffersResolved,
|
||||
backupCashuWallet = latestCashuWalletResolved,
|
||||
backupNutzapInfo = latestNutzapInfoResolved,
|
||||
callsEnabled = MutableStateFlow(callsEnabled),
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
|
||||
@@ -78,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
|
||||
@@ -294,6 +296,10 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
@@ -365,6 +371,8 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -780,6 +788,8 @@ class Account(
|
||||
|
||||
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
|
||||
|
||||
val bolt12OfferList = Bolt12OfferListState(signer, cache, scope, settings)
|
||||
|
||||
val feedDecryptionCaches =
|
||||
FeedDecryptionCaches(
|
||||
peopleListCache = peopleListDecryptionCache,
|
||||
@@ -1433,6 +1443,106 @@ class Account(
|
||||
client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a
|
||||
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
|
||||
* info event (its capability advertisement), which [NwcSignerState] already refreshes
|
||||
* on wallet change. A missing/unfetched info event reads as false, so the zap path
|
||||
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
|
||||
*/
|
||||
fun defaultWalletSupportsBolt12Pay(): Boolean {
|
||||
val uri = nip47SignerState.defaultWalletUri.value ?: return false
|
||||
return nip47SignerState.infoCache?.current(uri)?.supportsMethod(NwcMethod.PAY) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a NIP-XX BOLT12 zap to [recipientPubKey] over the default NWC wallet.
|
||||
*
|
||||
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
|
||||
* intent-bound `payer_note`, then — only if the wallet returns a payer proof that
|
||||
* validates — builds, self-consumes, and publishes the kind 9736 zap. Validation
|
||||
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
|
||||
* fails the binding check, so no invalid receipt is ever published (the payment
|
||||
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
|
||||
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
|
||||
* external-wallet or LNURL fallback because only NWC returns the proof.
|
||||
*/
|
||||
suspend fun sendBolt12Zap(
|
||||
zappedEvent: Event?,
|
||||
recipientPubKey: HexKey,
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
|
||||
onError: (Int, String?) -> Unit,
|
||||
onProcessed: () -> Unit,
|
||||
) {
|
||||
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
|
||||
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
|
||||
if (zapType == LnZapEvent.ZapType.NONZAP) {
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
scope.launch {
|
||||
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
onProcessed()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
|
||||
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
|
||||
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
|
||||
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else signer
|
||||
|
||||
val intent =
|
||||
if (zappedEvent == null) {
|
||||
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
|
||||
} else {
|
||||
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
|
||||
}
|
||||
|
||||
val payerNote = Bolt12ZapBuilder.payerNote(intent)
|
||||
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
|
||||
scope.launch {
|
||||
// try/finally so a failure while assembling/publishing the receipt (e.g. a
|
||||
// remote signer error) still steps progress and surfaces an error, instead
|
||||
// of vanishing as an uncaught coroutine exception. The payment already
|
||||
// settled at this point, so such a failure means "paid, no receipt".
|
||||
try {
|
||||
when (response) {
|
||||
is PaySuccessResponse -> {
|
||||
val proof = response.result?.payer_proof
|
||||
if (proof.isNullOrBlank()) {
|
||||
onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
} else {
|
||||
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
|
||||
if (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
|
||||
cache.justConsumeMyOwnEvent(zap)
|
||||
client.publish(zap, computeRelayListToBroadcast(zap))
|
||||
} else {
|
||||
onError(R.string.bolt12_zap_invalid_receipt, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
|
||||
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
|
||||
onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
} finally {
|
||||
onProcessed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createZapRequestFor(
|
||||
user: User,
|
||||
message: String = "",
|
||||
@@ -5687,6 +5797,8 @@ class Account(
|
||||
|
||||
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
|
||||
|
||||
suspend fun saveBolt12Offers(offers: List<String>) = sendMyPublicAndPrivateOutbox(bolt12OfferList.saveOffers(offers))
|
||||
|
||||
fun markAsRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
|
||||
@@ -75,6 +75,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -322,6 +323,7 @@ class AccountSettings(
|
||||
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
|
||||
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
|
||||
var backupBolt12Offers: Bolt12OfferListEvent? = null,
|
||||
var callTurnServers: List<CallTurnServer> = emptyList(),
|
||||
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
|
||||
var callMaxBitrateBps: Int = 1_500_000,
|
||||
@@ -1260,6 +1262,16 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
|
||||
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupBolt12Offers?.id != newBolt12Offers.id) {
|
||||
backupBolt12Offers = newBolt12Offers
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
|
||||
|
||||
@@ -400,6 +400,10 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
@@ -467,6 +471,13 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
*/
|
||||
val onchainZapResolver = OnchainZapResolver(this)
|
||||
|
||||
/**
|
||||
* NIP-XX BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification
|
||||
* is synchronous (a self-contained `lnp` payer proof), so `consume(Bolt12ZapEvent)`
|
||||
* validates inline and needs no async resolver.
|
||||
*/
|
||||
val bolt12ZapValidator = Bolt12ZapValidator()
|
||||
|
||||
/**
|
||||
* Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to
|
||||
* validate NIP-57 Appendix F. `null` skips the receipt-signer check (the
|
||||
@@ -1281,6 +1292,17 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
is Bolt12ZapEvent -> {
|
||||
// NIP-XX BOLT12 zaps target an event (e), an addressable event (a),
|
||||
// or just the recipient profile (p) — same shape as onchain zaps.
|
||||
buildList {
|
||||
event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } }
|
||||
event.zappedAddress()?.let { coord ->
|
||||
Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NutzapEvent -> {
|
||||
// The zapped event is carried in the kind:9321's `e` tags
|
||||
// (and optionally an `a` tag for addressables). Whichever
|
||||
@@ -2664,6 +2686,43 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return !alreadyLoaded
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: Bolt12ZapEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed — still route it into any live-activity channel it references.
|
||||
if (note.event != null) {
|
||||
attachZapToLiveActivityChannel(event, note, relay)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(wasVerified || justVerify(event))) return false
|
||||
|
||||
// NIP-XX validation is fully synchronous: zap-event structure, the embedded
|
||||
// kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed
|
||||
// validation drops the zap entirely — it never contributes to a zap total.
|
||||
// The outer event signature was already verified above (wasVerified/justVerify),
|
||||
// so skip the redundant re-check inside the validator.
|
||||
val validation = bolt12ZapValidator.validate(event, verifyEventSignature = false)
|
||||
if (validation !is Bolt12ZapValidation.Valid) {
|
||||
Log.w("ZP") { "dropping bolt12 zap ${event.id}: ${(validation as Bolt12ZapValidation.Invalid).reason}" }
|
||||
return false
|
||||
}
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = computeReplyTo(event)
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
repliesTo.forEach {
|
||||
it.addBolt12Zap(note, validation.paymentHashHex, validation.amountMillisats, validation.proofCryptoVerified)
|
||||
}
|
||||
attachZapToLiveActivityChannel(event, note, relay)
|
||||
refreshNewNoteObservers(note)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a NIP-61 nutzap (kind 9321). Resolves the e-tagged target
|
||||
* note(s), parses the proof amounts once, and attaches a `NutzapEntry`
|
||||
@@ -2719,6 +2778,23 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachZapToLiveActivityChannel(
|
||||
event: Bolt12ZapEvent,
|
||||
note: Note,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
// Only surface zaps whose recipient is the live activity host.
|
||||
val host = event.recipient() ?: return
|
||||
event.tags
|
||||
.asSequence()
|
||||
.mapNotNull(ATag::parseAddress)
|
||||
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex == host }
|
||||
.distinct()
|
||||
.forEach { address ->
|
||||
getOrCreateLiveChannel(address).addNote(note, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LnZapRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -4157,6 +4233,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is Bolt12OfferListEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is ClassifiedsEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
@@ -4577,6 +4657,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is Bolt12ZapEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is NIP90StatusEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.bolt12Offers
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The logged-in user's NIP-XX BOLT12 offer list (kind 10058) as live account state,
|
||||
* mirroring [com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState].
|
||||
* Exposes the current offers as a [flow], persists them across restarts (via
|
||||
* [AccountSettings]), and publishes updates with [saveOffers].
|
||||
*/
|
||||
class Bolt12OfferListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
val bolt12OfferListNote = cache.getOrCreateAddressableNote(getBolt12OfferListAddress())
|
||||
|
||||
fun getBolt12OfferListFlow(): StateFlow<NoteState> = bolt12OfferListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getBolt12OfferListAddress() = Bolt12OfferListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getBolt12OfferListEvent(): Bolt12OfferListEvent? = bolt12OfferListNote.event as? Bolt12OfferListEvent
|
||||
|
||||
/** The user's currently-published canonical raw BOLT12 offers. */
|
||||
val flow: StateFlow<List<String>> =
|
||||
getBolt12OfferListFlow()
|
||||
.map { (it.note.event as? Bolt12OfferListEvent)?.offers() ?: emptyList() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
suspend fun saveOffers(offers: List<String>): Bolt12OfferListEvent {
|
||||
val existing = getBolt12OfferListEvent()
|
||||
return if (existing != null && existing.tags.isNotEmpty()) {
|
||||
Bolt12OfferListEvent.updateOffers(existing, offers, signer)
|
||||
} else {
|
||||
Bolt12OfferListEvent.create(offers, signer)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupBolt12Offers?.let {
|
||||
Log.d("AccountRegisterObservers") { "Loading saved BOLT12 offer list ${it.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
getBolt12OfferListFlow().collect {
|
||||
(it.note.event as? Bolt12OfferListEvent)?.let { offerListEvent ->
|
||||
settings.updateBolt12Offers(offerListEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ class ZapPaymentHandler(
|
||||
val weight: Double = 1.0,
|
||||
val relay: NormalizedRelayUrl? = null,
|
||||
val user: User? = null,
|
||||
val bolt12Offer: String? = null,
|
||||
)
|
||||
|
||||
data class MyZapSplitSetup(
|
||||
@@ -76,6 +77,13 @@ class ZapPaymentHandler(
|
||||
val user: User? = null,
|
||||
)
|
||||
|
||||
/** A recipient routed over BOLT12 (NIP-XX): they publish a kind:10058 [offer] and we hold an NWC wallet. */
|
||||
data class Bolt12Recipient(
|
||||
val user: User,
|
||||
val offer: String,
|
||||
val weight: Double = 1.0,
|
||||
)
|
||||
|
||||
suspend fun zap(
|
||||
note: Note,
|
||||
amountMilliSats: Long,
|
||||
@@ -111,6 +119,7 @@ class ZapPaymentHandler(
|
||||
weight = setup.weight,
|
||||
relay = setup.relay,
|
||||
user = user,
|
||||
bolt12Offer = user?.bolt12Offers()?.firstOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +130,7 @@ class ZapPaymentHandler(
|
||||
noteEvent.hosts().map {
|
||||
val user = LocalCache.checkGetOrCreateUser(it.pubKey)
|
||||
val lnAddress = user?.lnAddress()
|
||||
UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user)
|
||||
UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user, bolt12Offer = user?.bolt12Offers()?.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,23 +139,62 @@ class ZapPaymentHandler(
|
||||
if (appLud16 != null) {
|
||||
listOf(UnverifiedZapSplitSetup(appLud16))
|
||||
} else {
|
||||
val lud16 =
|
||||
note.author?.lnAddress()
|
||||
listOf(UnverifiedZapSplitSetup(lud16))
|
||||
val author = note.author
|
||||
listOf(UnverifiedZapSplitSetup(author?.lnAddress(), user = author, bolt12Offer = author?.bolt12Offers()?.firstOrNull()))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val author = note.author
|
||||
listOf(
|
||||
UnverifiedZapSplitSetup(
|
||||
note.author?.lnAddress(),
|
||||
lnAddress = author?.lnAddress(),
|
||||
user = author,
|
||||
bolt12Offer = author?.bolt12Offers()?.firstOrNull(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// BOLT12-first: a recipient who publishes a kind:10058 offer is zapped over
|
||||
// BOLT12 when our default NWC wallet advertises the nwc#2 `pay` method (needed for
|
||||
// the payer proof). Otherwise — no wallet, or a wallet without `pay` — the recipient
|
||||
// stays on lightning, so an unsupported wallet degrades gracefully instead of erroring.
|
||||
val canBolt12 =
|
||||
account.settings.nwcWallets.value
|
||||
.isNotEmpty() &&
|
||||
account.defaultWalletSupportsBolt12Pay()
|
||||
|
||||
val bolt12Recipients =
|
||||
unverifiedZapsToSend.mapNotNull {
|
||||
val user = it.user
|
||||
if (canBolt12 && it.bolt12Offer != null && user != null) {
|
||||
Bolt12Recipient(user, it.bolt12Offer, it.weight)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val bolt12Users = bolt12Recipients.mapTo(HashSet()) { it.user }
|
||||
|
||||
val zapsToSend =
|
||||
unverifiedZapsToSend.mapNotNull {
|
||||
// Never lightning-zap a recipient already routed over BOLT12.
|
||||
if (it.user != null && it.user in bolt12Users) {
|
||||
null
|
||||
} else if (it.lnAddress != null) {
|
||||
MyZapSplitSetup(it.lnAddress, it.weight, it.relay, it.user)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (showErrorIfNoLnAddress) {
|
||||
val errors = unverifiedZapsToSend.filter { it.lnAddress.isNullOrBlank() }
|
||||
// Only a recipient with neither a BOLT12 route nor an lnAddress is unpayable.
|
||||
val errors =
|
||||
unverifiedZapsToSend.filter {
|
||||
val routedBolt12 = canBolt12 && it.bolt12Offer != null && it.user != null
|
||||
!routedBolt12 && it.lnAddress.isNullOrBlank()
|
||||
}
|
||||
errors.forEach {
|
||||
val message =
|
||||
if (it.user != null) {
|
||||
@@ -167,71 +215,77 @@ class ZapPaymentHandler(
|
||||
}
|
||||
}
|
||||
|
||||
val zapsToSend =
|
||||
unverifiedZapsToSend.mapNotNull {
|
||||
if (it.lnAddress != null) {
|
||||
MyZapSplitSetup(
|
||||
it.lnAddress,
|
||||
it.weight,
|
||||
it.relay,
|
||||
it.user,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
// Weight is shared across both lanes so splits stay proportional regardless of rail.
|
||||
val totalWeight = bolt12Recipients.sumOf { it.weight } + zapsToSend.sumOf { it.weight }
|
||||
if (totalWeight <= 0.0) {
|
||||
onProgress(0.00f)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
onProgress(0.02f)
|
||||
|
||||
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats)
|
||||
// --- Lightning lane -----------------------------------------------------------
|
||||
if (zapsToSend.isNotEmpty()) {
|
||||
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats, totalWeight)
|
||||
|
||||
if (splitZapRequests.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@withContext
|
||||
} else {
|
||||
onProgress(0.05f)
|
||||
if (splitZapRequests.isNotEmpty()) {
|
||||
onProgress(0.05f)
|
||||
|
||||
val payables =
|
||||
assembleAllInvoices(
|
||||
requests = splitZapRequests,
|
||||
totalAmountMilliSats = amountMilliSats,
|
||||
message = message,
|
||||
okHttpClient = okHttpClient,
|
||||
onError = onError,
|
||||
onProgress = { onProgress(it * 0.7f + 0.05f) },
|
||||
context = context,
|
||||
totalWeight = totalWeight,
|
||||
)
|
||||
|
||||
if (payables.isNotEmpty()) {
|
||||
onProgress(0.75f)
|
||||
|
||||
// Route through the user's selected default payment source. A CLINK debit takes
|
||||
// precedence over NWC when it is the chosen default; NWC-only users are unaffected
|
||||
// (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app.
|
||||
when (val source = account.settings.defaultPaymentSource()) {
|
||||
is PaymentSource.ClinkDebit -> {
|
||||
payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = {
|
||||
onProgress(it * 0.25f + 0.75f)
|
||||
}, context)
|
||||
}
|
||||
|
||||
is PaymentSource.Nwc -> {
|
||||
payViaNWC(payables, note, onError = onError, onProgress = {
|
||||
onProgress(it * 0.25f + 0.75f) // keeps within range.
|
||||
}, context)
|
||||
}
|
||||
|
||||
null -> {
|
||||
onPayViaIntent(payables.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val payables =
|
||||
assembleAllInvoices(
|
||||
requests = splitZapRequests,
|
||||
// --- BOLT12 lane --------------------------------------------------------------
|
||||
if (bolt12Recipients.isNotEmpty()) {
|
||||
payViaBolt12(
|
||||
recipients = bolt12Recipients,
|
||||
note = note,
|
||||
totalAmountMilliSats = amountMilliSats,
|
||||
totalWeight = totalWeight,
|
||||
message = message,
|
||||
okHttpClient = okHttpClient,
|
||||
zapType = zapType,
|
||||
onError = onError,
|
||||
onProgress = { onProgress(it * 0.7f + 0.05f) },
|
||||
onProgress = { onProgress(it * 0.25f + 0.75f) },
|
||||
context = context,
|
||||
)
|
||||
|
||||
if (payables.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@withContext
|
||||
} else {
|
||||
onProgress(0.75f)
|
||||
}
|
||||
|
||||
// Route through the user's selected default payment source. A CLINK debit takes
|
||||
// precedence over NWC when it is the chosen default; NWC-only users are unaffected
|
||||
// (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app.
|
||||
when (val source = account.settings.defaultPaymentSource()) {
|
||||
is PaymentSource.ClinkDebit -> {
|
||||
payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = {
|
||||
onProgress(it * 0.25f + 0.75f)
|
||||
}, context)
|
||||
}
|
||||
|
||||
is PaymentSource.Nwc -> {
|
||||
payViaNWC(payables, note, onError = onError, onProgress = {
|
||||
onProgress(it * 0.25f + 0.75f) // keeps within range.
|
||||
}, context)
|
||||
// onProgress(1f)
|
||||
}
|
||||
|
||||
null -> {
|
||||
onPayViaIntent(payables.toImmutableList())
|
||||
onProgress(0f)
|
||||
}
|
||||
}
|
||||
onProgress(1f)
|
||||
}
|
||||
|
||||
private fun calculateZapValue(
|
||||
@@ -256,9 +310,10 @@ class ZapPaymentHandler(
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapsToSend: List<MyZapSplitSetup>,
|
||||
totalAmountMilliSats: Long,
|
||||
): List<ZapRequestReady> {
|
||||
val totalWeight = zapsToSend.sumOf { it.weight }
|
||||
return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
|
||||
// Shared across the lightning + BOLT12 lanes so a mixed split stays proportional.
|
||||
totalWeight: Double = zapsToSend.sumOf { it.weight },
|
||||
): List<ZapRequestReady> =
|
||||
mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
|
||||
// makes sure the author receives the zap event
|
||||
val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
|
||||
|
||||
@@ -291,7 +346,6 @@ class ZapPaymentHandler(
|
||||
|
||||
ZapRequestReady(next, zapRequest)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assembleAllInvoices(
|
||||
requests: List<ZapRequestReady>,
|
||||
@@ -301,9 +355,10 @@ class ZapPaymentHandler(
|
||||
onError: (String, String, User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
// Shared across the lightning + BOLT12 lanes so a mixed split stays proportional.
|
||||
totalWeight: Double = requests.sumOf { it.inputSetup.weight },
|
||||
): List<Payable> {
|
||||
var progressAllPayments = 0.00f
|
||||
val totalWeight = requests.sumOf { it.inputSetup.weight }
|
||||
|
||||
return mapNotNullAsync(requests) { splitZapRequestPair: ZapRequestReady ->
|
||||
try {
|
||||
@@ -386,6 +441,47 @@ class ZapPaymentHandler(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BOLT12 zap rail (NIP-XX). For each recipient that publishes a kind:10058 offer,
|
||||
* signs a 9737 intent, pays the offer over NWC with the intent-bound `payer_note`,
|
||||
* and (if the returned proof validates) publishes a 9736 zap — see
|
||||
* [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic
|
||||
* and settlement/errors surface later through the async NWC response.
|
||||
*/
|
||||
suspend fun payViaBolt12(
|
||||
recipients: List<Bolt12Recipient>,
|
||||
note: Note,
|
||||
totalAmountMilliSats: Long,
|
||||
totalWeight: Double,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
onError: (String, String, User?) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
val progress = PaymentProgress(recipients.size, onProgress)
|
||||
|
||||
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
|
||||
account.sendBolt12Zap(
|
||||
zappedEvent = note.event,
|
||||
recipientPubKey = recipient.user.pubkeyHex,
|
||||
offer = recipient.offer,
|
||||
amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight),
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
onError = { msgRes, detail ->
|
||||
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
|
||||
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
|
||||
},
|
||||
onProcessed = { progress.step() },
|
||||
)
|
||||
|
||||
progress.step()
|
||||
|
||||
recipient
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread-safe progress accumulator for the parallel pay rails. Each payable advances in two
|
||||
* half-steps (request dispatched, then response/settlement), reported as a 0..1 fraction.
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ 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.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -111,6 +112,7 @@ class NotificationDispatcher(
|
||||
LnZapEvent.KIND,
|
||||
NutzapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
|
||||
+2
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
|
||||
val AccountInfoAndListsFromKeyKinds =
|
||||
listOf(
|
||||
@@ -80,6 +81,7 @@ val AccountInfoAndListsFromKeyKinds2 =
|
||||
GeohashListEvent.KIND,
|
||||
TrustProviderListEvent.KIND,
|
||||
PaymentTargetsEvent.KIND,
|
||||
Bolt12OfferListEvent.KIND,
|
||||
RelayFeedsListEvent.KIND,
|
||||
InterestSetEvent.KIND,
|
||||
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers.
|
||||
|
||||
+3
@@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Kinds that notify me about activity on MY messages inside a NIP-29 group — reactions, replies
|
||||
@@ -74,6 +75,7 @@ val GroupNotificationKinds =
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
)
|
||||
|
||||
@@ -85,6 +87,7 @@ val SummaryKinds =
|
||||
GenericRepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
)
|
||||
|
||||
val NotificationsPerKeyKinds =
|
||||
|
||||
+2
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
val RepliesAndReactionsToAddressesKinds1 =
|
||||
@@ -47,6 +48,7 @@ val RepliesAndReactionsToAddressesKinds1 =
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
AttestationEvent.KIND,
|
||||
|
||||
+2
@@ -44,6 +44,7 @@ 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.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
val RepliesAndReactionsKinds =
|
||||
@@ -55,6 +56,7 @@ val RepliesAndReactionsKinds =
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
OtsEvent.KIND,
|
||||
TextNoteModificationEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
|
||||
+2
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
val UserMetadataForKeyKinds =
|
||||
@@ -46,6 +47,7 @@ val UserMetadataForKeyKinds =
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
KeyPackageRelayListEvent.KIND,
|
||||
PaymentTargetsEvent.KIND,
|
||||
Bolt12OfferListEvent.KIND,
|
||||
// NIP-61 nutzap-info. Telegraphs which mints + P2PK pubkey this
|
||||
// user accepts nutzaps at. Co-loaded with kind:0 so the zap-picker
|
||||
// can decide whether to show the Nutzap chip the moment a note's
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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.actions.bolt12Offers
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
fun Bolt12OffersScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: Bolt12OffersViewModel = viewModel()
|
||||
viewModel.init(accountViewModel)
|
||||
|
||||
LaunchedEffect(key1 = accountViewModel) {
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
Bolt12OffersScaffold(viewModel) {
|
||||
nav.popBack()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Bolt12OffersScaffold(
|
||||
viewModel: Bolt12OffersViewModel,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SavingTopBar(
|
||||
titleRes = R.string.bolt12_offers,
|
||||
onCancel = {
|
||||
viewModel.refresh()
|
||||
onClose()
|
||||
},
|
||||
onPost = {
|
||||
viewModel.saveOffers()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
top = padding.calculateTopPadding(),
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.bolt12_offers_explainer),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
Bolt12OffersBody(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Bolt12OffersBody(viewModel: Bolt12OffersViewModel) {
|
||||
val offers by viewModel.offers.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
R.string.bolt12_offers,
|
||||
R.string.bolt12_offers_section_explainer,
|
||||
SettingsCategoryFirstModifier,
|
||||
)
|
||||
}
|
||||
|
||||
if (offers.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringRes(id = R.string.no_bolt12_offers_message),
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(offers, key = { it }) { offer ->
|
||||
Bolt12OfferEntry(offer = offer, onDelete = { viewModel.removeOffer(offer) })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Bolt12OfferAddField { raw -> viewModel.addOffer(raw) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Bolt12OfferEntry(
|
||||
offer: String,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Text(
|
||||
text = "${offer.take(14)}…${offer.takeLast(6)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(id = R.string.delete_bolt12_offer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Bolt12OfferAddField(onAdd: (raw: String) -> Boolean) {
|
||||
var offer by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Size10dp)) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.bolt12_offer)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = offer,
|
||||
onValueChange = {
|
||||
offer = it
|
||||
isError = false
|
||||
},
|
||||
isError = isError,
|
||||
supportingText =
|
||||
if (isError) {
|
||||
{ Text(text = stringRes(R.string.invalid_bolt12_offer)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "lno1…",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (onAdd(offer)) {
|
||||
offer = ""
|
||||
isError = false
|
||||
} else {
|
||||
isError = true
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
enabled = offer.isNotBlank(),
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.actions.bolt12Offers
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Edits the logged-in user's NIP-XX BOLT12 offer list (kind 10058). Mirrors
|
||||
* [com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel];
|
||||
* each entry is a canonical raw `lno1...` offer string.
|
||||
*/
|
||||
@Stable
|
||||
class Bolt12OffersViewModel : ViewModel() {
|
||||
private lateinit var accountViewModel: AccountViewModel
|
||||
private lateinit var account: Account
|
||||
|
||||
private val _offers = MutableStateFlow<List<String>>(emptyList())
|
||||
val offers = _offers.asStateFlow()
|
||||
private var isModified = false
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
}
|
||||
|
||||
fun load() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
isModified = false
|
||||
viewModelScope.launch {
|
||||
_offers.update { account.bolt12OfferList.flow.value }
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the canonical offer if [raw] is a well-formed BOLT12 offer, else null. */
|
||||
fun canonicalOfferOrNull(raw: String): String? {
|
||||
val canonical = Bolt12Bech32.canonicalize(raw)
|
||||
return if (Bolt12Bech32.isOffer(canonical)) canonical else null
|
||||
}
|
||||
|
||||
/** Adds [raw] if it's a valid offer not already present; returns true when added. */
|
||||
fun addOffer(raw: String): Boolean {
|
||||
val canonical = canonicalOfferOrNull(raw) ?: return false
|
||||
if (_offers.value.contains(canonical)) return false
|
||||
_offers.update { it.plus(canonical) }
|
||||
isModified = true
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeOffer(offer: String) {
|
||||
_offers.update { it.minus(offer) }
|
||||
isModified = true
|
||||
}
|
||||
|
||||
fun saveOffers() {
|
||||
if (isModified) {
|
||||
accountViewModel.launchSigner {
|
||||
saveOffersSuspend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveOffersSuspend() {
|
||||
if (isModified) {
|
||||
account.saveBolt12Offers(_offers.value)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.bolt12Offers.Bolt12OffersScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomImportScreen
|
||||
@@ -593,6 +594,7 @@ fun BuildNavigation(
|
||||
}
|
||||
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditBolt12Offers> { Bolt12OffersScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
|
||||
|
||||
@@ -486,6 +486,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
|
||||
@Serializable object EditBolt12Offers : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
|
||||
@Serializable data class Nip47NWCSetup(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import com.vitorpamplona.amethyst.commons.model.Bolt12ZapEntry
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
|
||||
private fun onBolt12ZapEntryClick(
|
||||
entry: Bolt12ZapEntry,
|
||||
nav: INav,
|
||||
) {
|
||||
entry.source.author?.let { nav.nav(routeFor(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactions-row gallery of the payers who BOLT12-zapped this note. Mirrors the
|
||||
* onchain gallery but simpler: BOLT12 zaps are validated synchronously at consume
|
||||
* time, so there is no async re-verification and no pending state — every entry
|
||||
* here is already counted. `Note.addBolt12Zap` invalidates `flowSet.zaps`, so this
|
||||
* refreshes on arrival; memoizing on the `bolt12Zaps` map reference keeps a busy
|
||||
* lightning thread from churning it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WatchBolt12ZapsAndRenderGallery(
|
||||
baseNote: Note,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val zapsState by observeNoteZaps(baseNote, accountViewModel)
|
||||
val bolt12ZapsMap = zapsState?.note?.bolt12Zaps
|
||||
val entries =
|
||||
remember(bolt12ZapsMap) {
|
||||
bolt12ZapsMap?.values?.toImmutableList() ?: persistentListOf()
|
||||
}
|
||||
|
||||
if (entries.isNotEmpty()) {
|
||||
RenderBolt12ZapGallery(entries, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBolt12ZapGallery(
|
||||
entries: ImmutableList<Bolt12ZapEntry>,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(modifier = WidthAuthorPictureModifier) {
|
||||
ZappedIcon(
|
||||
modifier = Modifier.size(Size25dp).align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
|
||||
Bolt12ZapAuthorGallery(entries, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun Bolt12ZapAuthorGallery(
|
||||
entries: ImmutableList<Bolt12ZapEntry>,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Column(modifier = StdStartPadding) {
|
||||
FlowRow {
|
||||
entries.forEach { entry ->
|
||||
Bolt12ZapEntryRow(entry, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bolt12ZapEntryRow(
|
||||
entry: Bolt12ZapEntry,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// Anonymous zaps carry no `P` payer tag — show the blank/unknown author (as the
|
||||
// standalone card does) instead of the throwaway ephemeral key's default avatar.
|
||||
val isAnonymous = (entry.source.event as? Bolt12ZapEvent)?.isAnonymous() == true
|
||||
val user = if (isAnonymous) null else entry.source.author
|
||||
|
||||
// The amount is validated (checked against the proof's invoice_amount), so it
|
||||
// is safe to show for any sender. A not-yet-crypto-verified (compressed) proof
|
||||
// still dims its avatar so the viewer can tell it apart from a fully-verified one.
|
||||
val avatarAlpha = if (entry.cryptoVerified) 1f else 0.6f
|
||||
val amountText =
|
||||
remember(entry.amountMillisats) {
|
||||
val sats = entry.amountMillisats / 1000
|
||||
if (sats > 0L) showAmount(BigDecimal.valueOf(sats)) else ""
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Size35Modifier.clickable { onBolt12ZapEntryClick(entry, nav) },
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Box(modifier = Modifier.alpha(avatarAlpha)) {
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
|
||||
user,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
if (amountText.isNotEmpty()) {
|
||||
CrossfadeToDisplayAmount(amountText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBolt12Zap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
|
||||
@@ -355,6 +356,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
@@ -1095,6 +1097,10 @@ private fun RenderNoteRow(
|
||||
RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is Bolt12ZapEvent -> {
|
||||
RenderBolt12Zap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is LiveActivitiesClipEvent -> {
|
||||
RenderChatClip(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@@ -587,6 +587,7 @@ private fun ReactionDetailGallery(
|
||||
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
|
||||
WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchBolt12ZapsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
if (relays.isNotEmpty()) {
|
||||
@@ -1514,7 +1515,8 @@ fun ObserveZapIconState(
|
||||
zapsState?.note?.zapPayments?.isNotEmpty() == true ||
|
||||
zapsState?.note?.zaps?.isNotEmpty() == true ||
|
||||
zapsState?.note?.nutzaps?.isNotEmpty() == true ||
|
||||
zapsState?.note?.onchainZaps?.isNotEmpty() == true
|
||||
zapsState?.note?.onchainZaps?.isNotEmpty() == true ||
|
||||
zapsState?.note?.bolt12Zaps?.isNotEmpty() == true
|
||||
val wasZapped =
|
||||
if (hasZapData) {
|
||||
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds)
|
||||
@@ -1554,7 +1556,12 @@ fun ObserveZapIcon(
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) {
|
||||
if (zapsState?.note?.zapPayments?.isNotEmpty() == true ||
|
||||
zapsState?.note?.zaps?.isNotEmpty() == true ||
|
||||
zapsState?.note?.nutzaps?.isNotEmpty() == true ||
|
||||
zapsState?.note?.onchainZaps?.isNotEmpty() == true ||
|
||||
zapsState?.note?.bolt12Zaps?.isNotEmpty() == true
|
||||
) {
|
||||
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds)
|
||||
if (wasZappedByLoggedInUser.value != newWasZapped) {
|
||||
wasZappedByLoggedInUser.value = newWasZapped
|
||||
|
||||
@@ -543,6 +543,33 @@ fun payViaIntent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a reusable BOLT12 offer (`lno1…`, from a recipient's kind:10058) off to an
|
||||
* installed wallet. Unlike a BOLT11 invoice, a BOLT12 offer is NOT a `lightning:`
|
||||
* payload — that scheme is defined for `lnbc…` invoices. Offers travel as the `lno`
|
||||
* parameter of a BIP21/BIP321 bitcoin URI (`bitcoin:?lno=lno1…`), where the on-chain
|
||||
* address is optional so a Lightning-only offer stands on its own. The wallet resolves
|
||||
* the offer, collects the amount, and completes the payment; this is a plain intent,
|
||||
* not a NIP-57/NIP-XX zap, so it produces no Nostr receipt.
|
||||
*/
|
||||
fun payViaBolt12Intent(
|
||||
offer: String,
|
||||
context: Context,
|
||||
onPaid: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "bitcoin:?lno=$offer".toUri())
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
context.startActivity(intent)
|
||||
onPaid()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(stringRes(context, R.string.no_wallet_found))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PayButton(
|
||||
isActive: Boolean,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ActivityAmountRow
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ActivityBadge
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ActivityCardFrame
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ActivityHeaderRow
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import java.text.NumberFormat
|
||||
|
||||
/**
|
||||
* Standalone card for a NIP-XX BOLT12 zap (kind 9736), styled like the NIP-57
|
||||
* lightning-zap card but labeled BOLT12. The sender is the `P` payer tag (or the
|
||||
* event pubkey when anonymous); the amount comes straight off the validated
|
||||
* `amount` tag — no LNURL provider or private-zap decryption is involved.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBolt12Zap(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? Bolt12ZapEvent ?: return
|
||||
|
||||
val senderKey = event.payer()
|
||||
val recipientKey = event.recipient()
|
||||
val amountSats = event.amount()?.div(1000)
|
||||
val comment = event.content.takeIf { it.isNotBlank() }
|
||||
|
||||
val orange = MaterialTheme.colorScheme.bitcoinColor
|
||||
|
||||
ActivityCardFrame(orange) { cardBackground ->
|
||||
ActivityHeaderRow(
|
||||
tint = orange,
|
||||
pillLabel = "BOLT12",
|
||||
badge = {
|
||||
ActivityBadge(orange) {
|
||||
ZapIcon(Modifier.size(18.dp), Color.White)
|
||||
}
|
||||
},
|
||||
senderAvatar = {
|
||||
if (senderKey != null) {
|
||||
UserPicture(senderKey, Size25dp, Modifier, accountViewModel, nav)
|
||||
} else {
|
||||
// Anonymous zap — no attributable payer.
|
||||
DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel)
|
||||
}
|
||||
},
|
||||
recipientAvatar =
|
||||
recipientKey?.let {
|
||||
{ UserPicture(it, Size25dp, Modifier, accountViewModel, nav) }
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
|
||||
amountSats?.let { ActivityAmountRow(NumberFormat.getNumberInstance().format(it), orange) }
|
||||
|
||||
comment?.let {
|
||||
CrossfadeToDisplayComment(it, cardBackground, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -163,6 +163,9 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
@@ -1205,6 +1208,34 @@ class AccountViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/** True when the account has at least one NIP-47 wallet configured. */
|
||||
fun hasNwcWallet(): Boolean =
|
||||
account.settings.nwcWallets.value
|
||||
.isNotEmpty()
|
||||
|
||||
/** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */
|
||||
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay()
|
||||
|
||||
/**
|
||||
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
|
||||
* `pay` method, wrapping it as a BIP321 `bitcoin:?lno=` instruction. This is a
|
||||
* plain payment, not a NIP-XX zap (no Nostr receipt); the outcome is surfaced as
|
||||
* a toast. Callers should gate on [hasNwcWallet].
|
||||
*/
|
||||
fun payBolt12OfferViaNwc(
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
) = launchSigner {
|
||||
account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
when (response) {
|
||||
is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent)
|
||||
is IErrorResponseLike ->
|
||||
toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, response.errorMessage() ?: "")
|
||||
else -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in
|
||||
* the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ 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
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts
|
||||
@@ -56,7 +57,7 @@ fun filterGoalForLiveActivities(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND),
|
||||
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND),
|
||||
tags = mapOf("e" to listOf(goalId)),
|
||||
limit = 200,
|
||||
since = since?.get(relay)?.time,
|
||||
|
||||
+2
@@ -29,6 +29,7 @@ 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
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
fun filterMessagesToLiveActivities(
|
||||
channel: LiveActivitiesChannel,
|
||||
@@ -46,6 +47,7 @@ fun filterMessagesToLiveActivities(
|
||||
LiveActivitiesClipEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
|
||||
limit = 200,
|
||||
|
||||
+2
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Per-room state for every wire subscription scoped to a single
|
||||
@@ -96,6 +97,7 @@ class NestRoomFilterSubAssembler(
|
||||
MeetingRoomPresenceEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to listOf(key.note.idHex)),
|
||||
since = since?.get(relay)?.time,
|
||||
|
||||
+3
-1
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -168,7 +169,7 @@ private fun ZapsCollector(
|
||||
LaunchedEffect(viewModel, roomATag) {
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(LnZapEvent.KIND),
|
||||
kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND),
|
||||
tags = mapOf("a" to listOf(roomATag)),
|
||||
)
|
||||
LocalCache.observeNotes(filter).collect { notes ->
|
||||
@@ -176,6 +177,7 @@ private fun ZapsCollector(
|
||||
notes.forEach { note ->
|
||||
viewModel.onChatEvent(note)
|
||||
(note.event as? LnZapEvent)?.let { viewModel.onZapEvent(it, nowSec) }
|
||||
(note.event as? Bolt12ZapEvent)?.let { viewModel.onZapEvent(it, nowSec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -136,6 +137,17 @@ class NotificationSummaryState(
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent is Bolt12ZapEvent -> {
|
||||
if (noteEvent.isTaggedUser(currentUser)) {
|
||||
val amount = noteEvent.amount()
|
||||
if (amount != null) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + BigDecimal.valueOf(amount / 1000)
|
||||
takenIntoAccount.add(noteEvent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent is BaseThreadedEvent &&
|
||||
noteEvent.isTaggedUser(currentUser) &&
|
||||
noteEvent.pubKey != currentUser -> {
|
||||
@@ -222,6 +234,18 @@ class NotificationSummaryState(
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent is Bolt12ZapEvent -> {
|
||||
if (noteEvent.isTaggedUser(currentUser)) {
|
||||
val amount = noteEvent.amount()
|
||||
if (amount != null) {
|
||||
val netDate = formatDate(noteEvent.createdAt)
|
||||
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + BigDecimal.valueOf(amount / 1000)
|
||||
takenIntoAccount.add(noteEvent.id)
|
||||
hasNewElements = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent is BaseThreadedEvent &&
|
||||
noteEvent.isTaggedUser(currentUser) &&
|
||||
noteEvent.pubKey != currentUser -> {
|
||||
|
||||
+9
-3
@@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -166,6 +167,7 @@ class NotificationFeedFilter(
|
||||
LnZapEvent.KIND,
|
||||
NutzapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
Bolt12ZapEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
PictureEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
@@ -267,7 +269,8 @@ class NotificationFeedFilter(
|
||||
// on the user marks it as theirs.
|
||||
val targetsZapReceipt =
|
||||
event.hasScopeKind(LnZapEvent.KIND.toString()) ||
|
||||
note.replyTo?.any { it.event is LnZapEvent } == true
|
||||
event.hasScopeKind(Bolt12ZapEvent.KIND.toString()) ||
|
||||
note.replyTo?.any { it.event is LnZapEvent || it.event is Bolt12ZapEvent } == true
|
||||
|
||||
if (targetsZapReceipt && event.isTaggedUser(authorHex)) {
|
||||
return true
|
||||
@@ -472,6 +475,9 @@ class NotificationFeedFilter(
|
||||
} else {
|
||||
noteEvent.pubKey
|
||||
}
|
||||
} else if (noteEvent is Bolt12ZapEvent) {
|
||||
// The sender is the `P` payer tag; anonymous zaps sign with an ephemeral key.
|
||||
noteEvent.payer() ?: noteEvent.pubKey
|
||||
} else {
|
||||
if (it is AddressableNote) {
|
||||
it.address.pubKeyHex
|
||||
@@ -482,7 +488,7 @@ class NotificationFeedFilter(
|
||||
|
||||
// Reactions/zaps/reposts target a note via `replyTo`, not via thread-root tags,
|
||||
// so isNotInMutedThread on the wrapper event misses them.
|
||||
if (noteEvent is ReactionEvent || noteEvent is LnZapEvent ||
|
||||
if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent ||
|
||||
noteEvent is RepostEvent || noteEvent is GenericRepostEvent
|
||||
) {
|
||||
val target = it.replyTo?.lastOrNull()
|
||||
@@ -545,7 +551,7 @@ class NotificationFeedFilter(
|
||||
// relevance check (tagsAnEventByUser is skipped below); it still scopes
|
||||
// to genuine replies, so unrelated channel chatter never leaks through.
|
||||
return noteEvent?.kind in NOTIFICATION_KINDS &&
|
||||
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(isChessEvent || isConcord || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) &&
|
||||
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
|
||||
|
||||
+2
-1
@@ -27,8 +27,9 @@ 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
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND)
|
||||
val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND)
|
||||
|
||||
fun filterUserProfileZapsReceived(
|
||||
user: User,
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* 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.profile.header
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun Bolt12PayButton(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val address =
|
||||
remember(user.pubkeyHex) {
|
||||
Bolt12OfferListEvent.createAddress(user.pubkeyHex)
|
||||
}
|
||||
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
EventFinderFilterAssemblerSubscription(note, accountViewModel)
|
||||
val event by observeNoteEvent<Bolt12OfferListEvent>(note, accountViewModel)
|
||||
val offers =
|
||||
remember(event) {
|
||||
event?.offers() ?: emptyList()
|
||||
}
|
||||
if (offers.isNotEmpty()) {
|
||||
Bolt12PayButtonWithOffers(offers, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Bolt12PayButtonWithOffers(
|
||||
offers: List<String>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
FilledTonalButton(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = { expanded = true },
|
||||
contentPadding = ZeroPadding,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = stringRes(R.string.bolt12_offers),
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Bolt12OffersDialog(
|
||||
offers = offers,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = { expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Bolt12OffersDialog(
|
||||
offers: List<String>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Whether we can settle in-app over the user's NIP-47 wallet (nwc#2 `pay`), or
|
||||
// must hand off to an external wallet via a bitcoin: intent.
|
||||
val canPayInApp = remember { accountViewModel.canPayBolt12ViaNwc() }
|
||||
|
||||
// The offer the user chose to pay over NWC; drives the amount-entry dialog.
|
||||
var nwcPayOffer by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.bolt12_offers),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
M3ActionSection {
|
||||
offers.forEach { offer ->
|
||||
Bolt12OfferRow(
|
||||
offer = offer,
|
||||
canPayInApp = canPayInApp,
|
||||
onCopy = {
|
||||
scope.launch {
|
||||
clipboardManager.setText(offer)
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
stringRes(context, R.string.copied_to_clipboard),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
payViaBolt12Intent(
|
||||
offer = offer,
|
||||
context = context,
|
||||
onPaid = { onDismiss() },
|
||||
onError = { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
},
|
||||
onPayInApp = { nwcPayOffer = offer },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nwcPayOffer?.let { offer ->
|
||||
Bolt12NwcAmountDialog(
|
||||
onDismiss = { nwcPayOffer = null },
|
||||
onPay = { amountMillisats ->
|
||||
accountViewModel.payBolt12OfferViaNwc(offer, amountMillisats)
|
||||
nwcPayOffer = null
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bolt12OfferRow(
|
||||
offer: String,
|
||||
canPayInApp: Boolean,
|
||||
onCopy: () -> Unit,
|
||||
onPayViaIntent: () -> Unit,
|
||||
onPayInApp: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "${offer.take(14)}…${offer.takeLast(6)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = onCopy) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ContentCopy,
|
||||
contentDescription = stringRes(R.string.copy_to_clipboard),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (canPayInApp) {
|
||||
IconButton(onClick = onPayInApp) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AccountBalanceWallet,
|
||||
contentDescription = stringRes(R.string.bolt12_pay_with_wallet),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onPayViaIntent) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = stringRes(R.string.bolt12_offers),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects an amount (in sats) and returns it to [onPay] in millisats. */
|
||||
@Composable
|
||||
private fun Bolt12NwcAmountDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onPay: (amountMillisats: Long) -> Unit,
|
||||
) {
|
||||
var sats by remember { mutableStateOf("") }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.bolt12_pay_with_wallet),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = sats,
|
||||
onValueChange = { new -> sats = new.filter { it.isDigit() } },
|
||||
label = { Text(text = stringRes(R.string.bolt12_payment_amount_sats)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = {
|
||||
val amountSats = sats.toLongOrNull()
|
||||
if (amountSats != null && amountSats > 0) {
|
||||
onPay(amountSats * 1000)
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
enabled = (sats.toLongOrNull() ?: 0) > 0,
|
||||
) {
|
||||
Text(text = stringRes(R.string.bolt12_pay_with_wallet))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -42,6 +42,8 @@ fun ProfileActions(
|
||||
|
||||
PaymentButton(baseUser, accountViewModel, nav)
|
||||
|
||||
Bolt12PayButton(baseUser, accountViewModel)
|
||||
|
||||
val isMe by
|
||||
remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } }
|
||||
|
||||
|
||||
+13
-1
@@ -32,6 +32,7 @@ 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.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.BigDecimal
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -54,7 +55,7 @@ class UserProfileZapsViewModel(
|
||||
) : ViewModel() {
|
||||
val zapsToUser =
|
||||
Filter(
|
||||
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND),
|
||||
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(user.pubkeyHex)),
|
||||
)
|
||||
|
||||
@@ -105,6 +106,16 @@ class UserProfileZapsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapBolt12Zap(event: Bolt12ZapEvent): ZapAmount {
|
||||
// The payer is the `P` tag; anonymous zaps fall back to the event pubkey.
|
||||
// amount() is in millisats — divide to sats for the profile total.
|
||||
val amountSats = (event.amount() ?: 0L) / 1000
|
||||
return ZapAmount(
|
||||
LocalCache.getOrCreateUser(event.payer() ?: event.pubKey),
|
||||
BigDecimal(amountSats),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun List<Event>.sumAmountsByUser(): List<ZapAmount> {
|
||||
val results = mutableMapOf<User, BigDecimal>()
|
||||
|
||||
@@ -113,6 +124,7 @@ class UserProfileZapsViewModel(
|
||||
when (zapEvent) {
|
||||
is LnZapEvent -> mapRequest(zapEvent)
|
||||
is OnchainZapEvent -> mapOnchainZap(zapEvent)
|
||||
is Bolt12ZapEvent -> mapBolt12Zap(zapEvent)
|
||||
else -> null
|
||||
}
|
||||
if (zapAmount != null) {
|
||||
|
||||
+2
@@ -169,6 +169,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -261,6 +262,7 @@ fun kindDisplayName(kind: Int): Int =
|
||||
LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats
|
||||
LiveActivitiesEvent.KIND -> R.string.kind_live_streams
|
||||
LnZapEvent.KIND -> R.string.kind_zaps
|
||||
Bolt12ZapEvent.KIND -> R.string.kind_zaps
|
||||
LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request
|
||||
LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response
|
||||
LnZapPrivateEvent.KIND -> R.string.kind_private_zaps
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ fun buildSettingsCatalog(
|
||||
symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
|
||||
symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges),
|
||||
symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets),
|
||||
symEntry(R.string.bolt12_offers, MaterialSymbols.Payment, R.string.bolt12_offers_search_keywords, Route.EditBolt12Offers),
|
||||
symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps),
|
||||
symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings),
|
||||
symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters),
|
||||
|
||||
+4
@@ -162,6 +162,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBolt12Zap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
||||
@@ -348,6 +349,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
@@ -919,6 +921,8 @@ private fun FullBleedNoteCompose(
|
||||
RenderNutzap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is OnchainZapEvent) {
|
||||
RenderOnchainZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is Bolt12ZapEvent) {
|
||||
RenderBolt12Zap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is ReactionEvent) {
|
||||
RenderReaction(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is SearchRelayListEvent) {
|
||||
|
||||
@@ -1753,6 +1753,21 @@
|
||||
<string name="no_payment_app_found_for_type">No app installed to handle %1$s payments. Please install a compatible wallet.</string>
|
||||
<string name="error_dialog_payment_error">Unable to open payment</string>
|
||||
|
||||
<string name="bolt12_offers">BOLT12 Offers</string>
|
||||
<string name="bolt12_offers_explainer">Publish reusable BOLT12 offers so others can zap you over Lightning without a separate invoice each time.</string>
|
||||
<string name="bolt12_offers_section_explainer">Add one or more BOLT12 offers (lno1…). They are shared publicly so anyone can pay you.</string>
|
||||
<string name="no_bolt12_offers_message">No BOLT12 offers set. Add one below ↓</string>
|
||||
<string name="bolt12_offer">BOLT12 offer (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">Not a valid BOLT12 offer</string>
|
||||
<string name="delete_bolt12_offer">Delete BOLT12 offer</string>
|
||||
<string name="bolt12_pay_with_wallet">Pay with connected wallet</string>
|
||||
<string name="bolt12_payment_amount_sats">Amount (sats)</string>
|
||||
<string name="bolt12_payment_sent">BOLT12 payment sent</string>
|
||||
<string name="bolt12_payment_failed">BOLT12 payment failed: %1$s</string>
|
||||
<string name="bolt12_zap_error">BOLT12 zap error</string>
|
||||
<string name="bolt12_zap_paid_no_receipt">Paid over BOLT12, but the wallet returned no proof, so no zap receipt was published.</string>
|
||||
<string name="bolt12_zap_invalid_receipt">Paid over BOLT12, but the payment proof did not validate, so no zap receipt was published.</string>
|
||||
|
||||
<string name="uploading_state_ready">Not Started</string>
|
||||
<string name="uploading_state_compressing">Compressing</string>
|
||||
<string name="uploading_state_uploading">Uploading</string>
|
||||
@@ -2255,6 +2270,7 @@
|
||||
<string name="video_player_search_keywords" translatable="false">video, player, playback, autoplay, mute</string>
|
||||
<string name="audio_visualizer_search_keywords" translatable="false">audio, visualizer, spectrum, bars, waves, radial, aurora, animation</string>
|
||||
<string name="payment_targets_search_keywords" translatable="false">zap split, split, recipients, forward zaps</string>
|
||||
<string name="bolt12_offers_search_keywords" translatable="false">bolt12, bolt 12, offer, lno, lightning, zap, reusable invoice</string>
|
||||
<string name="call_settings_search_keywords" translatable="false">webrtc, video call, voice call, calls</string>
|
||||
<string name="translations_search_keywords" translatable="false">language, translate, locale</string>
|
||||
<string name="ots_explorer_search_keywords" translatable="false">opentimestamps, timestamp, ots, proof</string>
|
||||
|
||||
+2
-1
@@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
@@ -776,7 +777,7 @@ class AmethystAppFunctions {
|
||||
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(LnZapEvent.KIND),
|
||||
kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(myPub)),
|
||||
since = sinceSecs,
|
||||
limit = 500,
|
||||
|
||||
@@ -672,6 +672,24 @@ output then also reports `paid` + the preimage.
|
||||
| `amy zap user USER SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Profile zap: build the zap request and fetch a BOLT11 from USER's LN service. |
|
||||
| `amy zap event EVENT-ID SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Same, attributed to a specific event (must be in the local store). Zap splits are honored — one invoice per recipient. |
|
||||
|
||||
### BOLT12 zaps (NIP-XX)
|
||||
|
||||
BOLT12 zaps (kinds 9736/9737, offers in kind:10058). amy has no NWC payment
|
||||
rail, so sending is a **two-step, out-of-band** flow: `bolt12 intent` signs the
|
||||
kind:9737 and prints its `payer_note`; you pay the offer elsewhere putting that
|
||||
note in the invoice request's `invreq_payer_note`; then `bolt12 zap` wraps the
|
||||
same signed intent and the settled `lnp` proof into a kind:9736 (validated
|
||||
before publishing).
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy bolt12 decode LNO1\|LNP1` | Decode a BOLT12 offer or payer proof to its fields (offline). |
|
||||
| `amy bolt12 verify EVENT-ID` | Validate a kind:9736 zap in the local store — reports `valid`, `crypto_verified`, recipient, amount, payment hash. |
|
||||
| `amy bolt12 offer get USER [--timeout SECS]` | Fetch + show a user's kind:10058 BOLT12 offers. |
|
||||
| `amy bolt12 offer set LNO1 [LNO1 …]` | Publish your own kind:10058 offer list. |
|
||||
| `amy bolt12 intent [event] TARGET SATS --offer LNO1 [--comment X]` | Sign a kind:9737 intent for a user (or event); prints its `intent_id`, `payer_note`, and `intent_json`. |
|
||||
| `amy bolt12 zap --intent JSON --proof LNP1` | Wrap a signed intent + settled payer proof into a kind:9736, validate, and publish. |
|
||||
|
||||
### CLINK Offers
|
||||
|
||||
| Command | What it does |
|
||||
|
||||
@@ -62,6 +62,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). |
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
| NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
|
||||
| BOLT12 zaps (NIP-XX, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions` — `bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent`→`zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. |
|
||||
| NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. |
|
||||
| CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. |
|
||||
| Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. |
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.cli
|
||||
import com.vitorpamplona.amethyst.cli.commands.AdminCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.Bolt12Commands
|
||||
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
|
||||
@@ -300,6 +301,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"graperank" -> GrapeRankCommand.dispatch(dataDir, tail)
|
||||
"search" -> SearchCommand.dispatch(dataDir, tail)
|
||||
"zap" -> ZapCommand.dispatch(dataDir, tail)
|
||||
"bolt12" -> Bolt12Commands.dispatch(dataDir, tail)
|
||||
"offer" -> OfferCommands.dispatch(dataDir, tail)
|
||||
"debit" -> DebitCommands.dispatch(dataDir, tail)
|
||||
"event" -> EventCommand.run(dataDir, tail)
|
||||
@@ -748,6 +750,16 @@ private fun printUsage() {
|
||||
| [--comment X] [--anon|--private] event (must be in local store)
|
||||
| [--timeout SECS]
|
||||
|
|
||||
|BOLT12 zaps (NIP-XX):
|
||||
| bolt12 decode LNO1|LNP1 decode a BOLT12 offer or payer proof
|
||||
| bolt12 verify EVENT-ID validate a kind:9736 zap in the local store
|
||||
| bolt12 offer get USER fetch + show a user's kind:10058 offers
|
||||
| bolt12 offer set LNO1 [LNO1 …] publish your own kind:10058 offer list
|
||||
| bolt12 intent [event] TARGET SATS sign a 9737 intent; prints its payer_note
|
||||
| --offer LNO1 [--comment X] (pay the offer out-of-band with that note)
|
||||
| bolt12 zap --intent JSON wrap a signed intent + settled proof into a
|
||||
| --proof LNP1 kind:9736, validate, and publish
|
||||
|
|
||||
|CLINK Offers:
|
||||
| offer info NOFFER decode a noffer1… pointer (local, no network)
|
||||
| offer discover USER find a user's published offers
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* 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.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* `amy bolt12 …` — NIP-XX BOLT12 zaps from the command line, for headless interop
|
||||
* testing of the quartz BOLT12 stack.
|
||||
*
|
||||
* Sub-verbs:
|
||||
* * `bolt12 decode <lno1…|lnp1…>` — decode an offer or payer proof to its fields.
|
||||
* * `bolt12 verify <event-id>` — validate a kind:9736 zap in the local store.
|
||||
* * `bolt12 offer get <user>` — fetch + show a user's kind:10058 offers.
|
||||
* * `bolt12 offer set <lno1…>…` — publish your own kind:10058 offer list.
|
||||
* * `bolt12 zap|intent …` — send-side assembly (see [Bolt12SendCommands]).
|
||||
*
|
||||
* amy has no NIP-47/NWC payment rail, so BOLT12 sending is a two-step, out-of-band
|
||||
* flow: `bolt12 intent` signs the 9737 and prints its `payer_note`; you pay the offer
|
||||
* elsewhere putting that note in the invoice request's `invreq_payer_note`; then
|
||||
* `bolt12 zap --intent <json> --proof <lnp1>` wraps that same signed intent and the
|
||||
* settled proof into a kind:9736. (The intent must be reused verbatim — re-deriving it
|
||||
* would change its id, and the `payer_note` you paid with wouldn't match.)
|
||||
*/
|
||||
object Bolt12Commands {
|
||||
val USAGE: String =
|
||||
"""
|
||||
|BOLT12 zaps (NIP-XX):
|
||||
| bolt12 decode LNO1|LNP1 decode an offer or payer proof to fields
|
||||
| bolt12 verify EVENT-ID validate a kind:9736 zap in the local store
|
||||
| bolt12 offer get USER fetch + show a user's kind:10058 offers
|
||||
| [--timeout SECS]
|
||||
| bolt12 offer set LNO1 [LNO1 …] publish your own kind:10058 offer list
|
||||
| bolt12 intent USER SATS --offer LNO1 [--comment X]
|
||||
| bolt12 intent event EVENT-ID SATS --offer LNO1 [--comment X]
|
||||
| sign a 9737 intent; prints its id + payer_note
|
||||
| bolt12 zap --intent JSON --proof LNP1
|
||||
| wrap a signed intent + settled proof into a
|
||||
| kind:9736, validate, and publish
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"bolt12",
|
||||
tail,
|
||||
"bolt12 <decode|verify|offer|intent|zap> …",
|
||||
help = USAGE,
|
||||
routes =
|
||||
mapOf(
|
||||
"decode" to { rest -> decode(rest) },
|
||||
"verify" to { rest -> verify(dataDir, rest) },
|
||||
"offer" to { rest -> offerDispatch(dataDir, rest) },
|
||||
"intent" to { rest -> Bolt12SendCommands.intent(dataDir, rest) },
|
||||
"zap" to { rest -> Bolt12SendCommands.zap(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
private fun decode(rest: Array<String>): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 decode <lno1…|lnp1…>")
|
||||
val raw = rest[0]
|
||||
val canonical = Bolt12Bech32.canonicalize(raw)
|
||||
when {
|
||||
Bolt12Bech32.isOffer(canonical) -> {
|
||||
val fields = Bolt12ZapActions.decodeOffer(raw) ?: return Output.error("decode_failed", "not a parseable BOLT12 offer")
|
||||
Output.emit(mapOf("type" to "offer") + fields)
|
||||
}
|
||||
Bolt12Bech32.isPayerProof(canonical) -> {
|
||||
val fields = Bolt12ZapActions.decodeProof(raw) ?: return Output.error("decode_failed", "not a parseable BOLT12 payer proof")
|
||||
Output.emit(mapOf("type" to "payer_proof") + fields)
|
||||
}
|
||||
else -> return Output.error("bad_args", "not a BOLT12 offer (lno1…) or payer proof (lnp1…)")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private suspend fun verify(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 verify <event-id>")
|
||||
val eventId = rest[0]
|
||||
if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex")
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
// Constrain by kind AND cast defensively: the store filters by id alone and
|
||||
// returns whatever kind that id actually is, so querying <Bolt12ZapEvent>
|
||||
// directly would ClassCastException if the id points at a non-9736 event.
|
||||
val zap =
|
||||
ctx.store
|
||||
.query<Event>(Filter(kinds = listOf(Bolt12ZapEvent.KIND), ids = listOf(eventId), limit = 1))
|
||||
.firstOrNull() as? Bolt12ZapEvent
|
||||
?: return Output.error("not_found", "no kind:9736 event $eventId in local store; sync or fetch first")
|
||||
|
||||
when (val result = Bolt12ZapActions.validate(zap)) {
|
||||
is Bolt12ZapValidation.Valid ->
|
||||
Output.emit(
|
||||
buildMap {
|
||||
put("event_id", zap.id)
|
||||
put("valid", true)
|
||||
put("crypto_verified", result.proofCryptoVerified)
|
||||
put("recipient", result.recipient)
|
||||
result.payer?.let { put("payer", it) }
|
||||
put("anonymous", result.payer == null)
|
||||
put("amount_msat", result.amountMillisats)
|
||||
put("payment_hash", result.paymentHashHex)
|
||||
result.zappedEventId?.let { put("zapped_event_id", it) }
|
||||
},
|
||||
)
|
||||
|
||||
is Bolt12ZapValidation.Invalid ->
|
||||
Output.emit(mapOf("event_id" to zap.id, "valid" to false, "reason" to result.reason.name))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun offerDispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"bolt12 offer",
|
||||
tail,
|
||||
"bolt12 offer <get|set> …",
|
||||
help = USAGE,
|
||||
routes =
|
||||
mapOf(
|
||||
"get" to { rest -> offerGet(dataDir, rest) },
|
||||
"set" to { rest -> offerSet(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun offerGet(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer get <user> [--timeout SECS]")
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val timeoutMs = args.timeoutMs(8)
|
||||
args.rejectUnknown()
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val user = ctx.requireUserHex(rest[0])
|
||||
val relays = ctx.bootstrapRelays()
|
||||
val filter = Filter(kinds = listOf(Bolt12OfferListEvent.KIND), authors = listOf(user), limit = 1)
|
||||
val latest =
|
||||
(ctx.store.query<Bolt12OfferListEvent>(filter) + ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs).mapNotNull { it.second as? Bolt12OfferListEvent })
|
||||
.filter { it.pubKey == user }
|
||||
.maxByOrNull { it.createdAt }
|
||||
|
||||
Output.emit(mapOf("user" to user, "offers" to (latest?.offers() ?: emptyList())))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun offerSet(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "bolt12 offer set <lno1…> [<lno1…> …]")
|
||||
val canonical =
|
||||
rest.map { raw ->
|
||||
Bolt12ZapActions.canonicalOfferOrNull(raw) ?: return Output.error("bad_args", "not a valid BOLT12 offer: $raw")
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val event = Bolt12ZapActions.buildOfferList(ctx.signer, canonical)
|
||||
val relays: Set<NormalizedRelayUrl> = ctx.outboxRelays()
|
||||
val results = ctx.publish(event, relays)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to event.id,
|
||||
"offers" to canonical,
|
||||
"published_to" to results.keys.map { it.url },
|
||||
"accepted_by" to results.filterValues { it.accepted }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
|
||||
/**
|
||||
* The BOLT12 send-side sub-verbs of `amy bolt12` (split from [Bolt12Commands] to keep
|
||||
* each file focused). Two steps because amy has no NWC rail to fetch the proof itself:
|
||||
* [intent] signs the 9737 and prints the `payer_note`; the caller pays the offer
|
||||
* out-of-band with that note; [zap] wraps the same intent + settled proof into a 9736.
|
||||
*/
|
||||
object Bolt12SendCommands {
|
||||
suspend fun intent(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val eventMode = rest.firstOrNull() == "event"
|
||||
val body = if (eventMode) rest.drop(1).toTypedArray() else rest
|
||||
if (body.size < 2) {
|
||||
return Output.error("bad_args", "bolt12 intent [event] <user|event-id> <sats> --offer <lno1> [--comment X]")
|
||||
}
|
||||
val target = body[0]
|
||||
val sats =
|
||||
body[1].toLongOrNull()?.takeIf { it > 0 }
|
||||
?: return Output.error("bad_args", "sats must be a positive integer (got '${body[1]}')")
|
||||
val args = Args(body.drop(2).toTypedArray())
|
||||
val offerArg = args.flag("offer") ?: return Output.error("bad_args", "--offer <lno1…> is required")
|
||||
val offer = Bolt12ZapActions.canonicalOfferOrNull(offerArg) ?: return Output.error("bad_args", "--offer is not a valid BOLT12 offer")
|
||||
val comment = args.flag("comment") ?: ""
|
||||
args.rejectUnknown()
|
||||
val amountMsat = Bolt12ZapActions.satsToMillisats(sats)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val intent =
|
||||
if (eventMode) {
|
||||
if (target.length != 64) return Output.error("bad_args", "event-id must be 64-hex")
|
||||
val zappedEvent =
|
||||
ctx.store.query<Event>(Filter(ids = listOf(target), limit = 1)).firstOrNull()
|
||||
?: return Output.error("not_found", "event $target not in local store; sync or fetch first")
|
||||
val recipient = zappedEvent.pubKey
|
||||
Bolt12ZapActions.buildEventIntent(ctx.signer, recipient, amountMsat, offer, zappedEvent, comment)
|
||||
} else {
|
||||
val recipient = ctx.requireUserHex(target)
|
||||
Bolt12ZapActions.buildProfileIntent(ctx.signer, recipient, amountMsat, offer, comment)
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"intent_id" to intent.id,
|
||||
"recipient" to intent.recipient(),
|
||||
"amount_msat" to amountMsat,
|
||||
"offer" to offer,
|
||||
// Put this in the BOLT12 invoice request's invreq_payer_note when paying.
|
||||
"payer_note" to Bolt12ZapActions.payerNote(intent),
|
||||
// Feed this verbatim back to `bolt12 zap --intent` once you have the proof.
|
||||
"intent_json" to intent.toJson(),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun zap(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val intentJson = args.flag("intent") ?: return Output.error("bad_args", "bolt12 zap --intent <json> --proof <lnp1>")
|
||||
val proofArg = args.flag("proof") ?: return Output.error("bad_args", "--proof <lnp1…> is required")
|
||||
args.rejectUnknown()
|
||||
|
||||
val proof = Bolt12Bech32.canonicalize(proofArg)
|
||||
if (!Bolt12Bech32.isPayerProof(proof)) return Output.error("bad_args", "--proof is not a BOLT12 payer proof (lnp1…)")
|
||||
|
||||
val intent =
|
||||
runCatching { Event.fromJson(intentJson) as? Bolt12ZapIntentEvent }.getOrNull()
|
||||
?: return Output.error("bad_args", "--intent is not a valid kind:9737 zap-intent event")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
// Sign the 9736 with our account key. The validator requires the zap and the
|
||||
// embedded intent to share a pubkey, so an intent signed by another key is
|
||||
// rejected below and nothing is published.
|
||||
val zap = Bolt12ZapActions.buildZap(ctx.signer, intent, proof, anonymous = false)
|
||||
|
||||
when (val result = Bolt12ZapActions.validate(zap)) {
|
||||
is Bolt12ZapValidation.Invalid ->
|
||||
return Output.error("invalid_zap", "assembled zap failed validation: ${result.reason.name}; nothing published")
|
||||
|
||||
is Bolt12ZapValidation.Valid -> {
|
||||
val recipientInbox: Set<NormalizedRelayUrl> =
|
||||
ctx
|
||||
.relaysOf(result.recipient)
|
||||
?.readRelaysNorm()
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
val relays = ctx.outboxRelays() + recipientInbox
|
||||
val results = ctx.publish(zap, relays)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to zap.id,
|
||||
"recipient" to result.recipient,
|
||||
"amount_msat" to result.amountMillisats,
|
||||
"payment_hash" to result.paymentHashHex,
|
||||
"crypto_verified" to result.proofCryptoVerified,
|
||||
"published_to" to results.keys.map { it.url },
|
||||
"accepted_by" to results.filterValues { it.accepted }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
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.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* NIP-XX BOLT12 zap building, decoding, and validation — the shared, UI-free
|
||||
* surface a non-Android caller (amy CLI, interop harnesses) drives.
|
||||
*
|
||||
* Assembly-only: it re-exposes [Bolt12ZapBuilder] / [Bolt12ZapValidator] and the
|
||||
* BOLT12 codecs. Obtaining a settled `lnp` payer proof (the wallet's job over
|
||||
* NIP-47/nwc#2) lives outside these builders — a caller supplies the proof.
|
||||
* Pattern matches [ZapActions].
|
||||
*/
|
||||
object Bolt12ZapActions {
|
||||
fun satsToMillisats(sats: Long): Long = sats * 1000L
|
||||
|
||||
/** The canonical raw offer if [raw] is a well-formed BOLT12 offer (`lno1…`), else null. */
|
||||
fun canonicalOfferOrNull(raw: String): String? {
|
||||
val canonical = Bolt12Bech32.canonicalize(raw)
|
||||
return if (Bolt12Bech32.isOffer(canonical)) canonical else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a BOLT12 offer (`lno1…`) to its interesting fields, or null when unparseable.
|
||||
* Individual field reads degrade to null on malformed values (e.g. an amount longer
|
||||
* than 8 bytes), so a structurally-valid offer still decodes without its bad field;
|
||||
* the extraction stays guarded as defense-in-depth against any future throwing read.
|
||||
*/
|
||||
fun decodeOffer(raw: String): Map<String, Any?>? {
|
||||
val offer = Bolt12Offer.parse(raw) ?: return null
|
||||
return runCatching {
|
||||
buildMap {
|
||||
put("canonical", Bolt12Bech32.canonicalize(raw))
|
||||
offer.amount()?.let { put("amount_msat", it) }
|
||||
offer.currency()?.let { put("currency", it) }
|
||||
offer.description()?.let { put("description", it) }
|
||||
offer.issuerId()?.let { put("issuer_id", Hex.encode(it)) }
|
||||
put("has_paths", offer.hasPaths())
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a BOLT12 payer proof (`lnp1…`) to its interesting fields, or null when
|
||||
* unparseable. Guarded like [decodeOffer] against a malformed-but-encoded field.
|
||||
*/
|
||||
fun decodeProof(raw: String): Map<String, Any?>? {
|
||||
val proof = Bolt12PayerProof.parse(raw) ?: return null
|
||||
return runCatching {
|
||||
buildMap {
|
||||
put("has_all_required_fields", proof.hasAllCryptoFields())
|
||||
put("compressed", proof.isCompressed())
|
||||
proof.invreqPayerNote()?.let { put("invreq_payer_note", it) }
|
||||
proof.invreqPayerId()?.let { put("invreq_payer_id", Hex.encode(it)) }
|
||||
proof.invoiceAmount()?.let { put("invoice_amount_msat", it) }
|
||||
proof.invoicePaymentHash()?.let { put("invoice_payment_hash", Hex.encode(it)) }
|
||||
proof.invoiceNodeId()?.let { put("invoice_node_id", Hex.encode(it)) }
|
||||
proof.offerIssuerId()?.let { put("offer_issuer_id", Hex.encode(it)) }
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** The value a payer MUST put in the BOLT12 `invreq_payer_note` to bind a payment to [intent]. */
|
||||
fun payerNote(intent: Bolt12ZapIntentEvent): String = Bolt12ZapBuilder.payerNote(intent)
|
||||
|
||||
/** Sign a kind:9737 zap intent targeting [recipientPubKey]'s profile. */
|
||||
suspend fun buildProfileIntent(
|
||||
signer: NostrSigner,
|
||||
recipientPubKey: HexKey,
|
||||
amountMillisats: Long,
|
||||
offer: String,
|
||||
comment: String = "",
|
||||
): Bolt12ZapIntentEvent = Bolt12ZapBuilder.buildProfileIntent(signer, recipientPubKey, amountMillisats, offer, comment)
|
||||
|
||||
/** Sign a kind:9737 zap intent targeting a specific [zappedEvent]. */
|
||||
suspend fun buildEventIntent(
|
||||
signer: NostrSigner,
|
||||
recipientPubKey: HexKey,
|
||||
amountMillisats: Long,
|
||||
offer: String,
|
||||
zappedEvent: Event,
|
||||
comment: String = "",
|
||||
): Bolt12ZapIntentEvent = Bolt12ZapBuilder.buildIntent(signer, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), comment)
|
||||
|
||||
/**
|
||||
* Wrap a signed [intent] and a settled [payerProof] into a signed kind:9736 zap.
|
||||
* When [anonymous], [signer] MUST be the same ephemeral key that signed [intent].
|
||||
*/
|
||||
suspend fun buildZap(
|
||||
signer: NostrSigner,
|
||||
intent: Bolt12ZapIntentEvent,
|
||||
payerProof: String,
|
||||
anonymous: Boolean = false,
|
||||
): Bolt12ZapEvent = Bolt12ZapBuilder.buildZap(signer, intent, payerProof, anonymous)
|
||||
|
||||
/** Validate a kind:9736 BOLT12 zap (structure + intent match + payer-proof binding + crypto). */
|
||||
fun validate(zap: Bolt12ZapEvent): Bolt12ZapValidation = Bolt12ZapValidator().validate(zap)
|
||||
|
||||
/** Sign a kind:10058 BOLT12 offer list publishing [offers] (canonical `lno1…` strings). */
|
||||
suspend fun buildOfferList(
|
||||
signer: NostrSigner,
|
||||
offers: List<String>,
|
||||
): Bolt12OfferListEvent = Bolt12OfferListEvent.create(offers, signer)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.Stable
|
||||
|
||||
/**
|
||||
* Per-payment NIP-XX BOLT12 zap entry attached to a target Note.
|
||||
*
|
||||
* Unlike NIP-BC onchain zaps (which carry an async chain-verification state
|
||||
* machine), a BOLT12 zap is validated **synchronously** at consumption time —
|
||||
* the `lnp` payer proof is a self-contained cryptographic settlement proof — so
|
||||
* every entry stored here has already passed [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator]
|
||||
* and its amount is counted directly, the same way a NIP-57 lightning zap
|
||||
* receipt's amount is.
|
||||
*
|
||||
* @property source The kind:9736 Bolt12ZapEvent note. `source.author` is the
|
||||
* payer shown in the reactions gallery and notifications card
|
||||
* (the `P` tag; an anonymous zap uses an ephemeral key).
|
||||
* @property amountMillisats The validated amount in **millisatoshis** (the
|
||||
* `amount` tag, checked against the proof's `invoice_amount`).
|
||||
* @property cryptoVerified True when the payer proof's signatures were fully
|
||||
* verified. False when the zap is structurally valid and bound
|
||||
* to its intent but the proof is compressed and its signatures
|
||||
* can't yet be checked (pending the lightning/bolts#1346 merkle
|
||||
* reconstruction). The UI SHOULD label the latter as unverified.
|
||||
*/
|
||||
@Stable
|
||||
data class Bolt12ZapEntry(
|
||||
val source: Note,
|
||||
val amountMillisats: Long,
|
||||
val cryptoVerified: Boolean,
|
||||
)
|
||||
@@ -172,6 +172,7 @@ open class Note(
|
||||
removeLabel(note)
|
||||
removeNutzap(note)
|
||||
removeOnchainZapBySource(note)
|
||||
removeBolt12ZapBySource(note)
|
||||
}
|
||||
|
||||
var poll: PollResponsesCache? = null
|
||||
@@ -285,6 +286,22 @@ open class Note(
|
||||
var nutzaps = mapOf<HexKey, NutzapEntry>()
|
||||
private set
|
||||
|
||||
/**
|
||||
* NIP-XX BOLT12 zaps (kind 9736) targeting this note.
|
||||
* Key: the payer proof's `invoice_payment_hash` (hex) — the spec's dedup key,
|
||||
* so two zap events proving the same settled payment collapse to one entry.
|
||||
* Value: entry with the source Bolt12ZapEvent note (so `source.author` is the
|
||||
* payer), the validated amount in millisats, and whether the proof's crypto
|
||||
* was fully verified. Every entry here has already passed the synchronous
|
||||
* `Bolt12ZapValidator`, so all are counted by `updateZapTotal` (there is no
|
||||
* async pending state like onchain zaps have).
|
||||
*
|
||||
* `@Volatile` for the same cross-thread visibility reason as [onchainZaps].
|
||||
*/
|
||||
@Volatile
|
||||
var bolt12Zaps = mapOf<String, Bolt12ZapEntry>()
|
||||
private set
|
||||
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
@@ -412,7 +429,8 @@ open class Note(
|
||||
zaps.isNotEmpty() ||
|
||||
boosts.isNotEmpty() ||
|
||||
onchainZaps.isNotEmpty() ||
|
||||
nutzaps.isNotEmpty()
|
||||
nutzaps.isNotEmpty() ||
|
||||
bolt12Zaps.isNotEmpty()
|
||||
|
||||
fun countReactions(): Int {
|
||||
var total = 0
|
||||
@@ -472,7 +490,7 @@ open class Note(
|
||||
fun clearChildLinks(): List<Note> {
|
||||
val repliesChanged = replies.isNotEmpty()
|
||||
val reactionsChanged = reactions.isNotEmpty()
|
||||
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty()
|
||||
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() || bolt12Zaps.isNotEmpty()
|
||||
val boostsChanged = boosts.isNotEmpty()
|
||||
val editsChanged = edits.isNotEmpty()
|
||||
val timestampsChanged = timestamps.isNotEmpty()
|
||||
@@ -492,7 +510,8 @@ open class Note(
|
||||
zapPayments.keys +
|
||||
zapPayments.values.filterNotNull() +
|
||||
nutzaps.values.map { it.source } +
|
||||
onchainZaps.values.map { it.source }
|
||||
onchainZaps.values.map { it.source } +
|
||||
bolt12Zaps.values.map { it.source }
|
||||
|
||||
replies = listOf()
|
||||
reactions = mapOf()
|
||||
@@ -505,6 +524,7 @@ open class Note(
|
||||
onchainZaps = mapOf()
|
||||
onchainZapResolved = false
|
||||
nutzaps = mapOf()
|
||||
bolt12Zaps = mapOf()
|
||||
zapPayments = mapOf()
|
||||
zapsAmount = BigDecimal(0)
|
||||
relays = listOf()
|
||||
@@ -785,6 +805,74 @@ open class Note(
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerAddBolt12Zap(
|
||||
paymentHashHex: String,
|
||||
entry: Bolt12ZapEntry,
|
||||
): Boolean =
|
||||
syncLock.withLock {
|
||||
val existing = bolt12Zaps[paymentHashHex]
|
||||
val merged =
|
||||
if (existing == null) {
|
||||
entry
|
||||
} else if (entry.cryptoVerified != existing.cryptoVerified) {
|
||||
// Cross-verification dedup. Only a crypto-verified proof has an amount
|
||||
// bound by the invoice signature; an unverified one (a compressed proof,
|
||||
// or an offer we can't bind) carries a self-chosen amount and payment
|
||||
// hash. Because a payer proof publishes its `proof_preimage`, once any
|
||||
// BOLT12 zap is public anyone can replay that preimage in a fresh,
|
||||
// unverifiable proof with a 1-msat amount and the same payment hash — so
|
||||
// if we let the lower amount win here and inherited the verified flag, a
|
||||
// griefer could drive the counted total to ~zero and mislabel a fake
|
||||
// amount as verified. Keep the verified entry outright, whichever amount
|
||||
// it carries; never let an unverified duplicate override it.
|
||||
if (entry.cryptoVerified) entry else existing
|
||||
} else {
|
||||
// Same verification status: dedup by the settled payment and, if amounts
|
||||
// differ, keep the LOWER — so a re-publish with a bigger amount tag can't
|
||||
// inflate the total (NIP-XX). Both share the same cryptoVerified flag.
|
||||
if (entry.amountMillisats < existing.amountMillisats) entry else existing
|
||||
}
|
||||
if (merged == existing) return@withLock false
|
||||
bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, merged)
|
||||
return@withLock true
|
||||
}
|
||||
|
||||
private fun innerRemoveBolt12ZapBySource(source: Note): Boolean =
|
||||
syncLock.withLock {
|
||||
val newMap = bolt12Zaps.filterValues { it.source != source }
|
||||
if (newMap.size == bolt12Zaps.size) return@withLock false
|
||||
bolt12Zaps = newMap
|
||||
return@withLock true
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a NIP-XX BOLT12 zap targeting this note. [source] is the kind:9736
|
||||
* event's own note — `source.author` is the payer shown in the reactions
|
||||
* gallery and notifications. [amountMillisats] and [cryptoVerified] come from
|
||||
* the synchronous [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator]
|
||||
* verdict; the caller MUST only call this for a `Valid` result. Deduplicated by
|
||||
* [paymentHashHex] (the proof's `invoice_payment_hash`).
|
||||
*/
|
||||
fun addBolt12Zap(
|
||||
source: Note,
|
||||
paymentHashHex: String,
|
||||
amountMillisats: Long,
|
||||
cryptoVerified: Boolean,
|
||||
) {
|
||||
if (innerAddBolt12Zap(paymentHashHex, Bolt12ZapEntry(source, amountMillisats, cryptoVerified))) {
|
||||
updateZapTotal()
|
||||
flowSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
/** Detach every BOLT12-zap entry contributed by [source] — used when the source note is pruned or deleted. */
|
||||
fun removeBolt12ZapBySource(source: Note) {
|
||||
if (innerRemoveBolt12ZapBySource(source)) {
|
||||
updateZapTotal()
|
||||
flowSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerAddZapPayment(
|
||||
zapPaymentRequest: Note,
|
||||
zapPayment: Note?,
|
||||
@@ -978,6 +1066,7 @@ open class Note(
|
||||
// zap requests).
|
||||
if (isNutzappedBy(user, afterTimeInSeconds)) return true
|
||||
if (isOnchainZappedBy(user, afterTimeInSeconds)) return true
|
||||
if (isBolt12ZappedBy(user, afterTimeInSeconds)) return true
|
||||
|
||||
val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps)
|
||||
if (first) return true
|
||||
@@ -1005,6 +1094,15 @@ open class Note(
|
||||
entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds
|
||||
}
|
||||
|
||||
private fun isBolt12ZappedBy(
|
||||
user: User,
|
||||
afterTimeInSeconds: Long,
|
||||
): Boolean =
|
||||
bolt12Zaps.values.any { entry ->
|
||||
val sourceEvent = entry.source.event ?: return@any false
|
||||
entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra sats to add on top of [zapsAmount] for the reaction-row
|
||||
* counter when the signed-in user has outgoing onchain zaps on
|
||||
@@ -1091,6 +1189,18 @@ open class Note(
|
||||
sumOfAmounts += BigDecimal(entry.claimedSats)
|
||||
}
|
||||
|
||||
// NIP-XX BOLT12 zaps — count only the crypto-verified ones. An unverified
|
||||
// (compressed, or offer-unbindable) proof carries a self-chosen preimage,
|
||||
// amount, and payment hash with no settled-payment guarantee, so counting it
|
||||
// would let anyone inflate a note's total for free. Unverified entries stay
|
||||
// stored and shown (dimmed) but never sum. Divide in BigDecimal so fractional
|
||||
// sats survive and match the millisat-native lightning column above.
|
||||
bolt12Zaps.values.forEach { entry ->
|
||||
if (entry.cryptoVerified) {
|
||||
sumOfAmounts += BigDecimal(entry.amountMillisats).divide(BigDecimal(1000))
|
||||
}
|
||||
}
|
||||
|
||||
zapsAmount = sumOfAmounts
|
||||
}
|
||||
|
||||
@@ -1258,7 +1368,8 @@ open class Note(
|
||||
|
||||
fun hasZapped(loggedIn: User): Boolean =
|
||||
zaps.any { it.key.author == loggedIn } ||
|
||||
nutzaps.values.any { it.source.author == loggedIn }
|
||||
nutzaps.values.any { it.source.author == loggedIn } ||
|
||||
bolt12Zaps.values.any { it.source.author == loggedIn }
|
||||
|
||||
fun hasReacted(
|
||||
loggedIn: User,
|
||||
@@ -1323,6 +1434,10 @@ open class Note(
|
||||
note.addOnchainZap(entry.source, txid, entry.claimedSats, entry.verifiedSats, entry.status)
|
||||
entry.source.replyTo = entry.source.replyTo?.replace(this, note)
|
||||
}
|
||||
bolt12Zaps.forEach { (paymentHash, entry) ->
|
||||
note.addBolt12Zap(entry.source, paymentHash, entry.amountMillisats, entry.cryptoVerified)
|
||||
entry.source.replyTo = entry.source.replyTo?.replace(this, note)
|
||||
}
|
||||
zapPayments.forEach {
|
||||
note.addZapPayment(it.key, it.value)
|
||||
it.key.replyTo = it.key.replyTo?.replace(this, note)
|
||||
@@ -1343,6 +1458,7 @@ open class Note(
|
||||
zaps = emptyMap()
|
||||
nutzaps = emptyMap()
|
||||
onchainZaps = emptyMap()
|
||||
bolt12Zaps = emptyMap()
|
||||
zapPayments = emptyMap()
|
||||
labels = emptyMap()
|
||||
zapsAmount = BigDecimal(0)
|
||||
|
||||
+2
-1
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@@ -182,7 +183,7 @@ class ThreadAssembler(
|
||||
*/
|
||||
fun Event?.anchorsItsOwnThread(): Boolean =
|
||||
when (this) {
|
||||
is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> true
|
||||
is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent, is Bolt12ZapEvent -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
@@ -77,6 +78,8 @@ class User(
|
||||
|
||||
val nutzapInfoNote: Note = context.addressableNote(NutzapInfoEvent.createAddress(pubkeyHex))
|
||||
|
||||
val bolt12OfferListNote: Note = context.addressableNote(Bolt12OfferListEvent.createAddress(pubkeyHex))
|
||||
|
||||
// These objects are designed to keep the cache
|
||||
// while this user obj is being used anywhere.
|
||||
//
|
||||
@@ -113,6 +116,12 @@ class User(
|
||||
|
||||
fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent
|
||||
|
||||
/** This user's published BOLT12 offer list (NIP-XX kind 10058), or null if none seen. */
|
||||
fun bolt12OfferList() = bolt12OfferListNote.event as? Bolt12OfferListEvent
|
||||
|
||||
/** The canonical raw BOLT12 offers (`lno1...`) this user accepts, empty when none published. */
|
||||
fun bolt12Offers(): List<String> = bolt12OfferList()?.offers().orEmpty()
|
||||
|
||||
/** True when this user has published a kind:10019 with a P2PK pubkey. */
|
||||
fun acceptsNutzaps(): Boolean = nutzapInfo()?.p2pkPubkey() != null
|
||||
|
||||
|
||||
+4
-1
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Nostr event kinds that can generate a notification when they tag the
|
||||
@@ -70,6 +71,7 @@ object NotificationKinds {
|
||||
NutzapEvent.KIND, // 9321 — NIP-61 Cashu nutzap
|
||||
LnZapEvent.KIND, // 9735 — NIP-57 zap receipt
|
||||
OnchainZapEvent.KIND, // 8333 — onchain zap
|
||||
Bolt12ZapEvent.KIND, // 9736 — NIP-XX BOLT12 zap
|
||||
// NIP-17 file-header messages (encrypted file DMs)
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
)
|
||||
@@ -114,7 +116,8 @@ object NotificationKinds {
|
||||
if (event.pubKey == myPubKeyHex &&
|
||||
event !is LnZapEvent &&
|
||||
event !is NutzapEvent &&
|
||||
event !is OnchainZapEvent
|
||||
event !is OnchainZapEvent &&
|
||||
event !is Bolt12ZapEvent
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
+19
-6
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -144,6 +145,11 @@ class LiveStreamTopZappersViewModel(
|
||||
goalContributions[it.receiptId] = it
|
||||
}
|
||||
}
|
||||
goal?.bolt12Zaps?.values?.forEach { entry ->
|
||||
contributionFromStreamZap(entry.source)?.let {
|
||||
goalContributions[it.receiptId] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,12 +158,19 @@ class LiveStreamTopZappersViewModel(
|
||||
_topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit)
|
||||
}
|
||||
|
||||
private fun contributionFromStreamZap(note: Note): ZapContribution? {
|
||||
val ev = note.event as? LnZapEvent ?: return null
|
||||
val request = ev.zapRequest ?: return null
|
||||
val sats = ev.amount()?.toLong() ?: return null
|
||||
return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
private fun contributionFromStreamZap(note: Note): ZapContribution? =
|
||||
when (val ev = note.event) {
|
||||
is LnZapEvent -> {
|
||||
val request = ev.zapRequest ?: return null
|
||||
val sats = ev.amount()?.toLong() ?: return null
|
||||
ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
is Bolt12ZapEvent -> {
|
||||
val sats = ev.amount()?.div(1000) ?: return null
|
||||
ZapContribution(note.idHex, ev.payer() ?: ev.pubKey, ev.isAnonymous(), sats)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun contributionFromGoalZap(
|
||||
zapRequestNote: Note,
|
||||
|
||||
+27
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* One in-flight kind-9735 zap to render as a floating overlay on the
|
||||
@@ -61,6 +62,20 @@ data class RoomZap(
|
||||
amountSats = event.amount?.toLong(),
|
||||
createdAtSec = event.createdAt,
|
||||
)
|
||||
|
||||
/**
|
||||
* Project a kind-9736 [Bolt12ZapEvent] into a [RoomZap]. The zapper is
|
||||
* the `P` payer tag (or the event pubkey for an anonymous zap); the amount
|
||||
* is the `amount` tag in millisats converted to sats.
|
||||
*/
|
||||
fun from(event: Bolt12ZapEvent): RoomZap =
|
||||
RoomZap(
|
||||
eventId = event.id,
|
||||
sourcePubkey = event.payer() ?: event.pubKey,
|
||||
targetPubkey = event.recipient(),
|
||||
amountSats = event.amount()?.div(1000),
|
||||
createdAtSec = event.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +97,19 @@ class RoomZapsAggregator {
|
||||
event: LnZapEvent,
|
||||
nowSec: Long,
|
||||
windowSec: Long,
|
||||
): Map<String, List<RoomZap>> = apply(RoomZap.from(event), nowSec, windowSec)
|
||||
|
||||
fun apply(
|
||||
event: Bolt12ZapEvent,
|
||||
nowSec: Long,
|
||||
windowSec: Long,
|
||||
): Map<String, List<RoomZap>> = apply(RoomZap.from(event), nowSec, windowSec)
|
||||
|
||||
private fun apply(
|
||||
incoming: RoomZap,
|
||||
nowSec: Long,
|
||||
windowSec: Long,
|
||||
): Map<String, List<RoomZap>> {
|
||||
val incoming = RoomZap.from(event)
|
||||
// Dedup: a relay re-delivery (or LocalCache.observeNotes's
|
||||
// full-list re-emit) of the same receipt must not stack.
|
||||
byEventId[incoming.eventId] = incoming
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Locks the shared BOLT12 assembly surface amy drives: offer canonicalization,
|
||||
* offer decoding, and the kind:10058 offer-list round-trip.
|
||||
*/
|
||||
class Bolt12ZapActionsTest {
|
||||
// A minimal, well-formed offer: amount(8) + description(10), TLV-ascending.
|
||||
private fun sampleOffer(amountMsat: Long): String {
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(Bolt12Offer.TYPE_AMOUNT, Bolt12Values.tu64ToBytes(amountMsat)),
|
||||
TlvRecord(Bolt12Offer.TYPE_DESCRIPTION, "coffee".encodeToByteArray()),
|
||||
)
|
||||
return Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, TlvStream(records).encode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalOfferAcceptsAValidOfferAndRejectsJunk() {
|
||||
val offer = sampleOffer(21_000L)
|
||||
assertEquals(offer.lowercase(), Bolt12ZapActions.canonicalOfferOrNull(offer))
|
||||
assertNull(Bolt12ZapActions.canonicalOfferOrNull("not-an-offer"))
|
||||
assertNull(Bolt12ZapActions.canonicalOfferOrNull("lnbc10n1xxx")) // bolt11, not bolt12
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeOfferReadsAmountAndDescription() {
|
||||
val fields = Bolt12ZapActions.decodeOffer(sampleOffer(21_000L))
|
||||
assertTrue(fields != null)
|
||||
assertEquals(21_000L, fields["amount_msat"])
|
||||
assertEquals("coffee", fields["description"])
|
||||
assertEquals(false, fields["has_paths"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeOfferReturnsNullForAProof() {
|
||||
assertNull(Bolt12ZapActions.decodeOffer("lnp1garbage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeOfferOmitsAMalformedAmountWithoutThrowing() {
|
||||
// The TLV stream parses (ascending type, valid length), but the amount value is
|
||||
// 9 bytes — too long for a tu64. Reading it must not throw: the offer still
|
||||
// decodes, just without an `amount_msat`.
|
||||
val bad =
|
||||
Bolt12Bech32.encode(
|
||||
Bolt12Bech32.OFFER_HRP,
|
||||
TlvStream(listOf(TlvRecord(Bolt12Offer.TYPE_AMOUNT, ByteArray(9) { 1 }))).encode(),
|
||||
)
|
||||
val fields = Bolt12ZapActions.decodeOffer(bad)
|
||||
assertTrue(fields != null)
|
||||
assertNull(fields["amount_msat"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offerListRoundTrips() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val offers = listOf(sampleOffer(21_000L).lowercase(), sampleOffer(5_000L).lowercase())
|
||||
val event = Bolt12ZapActions.buildOfferList(signer, offers)
|
||||
|
||||
assertEquals(10058, event.kind)
|
||||
assertEquals(offers, event.offers())
|
||||
assertEquals(signer.pubKey, event.pubKey)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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 kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies how validated NIP-XX BOLT12 zaps fold into a Note's aggregate zap
|
||||
* accounting — the model contract the LocalCache ingest path and the reaction-row
|
||||
* counter depend on.
|
||||
*/
|
||||
class NoteBolt12ZapTest {
|
||||
private fun note(id: String) = Note(id)
|
||||
|
||||
@Test
|
||||
fun addsMillisatAmountsAsSatsToTheZapTotal() {
|
||||
val target = note("a".repeat(64))
|
||||
target.addBolt12Zap(note("b".repeat(64)), "hash1", amountMillisats = 21_000_000L, cryptoVerified = true)
|
||||
target.addBolt12Zap(note("c".repeat(64)), "hash2", amountMillisats = 1_000_000L, cryptoVerified = true)
|
||||
|
||||
// 21_000_000 + 1_000_000 millisats = 22_000 sats.
|
||||
assertEquals(22_000L, target.zapsAmount.toLong())
|
||||
assertEquals(2, target.bolt12Zaps.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicatesByPaymentHash() {
|
||||
val target = note("a".repeat(64))
|
||||
target.addBolt12Zap(note("b".repeat(64)), "samehash", amountMillisats = 21_000_000L, cryptoVerified = true)
|
||||
// A relay echo (or a re-publish from another source) of the same settled payment.
|
||||
target.addBolt12Zap(note("c".repeat(64)), "samehash", amountMillisats = 21_000_000L, cryptoVerified = true)
|
||||
|
||||
assertEquals(1, target.bolt12Zaps.size)
|
||||
assertEquals(21_000L, target.zapsAmount.toLong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsTheLowerAmountWhenTwoProofsShareAPaymentHash() {
|
||||
// NIP-XX: same proof identifier, differing amounts → count the LOWER, in either order.
|
||||
val a = note("a".repeat(64))
|
||||
a.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 21_000_000L, cryptoVerified = true)
|
||||
a.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
|
||||
assertEquals(1, a.bolt12Zaps.size)
|
||||
assertEquals(5_000L, a.zapsAmount.toLong())
|
||||
|
||||
val b = note("a".repeat(64))
|
||||
b.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
|
||||
b.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 21_000_000L, cryptoVerified = true)
|
||||
assertEquals(5_000L, b.zapsAmount.toLong(), "order must not change the counted amount")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aVerifiedEntryIsNotDowngradedByAnUnverifiedRepublish() {
|
||||
val target = note("a".repeat(64))
|
||||
target.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
|
||||
target.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = false)
|
||||
|
||||
assertEquals(1, target.bolt12Zaps.size)
|
||||
assertTrue(target.bolt12Zaps["h"]!!.cryptoVerified, "a verified entry must not be overwritten by an unverified one")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLowerUnverifiedRepublishCannotGriefAVerifiedTotal() {
|
||||
// A payer proof publishes its proof_preimage, so once a BOLT12 zap is public
|
||||
// anyone can replay the same payment hash in an UNVERIFIABLE (e.g. compressed)
|
||||
// proof carrying a self-chosen 1-msat amount. The lower-amount dedup MUST apply
|
||||
// only among validated proofs — an unverified duplicate can neither lower the
|
||||
// counted amount nor inherit the verified flag. Both arrival orders.
|
||||
val verifiedFirst = note("a".repeat(64))
|
||||
verifiedFirst.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
|
||||
verifiedFirst.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 1L, cryptoVerified = false)
|
||||
assertEquals(1, verifiedFirst.bolt12Zaps.size)
|
||||
assertTrue(verifiedFirst.bolt12Zaps["h"]!!.cryptoVerified)
|
||||
assertEquals(5_000L, verifiedFirst.zapsAmount.toLong(), "an unverified 1-msat replay must not grief the total down")
|
||||
|
||||
val unverifiedFirst = note("a".repeat(64))
|
||||
unverifiedFirst.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 1L, cryptoVerified = false)
|
||||
unverifiedFirst.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
|
||||
assertEquals(1, unverifiedFirst.bolt12Zaps.size)
|
||||
assertTrue(unverifiedFirst.bolt12Zaps["h"]!!.cryptoVerified)
|
||||
assertEquals(5_000L, unverifiedFirst.zapsAmount.toLong(), "order must not matter")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingBySourceDropsTheEntryAndUpdatesTheTotal() {
|
||||
val target = note("a".repeat(64))
|
||||
val source = note("b".repeat(64))
|
||||
target.addBolt12Zap(source, "h", amountMillisats = 7_000_000L, cryptoVerified = true)
|
||||
assertEquals(7_000L, target.zapsAmount.toLong())
|
||||
|
||||
target.removeBolt12ZapBySource(source)
|
||||
assertTrue(target.bolt12Zaps.isEmpty())
|
||||
assertEquals(0L, target.zapsAmount.toLong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearChildLinksDropsBolt12ZapsAndReturnsTheirSources() {
|
||||
val target = note("a".repeat(64))
|
||||
val source = note("b".repeat(64))
|
||||
target.addBolt12Zap(source, "h", amountMillisats = 3_000_000L, cryptoVerified = true)
|
||||
assertTrue(target.hasZapsBoostsOrReactions())
|
||||
|
||||
val removed = target.clearChildLinks()
|
||||
|
||||
assertTrue(source in removed, "the source note must be returned so the cache can prune it")
|
||||
assertTrue(target.bolt12Zaps.isEmpty())
|
||||
assertEquals(0L, target.zapsAmount.toLong())
|
||||
assertFalse(target.hasZapsBoostsOrReactions())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyCryptoVerifiedBolt12ZapsCountTowardTheTotal() {
|
||||
val target = note("a".repeat(64))
|
||||
target.addBolt12Zap(note("b".repeat(64)), "h1", amountMillisats = 2_000_000L, cryptoVerified = true)
|
||||
// An unverified (compressed / unbindable) proof is stored + shown but MUST NOT count —
|
||||
// its amount is self-chosen with no settled-payment guarantee.
|
||||
target.addBolt12Zap(note("c".repeat(64)), "h2", amountMillisats = 500_000L, cryptoVerified = false)
|
||||
|
||||
assertEquals(2, target.bolt12Zaps.size, "both are stored (the unverified one still renders, dimmed)")
|
||||
assertEquals(2_000L, target.zapsAmount.toLong(), "only the verified 2000-sat zap counts")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fractionalSatAmountsSurviveInTheTotal() {
|
||||
val target = note("a".repeat(64))
|
||||
// 1500 msat = 1.5 sat; two of them = 3 sat. Integer-dividing each first drops to 2.
|
||||
target.addBolt12Zap(note("b".repeat(64)), "h1", amountMillisats = 1_500L, cryptoVerified = true)
|
||||
target.addBolt12Zap(note("c".repeat(64)), "h2", amountMillisats = 1_500L, cryptoVerified = true)
|
||||
assertEquals(3L, target.zapsAmount.toLong(), "fractional sats must not be truncated per-entry")
|
||||
}
|
||||
}
|
||||
+11
@@ -45,6 +45,7 @@ import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker
|
||||
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
@@ -636,6 +637,16 @@ class NestViewModel(
|
||||
_recentZaps.value = zapsAgg.apply(event, nowSec, windowSec)
|
||||
}
|
||||
|
||||
/** Apply one kind-9736 BOLT12 zap to the same sliding-window aggregator. */
|
||||
fun onZapEvent(
|
||||
event: Bolt12ZapEvent,
|
||||
nowSec: Long,
|
||||
windowSec: Long = REACTION_WINDOW_SEC,
|
||||
) {
|
||||
if (closed) return
|
||||
_recentZaps.value = zapsAgg.apply(event, nowSec, windowSec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop zaps older than the staleness threshold. Platform layer
|
||||
* drives this on a 1-s tick — the same cadence as [evictReactions]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# BOLT12 zap proof verification — interop test vectors (follow-up)
|
||||
|
||||
Status: **done** (both work items shipped), with one standing upstream caveat.
|
||||
`Bolt12ProofVerifier` now reconstructs and fully verifies **compressed** BOLT12
|
||||
payer proofs — the selective-disclosure case real wallets emit — so they count as
|
||||
`cryptoVerified = true` (subject to the usual offer-binding rule). Verified
|
||||
byte-for-byte against the draft's own conformance vectors.
|
||||
|
||||
## What shipped
|
||||
|
||||
- **Vector-driven interop test.** `bolt12/payer-proof-test.json` from
|
||||
[lightning/bolts#1346] is vendored into `quartz/src/commonTest/resources/bolt12/`
|
||||
and driven by `Bolt12PayerProofVectorTest`: all 5 `valid_vectors` verify, all 23
|
||||
`invalid_vectors` are rejected, and the writer (`Bolt12ProofBuilder`) reproduces
|
||||
each valid vector's `proof_omitted_tlvs` / `proof_missing_hashes` /
|
||||
`proof_leaf_hashes` exactly. The vectors disproved two of our earlier guesses,
|
||||
now fixed:
|
||||
- the **nonce leaf** hashes the record's *type* bytes, not the full encoded TLV
|
||||
(`Bolt12Merkle.nonceLeafHash`);
|
||||
- the **proof signature** field name is `proof_signature`, not `signature`
|
||||
(`Bolt12ProofVerifier.PROOF_SIG_FIELD`).
|
||||
|
||||
- **Compressed-proof merkle reconstruction.** `Bolt12Merkle.reconstructRoot`
|
||||
rebuilds the invoice root from the disclosed `LnLeaf` hashes + `proof_leaf_hashes`
|
||||
(nonce leaves) + `proof_omitted_tlvs` markers + `proof_missing_hashes` (consumed
|
||||
post-order DFS smallest-to-largest). `invreq_metadata` (type 0) is always the
|
||||
implied first omitted leaf. The `isCompressed()` short-circuit is gone; every
|
||||
proof now goes through reconstruction.
|
||||
|
||||
## Standing caveat (upstream)
|
||||
|
||||
#1346 is still an unmerged draft. The TLV type numbers, signature digest tag
|
||||
strings, leaf/branch tags, and the invoice/proof field ranges track the current
|
||||
PR head (`vincenzopalazzo/bolts@1be97b2`) and MUST be re-checked if the spec
|
||||
changes before it merges. The vector test is the tripwire: refresh the resource
|
||||
from the merged BOLT and it will flag any drift.
|
||||
|
||||
## Not covered here
|
||||
|
||||
Offer↔recipient-identity binding is still out of scope — a verified proof only
|
||||
proves payment to the *embedded* offer, not that the offer belongs to the
|
||||
p-tagged recipient (see `Bolt12ZapValidator.isInvoiceBoundToOffer`).
|
||||
|
||||
[lightning/bolts#1346]: https://github.com/lightning/bolts/pull/1346
|
||||
@@ -303,6 +303,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
@@ -539,6 +541,8 @@ object KindNames {
|
||||
RelayAddMemberEvent.KIND to KindName("Relay Add Member", "43"),
|
||||
RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"),
|
||||
OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"),
|
||||
Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "XX"),
|
||||
Bolt12OfferListEvent.KIND to KindName("Bolt12 Offers", "XX"),
|
||||
PutUserEvent.KIND to KindName("Group Put User", "29"),
|
||||
RemoveUserEvent.KIND to KindName("Group Remove User", "29"),
|
||||
EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"),
|
||||
|
||||
+60
@@ -40,6 +40,10 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveParams
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams
|
||||
@@ -60,6 +64,7 @@ import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
@@ -85,6 +90,14 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
value.params?.let { put("params", serializePayInvoiceParams(it)) }
|
||||
}
|
||||
|
||||
is PayMethod -> {
|
||||
value.params?.let { put("params", serializePayParams(it)) }
|
||||
}
|
||||
|
||||
is ReceiveMethod -> {
|
||||
value.params?.let { put("params", serializeReceiveParams(it)) }
|
||||
}
|
||||
|
||||
is PayKeysendMethod -> {
|
||||
value.params?.let { put("params", serializePayKeysendParams(it)) }
|
||||
}
|
||||
@@ -138,6 +151,21 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializePayParams(params: PayParams): JsonObject =
|
||||
buildJsonObject {
|
||||
params.payment?.let { put("payment", it) }
|
||||
params.amount?.let { put("amount", it) }
|
||||
params.payer_note?.let { put("payer_note", it) }
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializeReceiveParams(params: ReceiveParams): JsonObject =
|
||||
buildJsonObject {
|
||||
params.amount?.let { put("amount", it) }
|
||||
params.description?.let { put("description", it) }
|
||||
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
|
||||
}
|
||||
|
||||
private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject =
|
||||
buildJsonObject {
|
||||
params.amount?.let { put("amount", it) }
|
||||
@@ -236,6 +264,8 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
|
||||
return when (method) {
|
||||
NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject)
|
||||
NwcMethod.PAY -> parsePay(jsonObject)
|
||||
NwcMethod.RECEIVE -> parseReceive(jsonObject)
|
||||
NwcMethod.PAY_KEYSEND -> parsePayKeysend(jsonObject)
|
||||
NwcMethod.MAKE_INVOICE -> parseMakeInvoice(jsonObject)
|
||||
NwcMethod.LOOKUP_INVOICE -> parseLookupInvoice(jsonObject)
|
||||
@@ -265,6 +295,36 @@ object Nip47RequestKSerializer : KSerializer<Request> {
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePay(json: JsonObject): PayMethod {
|
||||
val params = json["params"]?.jsonObject
|
||||
return PayMethod(
|
||||
params?.let {
|
||||
// contentOrNull / `as? JsonObject` treat an explicit JSON `null` as absent —
|
||||
// Jackson (JVM/Android) writes null-valued keys, so a native/iOS peer parsing
|
||||
// that output must not read `JsonNull` as the string "null" or crash on it.
|
||||
PayParams(
|
||||
payment = it["payment"]?.jsonPrimitive?.contentOrNull,
|
||||
amount = it["amount"]?.jsonPrimitive?.longOrNull,
|
||||
payer_note = it["payer_note"]?.jsonPrimitive?.contentOrNull,
|
||||
metadata = (it["metadata"] as? JsonObject)?.toAnyMap(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReceive(json: JsonObject): ReceiveMethod {
|
||||
val params = json["params"]?.jsonObject
|
||||
return ReceiveMethod(
|
||||
params?.let {
|
||||
ReceiveParams(
|
||||
amount = it["amount"]?.jsonPrimitive?.longOrNull,
|
||||
description = it["description"]?.jsonPrimitive?.contentOrNull,
|
||||
metadata = (it["metadata"] as? JsonObject)?.toAnyMap(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePayKeysend(json: JsonObject): PayKeysendMethod {
|
||||
val params = json["params"]?.jsonObject
|
||||
return PayKeysendMethod(
|
||||
|
||||
+77
@@ -37,6 +37,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
|
||||
@@ -53,6 +55,7 @@ import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -81,6 +84,14 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
|
||||
value.result?.let { put("result", serializePayInvoiceResult(it)) }
|
||||
}
|
||||
|
||||
is PaySuccessResponse -> {
|
||||
value.result?.let { put("result", serializePayResult(it)) }
|
||||
}
|
||||
|
||||
is ReceiveSuccessResponse -> {
|
||||
value.result?.let { put("result", serializeReceiveResult(it)) }
|
||||
}
|
||||
|
||||
is PayInvoiceErrorResponse -> {
|
||||
value.error?.let { put("error", serializePayInvoiceErrorParams(it)) }
|
||||
}
|
||||
@@ -155,6 +166,28 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
|
||||
error.message?.let { put("message", it) }
|
||||
}
|
||||
|
||||
private fun serializePayResult(result: PaySuccessResponse.PayResult): JsonObject =
|
||||
buildJsonObject {
|
||||
result.transaction_id?.let { put("transaction_id", it) }
|
||||
result.state?.let { put("state", it) }
|
||||
result.instruction_type?.let { put("instruction_type", it) }
|
||||
result.amount?.let { put("amount", it) }
|
||||
result.fees_paid?.let { put("fees_paid", it) }
|
||||
result.payment_hash?.let { put("payment_hash", it) }
|
||||
result.preimage?.let { put("preimage", it) }
|
||||
result.payer_proof?.let { put("payer_proof", it) }
|
||||
result.txid?.let { put("txid", it) }
|
||||
result.failure_reason?.let { put("failure_reason", it) }
|
||||
result.created_at?.let { put("created_at", it) }
|
||||
result.settled_at?.let { put("settled_at", it) }
|
||||
}
|
||||
|
||||
private fun serializeReceiveResult(result: ReceiveSuccessResponse.ReceiveResult): JsonObject =
|
||||
buildJsonObject {
|
||||
result.bip321?.let { put("bip321", it) }
|
||||
result.transaction_id?.let { put("transaction_id", it) }
|
||||
}
|
||||
|
||||
private fun serializePayKeysendResult(result: PayKeysendSuccessResponse.PayKeysendResult): JsonObject =
|
||||
buildJsonObject {
|
||||
result.preimage?.let { put("preimage", it) }
|
||||
@@ -242,6 +275,14 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
|
||||
parsePayInvoiceSuccess(jsonObject)
|
||||
}
|
||||
|
||||
NwcMethod.PAY -> {
|
||||
parsePaySuccess(jsonObject)
|
||||
}
|
||||
|
||||
NwcMethod.RECEIVE -> {
|
||||
parseReceiveSuccess(jsonObject)
|
||||
}
|
||||
|
||||
NwcMethod.PAY_KEYSEND -> {
|
||||
parsePayKeysendSuccess(jsonObject)
|
||||
}
|
||||
@@ -387,6 +428,42 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePaySuccess(json: JsonObject): PaySuccessResponse {
|
||||
val result = json["result"]?.jsonObject
|
||||
return PaySuccessResponse(
|
||||
result?.let {
|
||||
// contentOrNull, not content: an explicit JSON `null` (which Jackson writes
|
||||
// for every null field) must read back as a real null, not the string "null".
|
||||
PaySuccessResponse.PayResult(
|
||||
transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull,
|
||||
state = it["state"]?.jsonPrimitive?.contentOrNull,
|
||||
instruction_type = it["instruction_type"]?.jsonPrimitive?.contentOrNull,
|
||||
amount = it["amount"]?.jsonPrimitive?.longOrNull,
|
||||
fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull,
|
||||
payment_hash = it["payment_hash"]?.jsonPrimitive?.contentOrNull,
|
||||
preimage = it["preimage"]?.jsonPrimitive?.contentOrNull,
|
||||
payer_proof = it["payer_proof"]?.jsonPrimitive?.contentOrNull,
|
||||
txid = it["txid"]?.jsonPrimitive?.contentOrNull,
|
||||
failure_reason = it["failure_reason"]?.jsonPrimitive?.contentOrNull,
|
||||
created_at = it["created_at"]?.jsonPrimitive?.longOrNull,
|
||||
settled_at = it["settled_at"]?.jsonPrimitive?.longOrNull,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReceiveSuccess(json: JsonObject): ReceiveSuccessResponse {
|
||||
val result = json["result"]?.jsonObject
|
||||
return ReceiveSuccessResponse(
|
||||
result?.let {
|
||||
ReceiveSuccessResponse.ReceiveResult(
|
||||
bip321 = it["bip321"]?.jsonPrimitive?.contentOrNull,
|
||||
transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse {
|
||||
val result = json["result"]?.jsonObject
|
||||
return PayKeysendSuccessResponse(
|
||||
|
||||
+4
@@ -33,6 +33,10 @@ enum class NwcErrorCode {
|
||||
BAD_REQUEST,
|
||||
NOT_FOUND,
|
||||
EXPIRED,
|
||||
|
||||
// nostr-wallet-connect/nwc#2 — pay/receive payment-instruction errors.
|
||||
UNSUPPORTED_PAYMENT_INSTRUCTION,
|
||||
UNSUPPORTED_NETWORK,
|
||||
OTHER,
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -34,4 +34,8 @@ object NwcMethod {
|
||||
const val MAKE_HOLD_INVOICE = "make_hold_invoice"
|
||||
const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice"
|
||||
const val SETTLE_HOLD_INVOICE = "settle_hold_invoice"
|
||||
|
||||
// nostr-wallet-connect/nwc#2 — generalized payment instructions (BOLT12/BIP321).
|
||||
const val PAY = "pay"
|
||||
const val RECEIVE = "receive"
|
||||
}
|
||||
|
||||
+42
@@ -47,6 +47,48 @@ class PayInvoiceMethod(
|
||||
}
|
||||
}
|
||||
|
||||
// pay (nostr-wallet-connect/nwc#2) — settle any BIP321 payment instruction
|
||||
// (bolt11 `lightning=`, BOLT12 `lno=`, or on-chain). `payment` is the BIP321 URI;
|
||||
// `amount` (msats) is required only when the instruction has no amount; `payer_note`
|
||||
// is delivered to the payee when the instruction supports payer messages (for a
|
||||
// BOLT12 zap this carries `nostr:nipXX:<intent-id>`).
|
||||
class PayParams(
|
||||
var payment: String? = null,
|
||||
var amount: Long? = null,
|
||||
var payer_note: String? = null,
|
||||
var metadata: Map<String, Any?>? = null,
|
||||
)
|
||||
|
||||
class PayMethod(
|
||||
var params: PayParams? = null,
|
||||
) : Request(NwcMethod.PAY) {
|
||||
companion object {
|
||||
fun create(
|
||||
payment: String,
|
||||
amount: Long? = null,
|
||||
payerNote: String? = null,
|
||||
): PayMethod = PayMethod(PayParams(payment, amount, payerNote))
|
||||
}
|
||||
}
|
||||
|
||||
// receive (nostr-wallet-connect/nwc#2) — mint a payment instruction to be paid.
|
||||
class ReceiveParams(
|
||||
var amount: Long? = null,
|
||||
var description: String? = null,
|
||||
var metadata: Map<String, Any?>? = null,
|
||||
)
|
||||
|
||||
class ReceiveMethod(
|
||||
var params: ReceiveParams? = null,
|
||||
) : Request(NwcMethod.RECEIVE) {
|
||||
companion object {
|
||||
fun create(
|
||||
amount: Long? = null,
|
||||
description: String? = null,
|
||||
): ReceiveMethod = ReceiveMethod(ReceiveParams(amount, description))
|
||||
}
|
||||
}
|
||||
|
||||
// pay_keysend
|
||||
class PayKeysendParams(
|
||||
var amount: Long? = null,
|
||||
|
||||
+32
@@ -68,6 +68,38 @@ class PayInvoiceErrorResponse(
|
||||
override fun errorMessage() = error?.message
|
||||
}
|
||||
|
||||
// pay success response (nostr-wallet-connect/nwc#2). For a settled BOLT12 payment
|
||||
// `payer_proof` carries the `lnp1...` proof — the input a kind:9736 BOLT12 zap needs.
|
||||
// It is best-effort ("optional if unavailable"), so callers must tolerate its absence.
|
||||
class PaySuccessResponse(
|
||||
val result: PayResult? = null,
|
||||
) : Response(NwcMethod.PAY) {
|
||||
class PayResult(
|
||||
val transaction_id: String? = null,
|
||||
val state: String? = null,
|
||||
val instruction_type: String? = null,
|
||||
val amount: Long? = null,
|
||||
val fees_paid: Long? = null,
|
||||
val payment_hash: String? = null,
|
||||
val preimage: String? = null,
|
||||
val payer_proof: String? = null,
|
||||
val txid: String? = null,
|
||||
val failure_reason: String? = null,
|
||||
val created_at: Long? = null,
|
||||
val settled_at: Long? = null,
|
||||
)
|
||||
}
|
||||
|
||||
// receive success response (nostr-wallet-connect/nwc#2).
|
||||
class ReceiveSuccessResponse(
|
||||
val result: ReceiveResult? = null,
|
||||
) : Response(NwcMethod.RECEIVE) {
|
||||
class ReceiveResult(
|
||||
val bip321: String? = null,
|
||||
val transaction_id: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
// pay_keysend success response
|
||||
class PayKeysendSuccessResponse(
|
||||
val result: PayKeysendResult? = null,
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
|
||||
|
||||
/**
|
||||
* BOLT12 bech32 codec.
|
||||
*
|
||||
* BOLT12 reuses the bech32 character set and 8-to-5-bit conversion but differs
|
||||
* from BIP-173 in two ways this object handles:
|
||||
*
|
||||
* 1. **No checksum and no length limit.** Offers and proofs can be far longer
|
||||
* than the 90-char BIP-173 cap and carry no trailing 6-char checksum, so we
|
||||
* decode with [Bech32.decodeBytes] in `noChecksum` mode. (The bech32 data
|
||||
* alphabet excludes `1`, so the single `1` separating the human-readable
|
||||
* prefix from the data is unambiguous even for long strings.)
|
||||
* 2. **`+` continuations.** For transport, a long string may be split with `+`
|
||||
* separators optionally surrounded by whitespace (`lno1abc+ def`). The
|
||||
* canonical form removes every `+` and whitespace character — see
|
||||
* [canonicalize]. The NIP stores only canonical offers/proofs, but callers
|
||||
* should canonicalize any externally-sourced string before use.
|
||||
*
|
||||
* See https://github.com/lightning/bolts/blob/master/12-offer-encoding.md
|
||||
*/
|
||||
object Bolt12Bech32 {
|
||||
/** Human-readable prefix of a BOLT12 offer. */
|
||||
const val OFFER_HRP = "lno"
|
||||
|
||||
/** Human-readable prefix of a BOLT12 payer proof (lightning/bolts#1346). */
|
||||
const val PAYER_PROOF_HRP = "lnp"
|
||||
|
||||
/**
|
||||
* Removes BOLT12 `+` continuations and all whitespace and lowercases the
|
||||
* result, producing the canonical raw form the NIP stores and compares.
|
||||
*/
|
||||
fun canonicalize(raw: String): String {
|
||||
val sb = StringBuilder(raw.length)
|
||||
for (c in raw) {
|
||||
if (c == '+' || c == ' ' || c == '\t' || c == '\n' || c == '\r') continue
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.toString().lowercase()
|
||||
}
|
||||
|
||||
// O(1) bech32 data-alphabet membership, indexed by char code (ASCII only).
|
||||
private val IS_BECH32_CHAR =
|
||||
BooleanArray(128).also { table ->
|
||||
for (c in Bech32.ALPHABET) table[c.code] = true
|
||||
}
|
||||
|
||||
private fun isBech32Char(c: Char): Boolean = c.code < 128 && IS_BECH32_CHAR[c.code]
|
||||
|
||||
private fun hasHrp(
|
||||
canonical: String,
|
||||
hrp: String,
|
||||
): Boolean {
|
||||
val prefix = "${hrp}1"
|
||||
if (canonical.length <= prefix.length) return false
|
||||
if (!canonical.startsWith(prefix)) return false
|
||||
// every data char must be in the bech32 alphabet
|
||||
for (i in prefix.length until canonical.length) {
|
||||
if (!isBech32Char(canonical[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** True when [canonical] is a syntactically well-formed canonical `lno1...` offer. */
|
||||
fun isOffer(canonical: String) = hasHrp(canonical, OFFER_HRP)
|
||||
|
||||
/** True when [canonical] is a syntactically well-formed canonical `lnp1...` payer proof. */
|
||||
fun isPayerProof(canonical: String) = hasHrp(canonical, PAYER_PROOF_HRP)
|
||||
|
||||
/**
|
||||
* Decodes a BOLT12 bech32 string (offer or proof) into its raw TLV-stream
|
||||
* bytes, first canonicalizing it. Optionally asserts the human-readable
|
||||
* prefix. Throws [IllegalArgumentException] on malformed input or a prefix
|
||||
* mismatch.
|
||||
*/
|
||||
fun decodeToBytes(
|
||||
raw: String,
|
||||
expectedHrp: String? = null,
|
||||
): ByteArray {
|
||||
val (hrp, bytes, _) = Bech32.decodeBytes(canonicalize(raw), noChecksum = true)
|
||||
if (expectedHrp != null) {
|
||||
require(hrp == expectedHrp) { "Expected BOLT12 prefix $expectedHrp but obtained $hrp" }
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
fun decodeToBytesOrNull(
|
||||
raw: String,
|
||||
expectedHrp: String? = null,
|
||||
): ByteArray? =
|
||||
try {
|
||||
decodeToBytes(raw, expectedHrp)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes raw TLV-stream [bytes] as a canonical (no `+` continuation) BOLT12
|
||||
* bech32 string with the given [hrp]. Primarily an interop/testing helper —
|
||||
* production only ever decodes offers and proofs produced by wallets.
|
||||
*/
|
||||
fun encode(
|
||||
hrp: String,
|
||||
bytes: ByteArray,
|
||||
): String = Bech32.encodeBytes(hrp, bytes, Bech32.Encoding.Beck32WithoutChecksum)
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* BOLT12 "Signature Calculation" merkle root and the message digest that BOLT12
|
||||
* signatures (invoice `signature`, payer `proof_signature`) are computed over.
|
||||
*
|
||||
* Definitions (from 12-offer-encoding.md, matching the CLN/LDK reference
|
||||
* implementations):
|
||||
*
|
||||
* - Tagged hash: `H(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)`.
|
||||
* - For each signable TLV record (types outside the 240..1000 signature range),
|
||||
* two leaves are produced, in TLV-ascending order:
|
||||
* 1. `H("LnLeaf", tlv)` — over the record's full `type || length || value`.
|
||||
* 2. `H("LnNonce" || first-tlv, type)` — over just the record's `type` field
|
||||
* (its BigSize bytes), where `first-tlv` is the encoded bytes of the
|
||||
* numerically-first signable record. (This is the subtle bit the BOLT
|
||||
* spec's worked example pins down: the nonce leaf hashes the *type*, not
|
||||
* the whole record.)
|
||||
* - Inner nodes: `H("LnBranch", lesser || greater)` (children sorted by their
|
||||
* 32-byte value). Odd nodes are promoted unchanged to the next level.
|
||||
* - The signature message digest is `H("lightning" || messagename || fieldname,
|
||||
* merkle_root)`, verified with BIP-340 against the signing key.
|
||||
*
|
||||
* [rootHash] computes the root over a **fully-disclosed** record set.
|
||||
* [reconstructRoot] rebuilds it from a selectively-disclosed payer proof
|
||||
* (lightning/bolts#1346), pulling omitted subtrees from `proof_missing_hashes`.
|
||||
* Both are exercised byte-for-byte against the spec's `payer-proof-test.json`
|
||||
* vectors.
|
||||
*/
|
||||
object Bolt12Merkle {
|
||||
private val LN_NONCE = "LnNonce".encodeToByteArray()
|
||||
|
||||
// The "LnLeaf" and "LnBranch" tags are constants, so their SHA-256 (the inner
|
||||
// hash of a tagged hash) is precomputed once instead of per leaf/branch. The
|
||||
// "LnNonce" tag isn't constant (it embeds the first TLV), so it is hashed once
|
||||
// per rootHash() call rather than once per record.
|
||||
private val LN_LEAF_TAG_HASH = sha256("LnLeaf".encodeToByteArray())
|
||||
private val LN_BRANCH_TAG_HASH = sha256("LnBranch".encodeToByteArray())
|
||||
|
||||
/** Tagged hash `SHA256(SHA256(tag) || SHA256(tag) || msg)`. */
|
||||
fun taggedHash(
|
||||
tag: ByteArray,
|
||||
msg: ByteArray,
|
||||
): ByteArray = taggedHashPrecomputed(sha256(tag), msg)
|
||||
|
||||
/** Tagged hash when the caller already holds `SHA256(tag)` (the inner tag hash). */
|
||||
private fun taggedHashPrecomputed(
|
||||
tagHash: ByteArray,
|
||||
msg: ByteArray,
|
||||
): ByteArray = sha256(tagHash + tagHash + msg)
|
||||
|
||||
/**
|
||||
* Computes the merkle root over [signableRecords] — the caller must have
|
||||
* already excluded the signature elements (types 240..1000). Records must be
|
||||
* in ascending type order.
|
||||
*/
|
||||
fun rootHash(signableRecords: List<TlvRecord>): ByteArray {
|
||||
require(signableRecords.isNotEmpty()) { "Cannot compute a merkle root over zero records" }
|
||||
|
||||
val firstTlv = signableRecords.first().encoded
|
||||
val nonceTagHash = sha256(LN_NONCE + firstTlv)
|
||||
|
||||
var nodes = ArrayList<ByteArray>(signableRecords.size * 2)
|
||||
for (record in signableRecords) {
|
||||
nodes.add(taggedHashPrecomputed(LN_LEAF_TAG_HASH, record.encoded))
|
||||
nodes.add(taggedHashPrecomputed(nonceTagHash, BigSize.encode(record.type)))
|
||||
}
|
||||
|
||||
while (nodes.size > 1) {
|
||||
val next = ArrayList<ByteArray>((nodes.size + 1) / 2)
|
||||
var i = 0
|
||||
while (i < nodes.size) {
|
||||
if (i + 1 < nodes.size) {
|
||||
next.add(branch(nodes[i], nodes[i + 1]))
|
||||
i += 2
|
||||
} else {
|
||||
next.add(nodes[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
nodes = next
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* The `H("LnLeaf", tlv)` leaf hash of a single record (over its full encoded
|
||||
* `type || length || value`).
|
||||
*/
|
||||
fun leafHash(encodedRecord: ByteArray): ByteArray = taggedHashPrecomputed(LN_LEAF_TAG_HASH, encodedRecord)
|
||||
|
||||
/**
|
||||
* The `H("LnNonce" || first-tlv, type)` nonce leaf hash for a record of the
|
||||
* given [type], where [firstTlvEncoded] is the encoded numerically-first
|
||||
* signable record of the message.
|
||||
*/
|
||||
fun nonceLeafHash(
|
||||
firstTlvEncoded: ByteArray,
|
||||
type: Long,
|
||||
): ByteArray = taggedHash(LN_NONCE + firstTlvEncoded, BigSize.encode(type))
|
||||
|
||||
/**
|
||||
* The per-field merkle node — `branch(H("LnLeaf", tlv), nonceLeafHash)` — the
|
||||
* hash that sits directly above a single TLV's leaf pair. A compressed proof
|
||||
* supplies [nonceLeafHash] (from `proof_leaf_hashes`) because it depends on
|
||||
* `first-tlv`, which the proof may have omitted.
|
||||
*/
|
||||
fun fieldNode(
|
||||
encodedRecord: ByteArray,
|
||||
nonceLeafHash: ByteArray,
|
||||
): ByteArray = branch(leafHash(encodedRecord), nonceLeafHash)
|
||||
|
||||
/**
|
||||
* A node of the reconstruction/emission tree. A leaf carries a per-field node
|
||||
* hash ([leafHash], null only for a reader's omitted position) and whether it
|
||||
* was omitted; an inner node carries [left]/[right].
|
||||
*/
|
||||
private class TreeNode private constructor(
|
||||
val left: TreeNode?,
|
||||
val right: TreeNode?,
|
||||
val leafHash: ByteArray?,
|
||||
val omitted: Boolean,
|
||||
) {
|
||||
val isLeaf: Boolean get() = left == null
|
||||
|
||||
companion object {
|
||||
fun leaf(
|
||||
hash: ByteArray?,
|
||||
omitted: Boolean,
|
||||
) = TreeNode(null, null, hash, omitted)
|
||||
|
||||
fun fork(
|
||||
left: TreeNode,
|
||||
right: TreeNode,
|
||||
) = TreeNode(left, right, null, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds the pair-adjacent / promote-odd tree shape [rootHash] flattens. */
|
||||
private fun buildTree(leaves: List<TreeNode>): TreeNode {
|
||||
var level = leaves
|
||||
while (level.size > 1) {
|
||||
val next = ArrayList<TreeNode>((level.size + 1) / 2)
|
||||
var i = 0
|
||||
while (i < level.size) {
|
||||
if (i + 1 < level.size) {
|
||||
next.add(TreeNode.fork(level[i], level[i + 1]))
|
||||
i += 2
|
||||
} else {
|
||||
next.add(level[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
level = next
|
||||
}
|
||||
return level[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs the merkle root of a selectively-disclosed BOLT12 message
|
||||
* (lightning/bolts#1346). [leafNodeHashes] holds one entry per non-signature
|
||||
* leaf of the **original** message in ascending-type order: the per-field node
|
||||
* hash ([fieldNode]) for a disclosed field, or `null` for an omitted one. When
|
||||
* exactly one child of an inner node is entirely omitted, its hash is pulled
|
||||
* from [missingHashes] in the post-order depth-first (smallest-to-largest)
|
||||
* order the writer emitted them.
|
||||
*
|
||||
* Returns `null` if the tree cannot be closed with exactly the supplied
|
||||
* missing hashes (too few, too many, or an entirely-omitted root) — i.e. an
|
||||
* unverifiable proof.
|
||||
*/
|
||||
fun reconstructRoot(
|
||||
leafNodeHashes: List<ByteArray?>,
|
||||
missingHashes: List<ByteArray>,
|
||||
): ByteArray? {
|
||||
if (leafNodeHashes.isEmpty()) return null
|
||||
val tree = buildTree(leafNodeHashes.map { TreeNode.leaf(it, omitted = it == null) })
|
||||
var idx = 0
|
||||
var failed = false
|
||||
|
||||
fun eval(node: TreeNode): ByteArray? {
|
||||
if (failed) return null
|
||||
if (node.isLeaf) return node.leafHash
|
||||
val a = eval(node.left!!)
|
||||
val b = eval(node.right!!)
|
||||
return when {
|
||||
failed -> null
|
||||
a == null && b == null -> null
|
||||
a != null && b != null -> branch(a, b)
|
||||
else -> {
|
||||
if (idx >= missingHashes.size) {
|
||||
failed = true
|
||||
null
|
||||
} else {
|
||||
branch(a ?: b!!, missingHashes[idx++])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val root = eval(tree)
|
||||
return if (failed || root == null || idx != missingHashes.size) null else root
|
||||
}
|
||||
|
||||
/**
|
||||
* The writer dual of [reconstructRoot]: given every non-signature leaf's
|
||||
* per-field node hash ([leafNodeHashes]) and whether each was omitted
|
||||
* ([leafOmitted]), emits the `proof_missing_hashes` — the hash of each subtree
|
||||
* that is the lone entirely-omitted child of an inner node — in post-order
|
||||
* depth-first (smallest-to-largest) order. Used to mint payer proofs (tests +
|
||||
* interop harness), never on the hot verification path.
|
||||
*/
|
||||
fun emitMissingHashes(
|
||||
leafNodeHashes: List<ByteArray>,
|
||||
leafOmitted: List<Boolean>,
|
||||
): List<ByteArray> {
|
||||
require(leafNodeHashes.size == leafOmitted.size) { "leaf hash / omitted-flag size mismatch" }
|
||||
if (leafNodeHashes.isEmpty()) return emptyList()
|
||||
val tree = buildTree(leafNodeHashes.indices.map { TreeNode.leaf(leafNodeHashes[it], leafOmitted[it]) })
|
||||
val missing = ArrayList<ByteArray>()
|
||||
|
||||
// Returns the subtree hash and whether it is entirely omitted.
|
||||
fun emit(node: TreeNode): Pair<ByteArray, Boolean> {
|
||||
if (node.isLeaf) return node.leafHash!! to node.omitted
|
||||
val (a, aOmitted) = emit(node.left!!)
|
||||
val (b, bOmitted) = emit(node.right!!)
|
||||
if (aOmitted != bOmitted) missing.add(if (aOmitted) a else b)
|
||||
return branch(a, b) to (aOmitted && bOmitted)
|
||||
}
|
||||
|
||||
emit(tree)
|
||||
return missing
|
||||
}
|
||||
|
||||
private fun branch(
|
||||
a: ByteArray,
|
||||
b: ByteArray,
|
||||
): ByteArray =
|
||||
if (compareUnsigned(a, b) <= 0) {
|
||||
taggedHashPrecomputed(LN_BRANCH_TAG_HASH, a + b)
|
||||
} else {
|
||||
taggedHashPrecomputed(LN_BRANCH_TAG_HASH, b + a)
|
||||
}
|
||||
|
||||
/**
|
||||
* The 32-byte BIP-340 message digest a BOLT12 signature signs:
|
||||
* `H("lightning" || messagename || fieldname, merkleRoot)`.
|
||||
*/
|
||||
fun signatureDigest(
|
||||
messageName: String,
|
||||
fieldName: String,
|
||||
merkleRoot: ByteArray,
|
||||
): ByteArray = taggedHash("lightning$messageName$fieldName".encodeToByteArray(), merkleRoot)
|
||||
|
||||
private fun compareUnsigned(
|
||||
a: ByteArray,
|
||||
b: ByteArray,
|
||||
): Int {
|
||||
val min = minOf(a.size, b.size)
|
||||
for (i in 0 until min) {
|
||||
val ai = a[i].toInt() and 0xff
|
||||
val bi = b[i].toInt() and 0xff
|
||||
if (ai != bi) return ai - bi
|
||||
}
|
||||
return a.size - b.size
|
||||
}
|
||||
}
|
||||
+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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
/**
|
||||
* A parsed BOLT12 offer (`lno1...`). Exposes the fields NIP-XX cares about; the
|
||||
* full TLV stream is retained for callers that need more.
|
||||
*
|
||||
* See https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#offer-fields
|
||||
*/
|
||||
class Bolt12Offer(
|
||||
val tlv: TlvStream,
|
||||
) {
|
||||
/** `offer_issuer_id` (type 22): the 33-byte compressed node id that signs invoices for this offer, if present. */
|
||||
fun issuerId(): ByteArray? = tlv.value(TYPE_ISSUER_ID)
|
||||
|
||||
/** `offer_amount` (type 8): the offer amount in the offer currency's minimal unit (msats when no currency). */
|
||||
fun amount(): Long? = tlv.tu64(TYPE_AMOUNT)
|
||||
|
||||
/** `offer_currency` (type 6): ISO 4217 code; absent means bitcoin (msats). */
|
||||
fun currency(): String? = tlv.value(TYPE_CURRENCY)?.decodeToString()
|
||||
|
||||
/** `offer_description` (type 10). */
|
||||
fun description(): String? = tlv.value(TYPE_DESCRIPTION)?.decodeToString()
|
||||
|
||||
fun hasPaths(): Boolean = tlv.has(TYPE_PATHS)
|
||||
|
||||
companion object {
|
||||
const val TYPE_CHAINS = 2L
|
||||
const val TYPE_METADATA = 4L
|
||||
const val TYPE_CURRENCY = 6L
|
||||
const val TYPE_AMOUNT = 8L
|
||||
const val TYPE_DESCRIPTION = 10L
|
||||
const val TYPE_FEATURES = 12L
|
||||
const val TYPE_ABSOLUTE_EXPIRY = 14L
|
||||
const val TYPE_PATHS = 16L
|
||||
const val TYPE_ISSUER = 18L
|
||||
const val TYPE_QUANTITY_MAX = 20L
|
||||
const val TYPE_ISSUER_ID = 22L
|
||||
|
||||
fun parse(canonicalOffer: String): Bolt12Offer? {
|
||||
val bytes = Bolt12Bech32.decodeToBytesOrNull(canonicalOffer, Bolt12Bech32.OFFER_HRP) ?: return null
|
||||
val tlv = TlvStream.readOrNull(bytes) ?: return null
|
||||
return Bolt12Offer(tlv)
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
/**
|
||||
* A parsed BOLT12 payer proof (`lnp1...`), per lightning/bolts#1346.
|
||||
*
|
||||
* A payer proof copies the relevant offer / invoice-request / invoice TLV fields,
|
||||
* plus the invoice's `signature`, and adds the payer's own `proof_signature`, the
|
||||
* `proof_preimage`, and the merkle-reconstruction fields `proof_missing_hashes` /
|
||||
* `proof_leaf_hashes` / `proof_omitted_tlvs` that let [Bolt12ProofVerifier] rebuild
|
||||
* the invoice root even though `invreq_metadata` (and optionally other fields) are
|
||||
* withheld for privacy.
|
||||
*
|
||||
* The type numbers below are the ones proposed in lightning/bolts#1346 and MUST
|
||||
* be reconciled against the final merged BOLT if they change.
|
||||
*/
|
||||
class Bolt12PayerProof(
|
||||
val tlv: TlvStream,
|
||||
) {
|
||||
fun offerIssuerId(): ByteArray? = tlv.value(TYPE_OFFER_ISSUER_ID)
|
||||
|
||||
fun invreqPayerId(): ByteArray? = tlv.value(TYPE_INVREQ_PAYER_ID)
|
||||
|
||||
fun invreqPayerNote(): String? = tlv.value(TYPE_INVREQ_PAYER_NOTE)?.decodeToString()
|
||||
|
||||
fun invreqAmount(): Long? = tlv.tu64(TYPE_INVREQ_AMOUNT)
|
||||
|
||||
fun invoicePaymentHash(): ByteArray? = tlv.value(TYPE_INVOICE_PAYMENT_HASH)
|
||||
|
||||
fun invoiceAmount(): Long? = tlv.tu64(TYPE_INVOICE_AMOUNT)
|
||||
|
||||
fun invoiceNodeId(): ByteArray? = tlv.value(TYPE_INVOICE_NODE_ID)
|
||||
|
||||
fun invoiceSignature(): ByteArray? = tlv.value(TYPE_SIGNATURE)
|
||||
|
||||
fun proofSignature(): ByteArray? = tlv.value(TYPE_PROOF_SIGNATURE)
|
||||
|
||||
fun proofPreimage(): ByteArray? = tlv.value(TYPE_PROOF_PREIMAGE)
|
||||
|
||||
/** The optional free-text `proof_note` (1005) a challenge-response verifier may request. */
|
||||
fun proofNote(): String? = tlv.value(TYPE_PROOF_NOTE)?.decodeToString()
|
||||
|
||||
fun proofOmittedTlvs(): ByteArray? = tlv.value(TYPE_PROOF_OMITTED_TLVS)
|
||||
|
||||
fun proofMissingHashes(): ByteArray? = tlv.value(TYPE_PROOF_MISSING_HASHES)
|
||||
|
||||
fun proofLeafHashes(): ByteArray? = tlv.value(TYPE_PROOF_LEAF_HASHES)
|
||||
|
||||
/**
|
||||
* The `proof_omitted_tlvs` marker numbers (BigSize-decoded), empty when the
|
||||
* field is absent. Returns null if the bytes don't decode as a BigSize list —
|
||||
* a malformed proof the verifier must reject.
|
||||
*/
|
||||
fun omittedTlvMarkers(): List<Long>? {
|
||||
val bytes = proofOmittedTlvs() ?: return emptyList()
|
||||
return try {
|
||||
val reader = TlvReader(bytes)
|
||||
buildList { while (reader.remaining() > 0) add(reader.readBigSize()) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** `proof_leaf_hashes` split into 32-byte hashes, or null if not a whole multiple of 32. */
|
||||
fun leafHashList(): List<ByteArray>? = split32(proofLeafHashes())
|
||||
|
||||
/** `proof_missing_hashes` split into 32-byte hashes, or null if not a whole multiple of 32. */
|
||||
fun missingHashList(): List<ByteArray>? = split32(proofMissingHashes())
|
||||
|
||||
/**
|
||||
* The disclosed invoice (offer / invoice-request / invoice) records — the
|
||||
* fields whose types fall in the invoice ranges 1..239 and
|
||||
* 1_000_000_000..3_999_999_999 (lightning/bolts#1346), excluding the signature
|
||||
* elements and the proof-specific fields (240..999_999_999).
|
||||
*/
|
||||
fun invoiceIncludedRecords(): List<TlvRecord> = tlv.records.filter { isInvoiceField(it.type) }
|
||||
|
||||
/**
|
||||
* True when the proof omits some of the original invoice's TLV fields (i.e.
|
||||
* carries `proof_omitted_tlvs` markers). Every proof reconstructs the invoice
|
||||
* root through the merkle machinery — `invreq_metadata` (type 0) is always
|
||||
* omitted — but this flags the extra selective disclosure for display.
|
||||
*/
|
||||
fun isCompressed(): Boolean = omittedTlvMarkers()?.isNotEmpty() == true
|
||||
|
||||
/** The signable proof records (everything but the 240..1000 signature elements) — used for the payer proof signature. */
|
||||
fun proofSignableRecords(): List<TlvRecord> = tlv.records.filter { !it.isSignatureElement() }
|
||||
|
||||
/**
|
||||
* True when every field the BOLT12 crypto verification requires is present and
|
||||
* well-sized (lightning/bolts#1346 reader rules). `invreq_payer_note` is *not*
|
||||
* required here — the NIP-XX zap binding checks it separately in the validator.
|
||||
*/
|
||||
fun hasAllCryptoFields(): Boolean =
|
||||
invreqPayerId() != null &&
|
||||
invoicePaymentHash()?.size == 32 &&
|
||||
invoiceNodeId() != null &&
|
||||
invoiceSignature()?.size == 64 &&
|
||||
proofSignature()?.size == 64 &&
|
||||
proofPreimage()?.size == 32 &&
|
||||
tlv.has(TYPE_PROOF_MISSING_HASHES) &&
|
||||
tlv.has(TYPE_PROOF_LEAF_HASHES)
|
||||
|
||||
companion object {
|
||||
// Offer / invoice-request fields copied into the proof.
|
||||
const val TYPE_INVREQ_CHAIN = 80L
|
||||
const val TYPE_INVREQ_AMOUNT = 82L
|
||||
const val TYPE_INVREQ_FEATURES = 84L
|
||||
const val TYPE_INVREQ_QUANTITY = 86L
|
||||
const val TYPE_INVREQ_PAYER_ID = 88L
|
||||
const val TYPE_INVREQ_PAYER_NOTE = 89L
|
||||
const val TYPE_INVREQ_PATHS = 90L
|
||||
const val TYPE_INVREQ_BIP353_NAME = 91L
|
||||
const val TYPE_OFFER_ISSUER_ID = 22L
|
||||
|
||||
// Invoice fields copied into the proof.
|
||||
const val TYPE_INVOICE_PATHS = 160L
|
||||
const val TYPE_INVOICE_BLINDEDPAY = 162L
|
||||
const val TYPE_INVOICE_CREATED_AT = 164L
|
||||
const val TYPE_INVOICE_RELATIVE_EXPIRY = 166L
|
||||
const val TYPE_INVOICE_PAYMENT_HASH = 168L
|
||||
const val TYPE_INVOICE_AMOUNT = 170L
|
||||
const val TYPE_INVOICE_FALLBACKS = 172L
|
||||
const val TYPE_INVOICE_FEATURES = 174L
|
||||
const val TYPE_INVOICE_NODE_ID = 176L
|
||||
|
||||
// Signature elements (240..1000).
|
||||
const val TYPE_SIGNATURE = 240L
|
||||
const val TYPE_PROOF_SIGNATURE = 241L
|
||||
|
||||
// Payer-proof-specific fields (> 1000).
|
||||
const val TYPE_PROOF_PREIMAGE = 1001L
|
||||
const val TYPE_PROOF_OMITTED_TLVS = 1002L
|
||||
const val TYPE_PROOF_MISSING_HASHES = 1003L
|
||||
const val TYPE_PROOF_LEAF_HASHES = 1004L
|
||||
const val TYPE_PROOF_NOTE = 1005L
|
||||
|
||||
/**
|
||||
* The invoice TLV type ranges that participate in the invoice merkle tree,
|
||||
* per lightning/bolts#1346: 1..239 (offer/invreq/invoice) and
|
||||
* 1_000_000_000..3_999_999_999 (high/unknown invoice fields). Excludes the
|
||||
* signature range 240..1000 and the proof-specific fields 1001..999_999_999.
|
||||
*/
|
||||
fun isInvoiceField(type: Long): Boolean = type in 1L..239L || type in 1_000_000_000L..3_999_999_999L
|
||||
|
||||
private fun split32(bytes: ByteArray?): List<ByteArray>? {
|
||||
if (bytes == null) return null
|
||||
if (bytes.size % 32 != 0) return null
|
||||
return (0 until bytes.size / 32).map { bytes.copyOfRange(it * 32, it * 32 + 32) }
|
||||
}
|
||||
|
||||
fun parse(canonicalProof: String): Bolt12PayerProof? {
|
||||
val bytes = Bolt12Bech32.decodeToBytesOrNull(canonicalProof, Bolt12Bech32.PAYER_PROOF_HRP) ?: return null
|
||||
val tlv = TlvStream.readOrNull(bytes) ?: return null
|
||||
return Bolt12PayerProof(tlv)
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
/**
|
||||
* Mints a canonical BOLT12 `lnp1...` payer proof from a full invoice, per
|
||||
* lightning/bolts#1346 — the writer dual of [Bolt12ProofVerifier]. Production never
|
||||
* *creates* payer proofs (a paying wallet does); this exists for the self-consistent
|
||||
* test fixtures and the interop harness, and to prove the reader against
|
||||
* writer-produced streams. It reproduces the spec `payer-proof-test.json` vectors'
|
||||
* `proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` byte-for-byte.
|
||||
*/
|
||||
object Bolt12ProofBuilder {
|
||||
/**
|
||||
* One non-signature invoice TLV. [include] flags whether it is disclosed in the
|
||||
* proof; `invreq_metadata` (type 0) is always omitted regardless (it is the
|
||||
* hashing nonce and must never be revealed).
|
||||
*/
|
||||
class InvoiceField(
|
||||
val type: Long,
|
||||
val value: ByteArray,
|
||||
val include: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* @param invoiceFields ALL non-signature invoice TLVs in ascending type order,
|
||||
* including `invreq_metadata` (type 0).
|
||||
* @param preimage the `proof_preimage`; the caller ensures the invoice's
|
||||
* `invoice_payment_hash` (type 168) is its SHA-256 (or deliberately corrupt).
|
||||
* @param signInvoiceDigest signs the 32-byte invoice merkle digest (node key).
|
||||
* @param signProofDigest signs the 32-byte proof merkle digest (payer key).
|
||||
*/
|
||||
fun build(
|
||||
invoiceFields: List<InvoiceField>,
|
||||
preimage: ByteArray,
|
||||
proofNote: String? = null,
|
||||
signInvoiceDigest: (ByteArray) -> ByteArray,
|
||||
signProofDigest: (ByteArray) -> ByteArray,
|
||||
): String {
|
||||
val firstTlv = TlvRecord(invoiceFields.first().type, invoiceFields.first().value).encoded
|
||||
|
||||
// Invoice signature (240) over the full invoice merkle root.
|
||||
val invoiceRecords = invoiceFields.map { TlvRecord(it.type, it.value) }
|
||||
val invoiceRoot = Bolt12Merkle.rootHash(invoiceRecords)
|
||||
val invoiceSig = signInvoiceDigest(Bolt12Merkle.signatureDigest("invoice", INVOICE_SIG_FIELD, invoiceRoot))
|
||||
|
||||
// Disclosed invoice fields (type 0 is always withheld).
|
||||
val disclosed = invoiceFields.filter { it.include && it.type != 0L }
|
||||
val leafHashes = disclosed.map { Bolt12Merkle.nonceLeafHash(firstTlv, it.type) }
|
||||
val markers = renumberOmitted(invoiceFields)
|
||||
|
||||
val leafNodeHashes =
|
||||
invoiceFields.map { Bolt12Merkle.fieldNode(TlvRecord(it.type, it.value).encoded, Bolt12Merkle.nonceLeafHash(firstTlv, it.type)) }
|
||||
val leafOmitted = invoiceFields.map { !(it.include && it.type != 0L) }
|
||||
val missingHashes = Bolt12Merkle.emitMissingHashes(leafNodeHashes, leafOmitted)
|
||||
|
||||
// Records the proof signature (241) commits to: everything but the 240..1000
|
||||
// signature elements — disclosed invoice fields plus the proof-specific fields.
|
||||
val proofSignable =
|
||||
buildList {
|
||||
disclosed.forEach { add(TlvRecord(it.type, it.value)) }
|
||||
add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_PREIMAGE, preimage))
|
||||
if (markers.isNotEmpty()) add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_OMITTED_TLVS, encodeMarkers(markers)))
|
||||
add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_MISSING_HASHES, concat(missingHashes)))
|
||||
add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_LEAF_HASHES, concat(leafHashes)))
|
||||
if (proofNote != null) add(TlvRecord(Bolt12PayerProof.TYPE_PROOF_NOTE, proofNote.encodeToByteArray()))
|
||||
}.sortedBy { it.type }
|
||||
val proofRoot = Bolt12Merkle.rootHash(proofSignable)
|
||||
val proofSig = signProofDigest(Bolt12Merkle.signatureDigest("payer_proof", PROOF_SIG_FIELD, proofRoot))
|
||||
|
||||
val all =
|
||||
(
|
||||
proofSignable +
|
||||
TlvRecord(Bolt12PayerProof.TYPE_SIGNATURE, invoiceSig) +
|
||||
TlvRecord(Bolt12PayerProof.TYPE_PROOF_SIGNATURE, proofSig)
|
||||
).sortedBy { it.type }
|
||||
return Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, TlvStream(all).encode())
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal renumbering of omitted fields into `proof_omitted_tlvs` markers
|
||||
* (lightning/bolts#1346): `invreq_metadata` (type 0) is implied and never
|
||||
* emitted; every other omitted field takes the previous included type + 1, or
|
||||
* the next value after the previous marker (starting at 1), with the
|
||||
* 1_000_000_000 jump once the low range (≤239) is exhausted.
|
||||
*/
|
||||
private fun renumberOmitted(invoiceFields: List<InvoiceField>): List<Long> {
|
||||
val markers = ArrayList<Long>()
|
||||
var prevIncludedType: Long? = null
|
||||
for (f in invoiceFields) {
|
||||
if (f.include && f.type != 0L) {
|
||||
prevIncludedType = f.type
|
||||
continue
|
||||
}
|
||||
if (f.type == 0L) {
|
||||
prevIncludedType = null
|
||||
continue
|
||||
}
|
||||
markers.add(
|
||||
when {
|
||||
prevIncludedType != null -> prevIncludedType + 1
|
||||
markers.isEmpty() -> 1L
|
||||
markers.last() == 239L -> 1_000_000_000L
|
||||
else -> markers.last() + 1
|
||||
},
|
||||
)
|
||||
prevIncludedType = null
|
||||
}
|
||||
return markers
|
||||
}
|
||||
|
||||
private fun encodeMarkers(markers: List<Long>): ByteArray {
|
||||
var size = 0
|
||||
for (m in markers) size += BigSize.encodedSize(m)
|
||||
val out = ByteArray(size)
|
||||
var offset = 0
|
||||
for (m in markers) {
|
||||
val enc = BigSize.encode(m)
|
||||
enc.copyInto(out, offset)
|
||||
offset += enc.size
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun concat(hashes: List<ByteArray>): ByteArray {
|
||||
val out = ByteArray(hashes.size * 32)
|
||||
var offset = 0
|
||||
for (h in hashes) {
|
||||
h.copyInto(out, offset)
|
||||
offset += 32
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private const val INVOICE_SIG_FIELD = "signature"
|
||||
private const val PROOF_SIG_FIELD = "proof_signature"
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
/**
|
||||
* BOLT-1 BigSize codec: a variable-length unsigned integer, big-endian, with a
|
||||
* length prefix byte (`0xfd`/`0xfe`/`0xff`) for the 2/4/8-byte forms. Used for
|
||||
* both TLV record types and lengths.
|
||||
*
|
||||
* See https://github.com/lightning/bolts/blob/master/01-messaging.md#appendix-a-bigsize-test-vectors
|
||||
*/
|
||||
object BigSize {
|
||||
fun encodedSize(value: Long): Int =
|
||||
when {
|
||||
value < 0xfdL -> 1
|
||||
value < 0x10000L -> 3
|
||||
value < 0x100000000L -> 5
|
||||
else -> 9
|
||||
}
|
||||
|
||||
fun encode(value: Long): ByteArray {
|
||||
require(value >= 0) { "BigSize cannot encode a negative value" }
|
||||
return when {
|
||||
value < 0xfdL -> byteArrayOf(value.toByte())
|
||||
value < 0x10000L -> byteArrayOf(0xfd.toByte(), (value shr 8).toByte(), value.toByte())
|
||||
value < 0x100000000L ->
|
||||
byteArrayOf(
|
||||
0xfe.toByte(),
|
||||
(value shr 24).toByte(),
|
||||
(value shr 16).toByte(),
|
||||
(value shr 8).toByte(),
|
||||
value.toByte(),
|
||||
)
|
||||
else ->
|
||||
byteArrayOf(
|
||||
0xff.toByte(),
|
||||
(value shr 56).toByte(),
|
||||
(value shr 48).toByte(),
|
||||
(value shr 40).toByte(),
|
||||
(value shr 32).toByte(),
|
||||
(value shr 24).toByte(),
|
||||
(value shr 16).toByte(),
|
||||
(value shr 8).toByte(),
|
||||
value.toByte(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cursor over a byte array for reading BigSize values and fixed-length byte
|
||||
* runs out of a TLV stream.
|
||||
*/
|
||||
class TlvReader(
|
||||
private val bytes: ByteArray,
|
||||
) {
|
||||
var pos: Int = 0
|
||||
private set
|
||||
|
||||
fun remaining(): Int = bytes.size - pos
|
||||
|
||||
fun readBytes(n: Int): ByteArray {
|
||||
require(n >= 0 && n <= remaining()) { "TLV read of $n bytes exceeds the remaining ${remaining()}" }
|
||||
val out = bytes.copyOfRange(pos, pos + n)
|
||||
pos += n
|
||||
return out
|
||||
}
|
||||
|
||||
private fun readByte(): Int {
|
||||
require(remaining() > 0) { "Unexpected end of TLV stream" }
|
||||
return bytes[pos++].toInt() and 0xff
|
||||
}
|
||||
|
||||
fun readBigSize(): Long {
|
||||
val first = readByte()
|
||||
// BOLT-1 requires minimal encoding. Rejecting non-minimal forms also rejects
|
||||
// the multi-byte forms whose value would overflow a signed Long (≥ 2^63 reads
|
||||
// back negative, failing the `>=` bound), so callers get a non-negative Long.
|
||||
return when (first) {
|
||||
0xff -> readUInt(8).also { require(it >= 0x100000000L) { "non-minimal or out-of-range BigSize" } }
|
||||
0xfe -> readUInt(4).also { require(it >= 0x10000L) { "non-minimal BigSize" } }
|
||||
0xfd -> readUInt(2).also { require(it >= 0xfdL) { "non-minimal BigSize" } }
|
||||
else -> first.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUInt(n: Int): Long {
|
||||
var value = 0L
|
||||
repeat(n) {
|
||||
value = (value shl 8) or readByte().toLong()
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single TLV record: an unsigned [type], its [value] bytes, and a canonical
|
||||
* `type || length || value` [encoded] form (used both to re-serialize a stream
|
||||
* and as the leaf input for the BOLT12 signature merkle tree).
|
||||
*/
|
||||
class TlvRecord(
|
||||
val type: Long,
|
||||
val value: ByteArray,
|
||||
) {
|
||||
val encoded: ByteArray by lazy {
|
||||
BigSize.encode(type) + BigSize.encode(value.size.toLong()) + value
|
||||
}
|
||||
|
||||
/** BOLT12 signature TLV elements (types 240..1000 inclusive) are excluded from the merkle root. */
|
||||
fun isSignatureElement() = type in SIGNATURE_TYPE_MIN..SIGNATURE_TYPE_MAX
|
||||
|
||||
companion object {
|
||||
const val SIGNATURE_TYPE_MIN = 240L
|
||||
const val SIGNATURE_TYPE_MAX = 1000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed BOLT12 TLV stream (an offer, invoice request, invoice, or payer
|
||||
* proof). Records are kept in the order read; BOLT12 requires strictly
|
||||
* ascending, unique types, which [read] enforces.
|
||||
*/
|
||||
class TlvStream(
|
||||
val records: List<TlvRecord>,
|
||||
) {
|
||||
fun get(type: Long): TlvRecord? = records.firstOrNull { it.type == type }
|
||||
|
||||
fun value(type: Long): ByteArray? = get(type)?.value
|
||||
|
||||
fun has(type: Long): Boolean = get(type) != null
|
||||
|
||||
/**
|
||||
* The truncated-uint64 value of a record, or null if absent **or malformed**.
|
||||
* A `tu64` is at most 8 bytes; a longer value is invalid encoding, so this
|
||||
* returns null rather than throwing — untrusted proofs/offers reach this on the
|
||||
* validation path and must degrade to a clean rejection, not an exception.
|
||||
*/
|
||||
fun tu64(type: Long): Long? = value(type)?.let { if (it.size > 8) null else Bolt12Values.tu64(it) }
|
||||
|
||||
fun encode(): ByteArray {
|
||||
var size = 0
|
||||
for (r in records) size += r.encoded.size
|
||||
val out = ByteArray(size)
|
||||
var offset = 0
|
||||
for (r in records) {
|
||||
r.encoded.copyInto(out, offset)
|
||||
offset += r.encoded.size
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun read(bytes: ByteArray): TlvStream {
|
||||
val reader = TlvReader(bytes)
|
||||
val records = ArrayList<TlvRecord>()
|
||||
var lastType = -1L
|
||||
while (reader.remaining() > 0) {
|
||||
val type = reader.readBigSize()
|
||||
val length = reader.readBigSize()
|
||||
require(length in 0..reader.remaining().toLong()) { "TLV length $length out of range (remaining ${reader.remaining()})" }
|
||||
val value = reader.readBytes(length.toInt())
|
||||
require(type > lastType) { "TLV records must be strictly ascending (saw $type after $lastType)" }
|
||||
lastType = type
|
||||
records.add(TlvRecord(type, value))
|
||||
}
|
||||
return TlvStream(records)
|
||||
}
|
||||
|
||||
fun readOrNull(bytes: ByteArray): TlvStream? =
|
||||
try {
|
||||
read(bytes)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Encoders/decoders for the BOLT12 fundamental TLV value types we use. */
|
||||
object Bolt12Values {
|
||||
/**
|
||||
* Decodes a `tu64` (truncated uint64): a big-endian unsigned integer with
|
||||
* leading zero bytes removed, so its encoded length is 0..8 bytes.
|
||||
*/
|
||||
fun tu64(bytes: ByteArray): Long {
|
||||
require(bytes.size <= 8) { "tu64 must be at most 8 bytes, was ${bytes.size}" }
|
||||
var value = 0L
|
||||
for (b in bytes) {
|
||||
value = (value shl 8) or (b.toLong() and 0xff)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Minimal big-endian `tu64` encoding of [value] (leading zero bytes stripped). */
|
||||
fun tu64ToBytes(value: Long): ByteArray {
|
||||
require(value >= 0) { "tu64 cannot encode a negative value" }
|
||||
if (value == 0L) return ByteArray(0)
|
||||
val full =
|
||||
byteArrayOf(
|
||||
(value shr 56).toByte(),
|
||||
(value shr 48).toByte(),
|
||||
(value shr 40).toByte(),
|
||||
(value shr 32).toByte(),
|
||||
(value shr 24).toByte(),
|
||||
(value shr 16).toByte(),
|
||||
(value shr 8).toByte(),
|
||||
value.toByte(),
|
||||
)
|
||||
var start = 0
|
||||
while (start < full.size && full[start].toInt() == 0) start++
|
||||
return full.copyOfRange(start, full.size)
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.builder
|
||||
|
||||
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.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Assembles the two NIP-XX events in the order the payment flow requires:
|
||||
*
|
||||
* 1. [buildIntent] — sign a kind 9737 zap intent *before* paying.
|
||||
* 2. Pay the offer, putting [payerNote] in the BOLT12 `invreq_payer_note`, and
|
||||
* collect a settled `lnp` payer proof from the wallet.
|
||||
* 3. [buildZap] — wrap the signed intent and the proof into a kind 9736 zap.
|
||||
*
|
||||
* This is the on-Nostr assembly only; requesting the BOLT12 invoice, paying it,
|
||||
* and obtaining the payer proof are the wallet's job (no Amethyst payment rail
|
||||
* exposes `lnp` proofs yet).
|
||||
*/
|
||||
object Bolt12ZapBuilder {
|
||||
/** A fresh 128-bit `zap_id` as lowercase hex. */
|
||||
fun randomZapId(): String = Hex.encode(RandomInstance.bytes(16))
|
||||
|
||||
/**
|
||||
* The value the payer MUST place in the BOLT12 `invreq_payer_note` when paying
|
||||
* the offer, binding the settled payment to [intent].
|
||||
*/
|
||||
fun payerNote(intent: Bolt12ZapIntentEvent): String = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
|
||||
/** Sign a zap intent targeting a specific event. */
|
||||
suspend fun buildIntent(
|
||||
signer: NostrSigner,
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
zappedEvent: EventHintBundle<out Event>,
|
||||
comment: String = "",
|
||||
zapId: String = randomZapId(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12ZapIntentEvent =
|
||||
signer.sign(
|
||||
Bolt12ZapIntentEvent.build(recipientPubKey, amountInMillisats, offer, zapId, zappedEvent, comment, createdAt),
|
||||
)
|
||||
|
||||
/** Sign a zap intent targeting a recipient's profile (no event / address). */
|
||||
suspend fun buildProfileIntent(
|
||||
signer: NostrSigner,
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
comment: String = "",
|
||||
zapId: String = randomZapId(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12ZapIntentEvent =
|
||||
signer.sign(
|
||||
Bolt12ZapIntentEvent.buildProfileZap(recipientPubKey, amountInMillisats, offer, zapId, comment, createdAt),
|
||||
)
|
||||
|
||||
/**
|
||||
* Sign the final kind 9736 zap from a [signedIntent] and a settled [payerProof].
|
||||
*
|
||||
* @param anonymous when true, no `P` tag is added; the caller MUST pass an
|
||||
* ephemeral [signer] (the same key that signed [signedIntent]). When false,
|
||||
* the signer's pubkey is added as `P`, publicly attributing the zap.
|
||||
*/
|
||||
suspend fun buildZap(
|
||||
signer: NostrSigner,
|
||||
signedIntent: Bolt12ZapIntentEvent,
|
||||
payerProof: String,
|
||||
anonymous: Boolean = false,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12ZapEvent {
|
||||
val payerPubKey = if (anonymous) null else signer.pubKey
|
||||
return signer.sign(
|
||||
Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey, createdAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
+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.nipXXBolt12Zaps.intent
|
||||
|
||||
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.kinds.KindTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-XX: BOLT12 Zaps — the **zap intent** (kind 9737).
|
||||
*
|
||||
* The payer creates and signs this event *before* paying the BOLT12 offer. It
|
||||
* binds the payer's Nostr key to the recipient, the target, the amount and the
|
||||
* offer. The payer then references this event's id in the BOLT12
|
||||
* `invreq_payer_note` (`nostr:nipXX:<zap-intent-event-id>`), and finally embeds
|
||||
* the whole serialized intent inside the kind 9736 zap event's `description` tag
|
||||
* — the same embedding pattern NIP-57 uses for the zap request.
|
||||
*
|
||||
* A zap intent is never counted on its own; only the kind 9736 zap that carries
|
||||
* both this intent and a settled payer proof is.
|
||||
*/
|
||||
@Immutable
|
||||
class Bolt12ZapIntentEvent(
|
||||
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 recipient pubkey (`p` tag). */
|
||||
fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The amount in millisatoshis (`amount` tag). */
|
||||
fun amount() = tags.firstNotNullOfOrNull(AmountTag::parse)
|
||||
|
||||
/** The canonical raw BOLT12 offer (`offer` tag). */
|
||||
fun offer() = tags.firstNotNullOfOrNull(OfferTag::parse)
|
||||
|
||||
/** The random `zap_id`. */
|
||||
fun zapId() = tags.firstNotNullOfOrNull(ZapIdTag::parse)
|
||||
|
||||
/** The event being zapped, if any (`e` tag). */
|
||||
fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId)
|
||||
|
||||
/** The addressable event being zapped, if any (`a` tag). */
|
||||
fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
|
||||
|
||||
/** The kind of the target event, if declared (`k` tag). */
|
||||
fun zappedKind() = tags.firstNotNullOfOrNull(KindTag::parse)
|
||||
|
||||
/** True when neither `e` nor `a` is present — the intent targets the recipient's profile. */
|
||||
fun isProfileZap() = zappedEvent() == null && zappedAddress() == null
|
||||
|
||||
companion object {
|
||||
const val KIND = 9737
|
||||
|
||||
/** Build a zap intent that targets a specific event. */
|
||||
fun build(
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
zapId: String,
|
||||
zappedEvent: EventHintBundle<out Event>,
|
||||
comment: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Bolt12ZapIntentEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, comment, createdAt) {
|
||||
recipient(recipientPubKey)
|
||||
amountInMillisats(amountInMillisats)
|
||||
offer(offer)
|
||||
zapId(zapId)
|
||||
if (zappedEvent.event is AddressableEvent) {
|
||||
zappedAddress(zappedEvent.toATag())
|
||||
} else {
|
||||
zappedEvent(zappedEvent.toETag())
|
||||
}
|
||||
zappedKind(zappedEvent.event.kind)
|
||||
initializer()
|
||||
}
|
||||
|
||||
/** Build a zap intent that targets a recipient's profile (no event / address). */
|
||||
fun buildProfileZap(
|
||||
recipientPubKey: HexKey,
|
||||
amountInMillisats: Long,
|
||||
offer: String,
|
||||
zapId: String,
|
||||
comment: String = "",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Bolt12ZapIntentEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, comment, createdAt) {
|
||||
recipient(recipientPubKey)
|
||||
amountInMillisats(amountInMillisats)
|
||||
offer(offer)
|
||||
zapId(zapId)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.intent
|
||||
|
||||
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.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ZapIdTag
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zapId(zapId: String) = addUnique(ZapIdTag.assemble(zapId))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedEvent(tag: ETag) = addUnique(tag.toTagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedAddress(tag: ATag) = addUnique(tag.toATagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapIntentEvent>.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind))
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.offer
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-XX: BOLT12 Zaps — a user's **BOLT12 offer list** (kind 10058).
|
||||
*
|
||||
* A replaceable event, anchored to the author's pubkey, that publishes one or more
|
||||
* canonical raw BOLT12 offers (`["offer", "lno1..."]`). This is how a payer
|
||||
* discovers a recipient's offer before sending a BOLT12 zap — the on-Nostr
|
||||
* analogue of the `lud16` metadata field NIP-57 lightning zaps use, and the thing
|
||||
* that ties an offer to a Nostr identity (the author's signature).
|
||||
*
|
||||
* Fetch a recipient's latest 10058 and pick one of its offers; when more than one
|
||||
* is present, a payer MAY select any it supports. Content SHOULD be empty.
|
||||
*/
|
||||
@Immutable
|
||||
class Bolt12OfferListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** All canonical raw BOLT12 offers (`lno1...`) this user publishes, in tag order. */
|
||||
fun offers(): List<String> = tags.mapNotNull(OfferTag::parse)
|
||||
|
||||
/** The first valid offer, or null when the list carries none. */
|
||||
fun firstOffer(): String? = tags.firstNotNullOfOrNull(OfferTag::parse)
|
||||
|
||||
companion object {
|
||||
const val KIND = 10058
|
||||
|
||||
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
|
||||
|
||||
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
|
||||
|
||||
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
|
||||
|
||||
fun createTagArray(offers: List<String>): Array<Array<String>> =
|
||||
offers
|
||||
.map { OfferTag.assemble(it) }
|
||||
.toTypedArray()
|
||||
|
||||
/** Replace the offer set, preserving any non-offer tags an earlier version carried. */
|
||||
suspend fun updateOffers(
|
||||
earlierVersion: Bolt12OfferListEvent,
|
||||
offers: List<String>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12OfferListEvent {
|
||||
val tags =
|
||||
earlierVersion.tags
|
||||
.filter { !OfferTag.isTag(it) }
|
||||
.plus(offers.map { OfferTag.assemble(it) })
|
||||
.toTypedArray()
|
||||
|
||||
return signer.sign(createdAt, KIND, tags, earlierVersion.content)
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
offers: List<String>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "")
|
||||
|
||||
fun create(
|
||||
offers: List<String>,
|
||||
signer: NostrSignerSync,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Bolt12OfferListEvent = signer.sign(createdAt, KIND, createTagArray(offers), "")
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `amount` tag of a NIP-XX BOLT12 zap or zap intent: the payment amount in
|
||||
* **millisatoshis** (not sats — this matches the BOLT12 `invoice_amount` field it
|
||||
* is validated against, and NIP-57's `amount` tag).
|
||||
*/
|
||||
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(amountInMillisats: Long) = arrayOf(TAG_NAME, amountInMillisats.toString())
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `description` tag of a BOLT12 zap event (kind 9736): the complete serialized
|
||||
* kind 9737 zap intent event, as JSON. Uses the same embedding pattern as NIP-57,
|
||||
* where the zap receipt carries the serialized zap request.
|
||||
*
|
||||
* This class only extracts the raw string; parsing it back into an event and
|
||||
* checking its signature is the validator's job.
|
||||
*/
|
||||
class DescriptionTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "description"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(serializedIntentEventJson: String) = arrayOf(TAG_NAME, serializedIntentEventJson)
|
||||
}
|
||||
}
|
||||
+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.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `offer` tag: the **canonical raw BOLT12 offer** the payment was made to.
|
||||
*
|
||||
* The canonical form is the lowercase `lno1...` string with BOLT12 `+`
|
||||
* continuation separators and whitespace removed (see [Bolt12Bech32.canonicalize]).
|
||||
* Both the zap event (kind 9736) and the embedded zap intent (kind 9737) carry
|
||||
* the exact same canonical value; the validator compares them byte-for-byte.
|
||||
*/
|
||||
class OfferTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "offer"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isOffer(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(Bolt12Bech32.isOffer(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(canonicalOffer: String) = arrayOf(TAG_NAME, canonicalOffer)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The uppercase `P` tag: the **payer** pubkey of a publicly-attributed BOLT12 zap.
|
||||
*
|
||||
* When present it MUST equal the zap event `pubkey`. Anonymous zaps use an
|
||||
* ephemeral event pubkey and MUST omit this tag (mirrors NIP-57's uppercase-`P`
|
||||
* convention for the sender).
|
||||
*/
|
||||
class PayerTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "P"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
|
||||
|
||||
fun parse(tag: Array<String>): HexKey? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].length == 64) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(payerPubKey: HexKey) = arrayOf(TAG_NAME, payerPubKey)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `proof` tag: the bech32-encoded BOLT12 `lnp` **payer proof** for the settled
|
||||
* payment. Only present on the zap event (kind 9736), never on the intent.
|
||||
*
|
||||
* The proof is decoded and cryptographically checked by the validator; this tag
|
||||
* class only guards the surface syntax (`lnp1...`).
|
||||
*/
|
||||
class ProofTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "proof"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && Bolt12Bech32.isPayerProof(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(Bolt12Bech32.isPayerProof(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(payerProof: String) = arrayOf(TAG_NAME, payerProof)
|
||||
}
|
||||
}
|
||||
+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.nipXXBolt12Zaps.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The `zap_id` tag of a zap intent (kind 9737): a random value with at least
|
||||
* 128 bits of entropy, encoded as lowercase hex. It only needs to be present and
|
||||
* well-formed; the anti-replay binding is enforced by the payer note referencing
|
||||
* the intent's event id, not by this value itself.
|
||||
*/
|
||||
class ZapIdTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "zap_id"
|
||||
|
||||
/** 128 bits of entropy = 16 bytes = 32 lowercase-hex characters. */
|
||||
const val MIN_HEX_LENGTH = 32
|
||||
|
||||
fun isValid(zapId: String) = zapId.length >= MIN_HEX_LENGTH && Hex.isHex(zapId) && zapId == zapId.lowercase()
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && isValid(tag[1])
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(isValid(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(zapId: String) = arrayOf(TAG_NAME, zapId)
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.verify
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/** Outcome of cryptographically verifying a BOLT12 payer proof. */
|
||||
@Immutable
|
||||
sealed interface Bolt12ProofResult {
|
||||
/**
|
||||
* The proof cryptographically checks out.
|
||||
*
|
||||
* @property paymentHash the `invoice_payment_hash` — the dedup key for zaps
|
||||
* sharing a target.
|
||||
* @property invoiceAmountMillisats the settled `invoice_amount`, if present.
|
||||
*/
|
||||
@Immutable
|
||||
data class Valid(
|
||||
val paymentHash: ByteArray,
|
||||
val invoiceAmountMillisats: Long?,
|
||||
) : Bolt12ProofResult {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Valid) return false
|
||||
return paymentHash.contentEquals(other.paymentHash) && invoiceAmountMillisats == other.invoiceAmountMillisats
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * paymentHash.contentHashCode() + (invoiceAmountMillisats?.hashCode() ?: 0)
|
||||
}
|
||||
|
||||
/** The proof is present but fails a check and MUST NOT be counted. */
|
||||
@Immutable
|
||||
data class Invalid(
|
||||
val reason: Reason,
|
||||
) : Bolt12ProofResult
|
||||
|
||||
enum class Reason {
|
||||
MISSING_REQUIRED_FIELDS,
|
||||
PREIMAGE_MISMATCH,
|
||||
|
||||
/**
|
||||
* The selective-disclosure fields (`proof_omitted_tlvs` /
|
||||
* `proof_missing_hashes` / `proof_leaf_hashes`) are malformed or don't
|
||||
* describe a closable merkle tree, so the invoice root can't be rebuilt.
|
||||
*/
|
||||
RECONSTRUCTION_FAILED,
|
||||
INVOICE_SIGNATURE_INVALID,
|
||||
PROOF_SIGNATURE_INVALID,
|
||||
MALFORMED_KEY,
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Merkle
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* Cryptographic verification of a BOLT12 `lnp` payer proof, per lightning/bolts#1346:
|
||||
*
|
||||
* 1. `SHA256(proof_preimage) == invoice_payment_hash` — proves the payment settled.
|
||||
* 2. The invoice `signature` (240) is valid over the **reconstructed** invoice
|
||||
* merkle root, signed by `invoice_node_id`. The proof discloses only some of
|
||||
* the invoice's TLV fields (`invreq_metadata` is always withheld); the omitted
|
||||
* branches are rebuilt from `proof_missing_hashes` / `proof_leaf_hashes` /
|
||||
* `proof_omitted_tlvs` via [Bolt12Merkle.reconstructRoot].
|
||||
* 3. The `proof_signature` (241) is valid over the proof's own (fully-disclosed)
|
||||
* merkle root, signed by `invreq_payer_id`.
|
||||
*
|
||||
* The full reader path — reconstruction and both BIP-340 checks — is exercised
|
||||
* byte-for-byte against the spec's `payer-proof-test.json` vectors
|
||||
* ([Bolt12PayerProofVectorTest]). The BOLT signature message/field names below
|
||||
* still track the unmerged draft and must be reconciled if it changes on merge.
|
||||
*/
|
||||
class Bolt12ProofVerifier {
|
||||
fun verify(proof: Bolt12PayerProof): Bolt12ProofResult {
|
||||
if (!proof.hasAllCryptoFields()) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS)
|
||||
}
|
||||
|
||||
val preimage = proof.proofPreimage()!!
|
||||
val paymentHash = proof.invoicePaymentHash()!!
|
||||
|
||||
// 1. Settlement proof: the preimage must hash to the invoice payment hash.
|
||||
if (!sha256(preimage).contentEquals(paymentHash)) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.PREIMAGE_MISMATCH)
|
||||
}
|
||||
|
||||
// 2. Invoice signature over the reconstructed invoice merkle root.
|
||||
val invoiceRoot =
|
||||
reconstructInvoiceRoot(proof)
|
||||
?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.RECONSTRUCTION_FAILED)
|
||||
val invoiceSig = proof.invoiceSignature()!!
|
||||
val nodeId = xOnly(proof.invoiceNodeId()!!) ?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MALFORMED_KEY)
|
||||
val invoiceDigest = Bolt12Merkle.signatureDigest(INVOICE_MESSAGE, INVOICE_SIG_FIELD, invoiceRoot)
|
||||
if (!Nip01Crypto.verify(invoiceSig, invoiceDigest, nodeId)) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.INVOICE_SIGNATURE_INVALID)
|
||||
}
|
||||
|
||||
// 3. Payer proof signature over the proof's own (disclosed) records.
|
||||
val proofSig = proof.proofSignature()!!
|
||||
val payerId = xOnly(proof.invreqPayerId()!!) ?: return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.MALFORMED_KEY)
|
||||
val proofRoot = Bolt12Merkle.rootHash(proof.proofSignableRecords())
|
||||
val proofDigest = Bolt12Merkle.signatureDigest(PROOF_MESSAGE, PROOF_SIG_FIELD, proofRoot)
|
||||
if (!Nip01Crypto.verify(proofSig, proofDigest, payerId)) {
|
||||
return Bolt12ProofResult.Invalid(Bolt12ProofResult.Reason.PROOF_SIGNATURE_INVALID)
|
||||
}
|
||||
|
||||
return Bolt12ProofResult.Valid(paymentHash = paymentHash, invoiceAmountMillisats = proof.invoiceAmount())
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the invoice merkle root from a selectively-disclosed proof. Returns
|
||||
* null when the proof's compression fields are malformed or don't describe a
|
||||
* closable tree — an unverifiable proof the caller must not count.
|
||||
*/
|
||||
private fun reconstructInvoiceRoot(proof: Bolt12PayerProof): ByteArray? {
|
||||
val markers = proof.omittedTlvMarkers() ?: return null
|
||||
val leafHashes = proof.leafHashList() ?: return null
|
||||
val missingHashes = proof.missingHashList() ?: return null
|
||||
|
||||
val included = proof.invoiceIncludedRecords().sortedBy { it.type }
|
||||
if (leafHashes.size != included.size) return null
|
||||
if (!markersValid(markers, included)) return null
|
||||
|
||||
// Ordered non-signature leaves of the original invoice: the implied
|
||||
// `invreq_metadata` (type 0) plus every omitted marker plus every disclosed
|
||||
// field, merged in ascending numeric order. Disclosed fields carry their
|
||||
// per-field node hash; omitted positions are null (filled from missing hashes).
|
||||
val positions = ArrayList<Pair<Long, TlvRecord?>>(included.size + markers.size + 1)
|
||||
positions.add(0L to null) // type 0 (invreq_metadata) is always omitted
|
||||
for (m in markers) positions.add(m to null)
|
||||
for (r in included) positions.add(r.type to r)
|
||||
positions.sortBy { it.first }
|
||||
|
||||
var leafIdx = 0
|
||||
val leafNodeHashes =
|
||||
positions.map { (_, record) ->
|
||||
if (record == null) {
|
||||
null
|
||||
} else {
|
||||
Bolt12Merkle.fieldNode(record.encoded, leafHashes[leafIdx++])
|
||||
}
|
||||
}
|
||||
|
||||
return Bolt12Merkle.reconstructRoot(leafNodeHashes, missingHashes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lightning/bolts#1346 reader rules for `proof_omitted_tlvs`: strictly
|
||||
* ascending, non-zero, within the invoice ranges, never the number of a
|
||||
* disclosed field, and each a valid *minimal renumbering* successor — one more
|
||||
* than a disclosed field, one more than the previous marker (or 0 for the
|
||||
* first), or the 1_000_000_000 jump after 239.
|
||||
*/
|
||||
private fun markersValid(
|
||||
markers: List<Long>,
|
||||
included: List<TlvRecord>,
|
||||
): Boolean {
|
||||
val includedTypes = included.mapTo(HashSet()) { it.type }
|
||||
var prev = 0L
|
||||
for ((i, m) in markers.withIndex()) {
|
||||
if (i > 0 && m <= markers[i - 1]) return false // strict ascending, no duplicates
|
||||
if (m == 0L) return false
|
||||
if (!Bolt12PayerProof.isInvoiceField(m)) return false
|
||||
if (m in includedTypes) return false
|
||||
val validSuccessor =
|
||||
(m - 1) in includedTypes ||
|
||||
m == prev + 1 ||
|
||||
(m == 1_000_000_000L && prev == 239L)
|
||||
if (!validSuccessor) return false
|
||||
prev = m
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A BOLT12 `point` is a 33-byte compressed secp256k1 key; BIP-340 uses the
|
||||
* 32-byte x-only form. Drop the parity prefix. (Already-x-only 32-byte input
|
||||
* is passed through for convenience in tests.)
|
||||
*/
|
||||
private fun xOnly(point: ByteArray): ByteArray? =
|
||||
when (point.size) {
|
||||
33 -> point.copyOfRange(1, 33)
|
||||
32 -> point
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
// BOLT12 signature digest tags are "lightning" || messagename || fieldname.
|
||||
// These strings track lightning/bolts#1346 and must be reconciled on merge.
|
||||
const val INVOICE_MESSAGE = "invoice"
|
||||
const val PROOF_MESSAGE = "payer_proof"
|
||||
|
||||
/** The invoice's own signature field is named `signature`. */
|
||||
const val INVOICE_SIG_FIELD = "signature"
|
||||
|
||||
/** The payer proof's signature field is named `proof_signature`. */
|
||||
const val PROOF_SIG_FIELD = "proof_signature"
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.verify
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/** Result of validating a NIP-XX BOLT12 zap event (kind 9736). */
|
||||
@Immutable
|
||||
sealed interface Bolt12ZapValidation {
|
||||
/**
|
||||
* The zap event is well-formed, its embedded intent is signed by the same key
|
||||
* and matches, and the payer proof is bound to that intent.
|
||||
*
|
||||
* @property recipient the zapped author (`p`).
|
||||
* @property payer the payer (`P`), or null for an anonymous zap.
|
||||
* @property amountMillisats the amount to count.
|
||||
* @property paymentHashHex the proof's `invoice_payment_hash`, hex-encoded —
|
||||
* the key clients MUST deduplicate on before summing.
|
||||
* @property proofCryptoVerified true when the BOLT12 payer-proof signatures
|
||||
* verified **and** the settled invoice is provably the embedded offer's (the
|
||||
* offer publishes an `offer_issuer_id`, uses no blinded paths, and the
|
||||
* invoice node key equals it). False when the signatures verify but the offer
|
||||
* hides its destination (blinded paths / no issuer id), so paying *this* offer
|
||||
* isn't proven — see [Bolt12ProofVerifier]. Callers decide whether to count or
|
||||
* merely display the latter, and MUST label it as unverified.
|
||||
*/
|
||||
@Immutable
|
||||
data class Valid(
|
||||
val recipient: HexKey,
|
||||
val payer: HexKey?,
|
||||
val amountMillisats: Long,
|
||||
val paymentHashHex: String,
|
||||
val zappedEventId: String?,
|
||||
val zappedAddress: String?,
|
||||
val zappedKind: Int?,
|
||||
val proofCryptoVerified: Boolean,
|
||||
) : Bolt12ZapValidation {
|
||||
val isProfileZap: Boolean get() = zappedEventId == null && zappedAddress == null
|
||||
}
|
||||
|
||||
/** The event failed validation and MUST NOT be counted. */
|
||||
@Immutable
|
||||
data class Invalid(
|
||||
val reason: Reason,
|
||||
) : Bolt12ZapValidation
|
||||
|
||||
enum class Reason {
|
||||
WRONG_KIND,
|
||||
BAD_EVENT_SIGNATURE,
|
||||
MISSING_DESCRIPTION,
|
||||
NOT_EXACTLY_ONE_DESCRIPTION,
|
||||
MISSING_RECIPIENT,
|
||||
NOT_EXACTLY_ONE_RECIPIENT,
|
||||
MISSING_AMOUNT,
|
||||
NON_POSITIVE_AMOUNT,
|
||||
MISSING_OR_INVALID_OFFER,
|
||||
MISSING_OR_INVALID_PROOF,
|
||||
MULTIPLE_EVENT_TARGETS,
|
||||
MULTIPLE_ADDRESS_TARGETS,
|
||||
BOTH_EVENT_AND_ADDRESS_TARGET,
|
||||
PAYER_TAG_MISMATCH,
|
||||
MISSING_OR_INVALID_INTENT,
|
||||
BAD_INTENT_SIGNATURE,
|
||||
INTENT_PUBKEY_MISMATCH,
|
||||
INVALID_ZAP_ID,
|
||||
INTENT_STRUCTURE_INVALID,
|
||||
CONTENT_MISMATCH,
|
||||
RECIPIENT_MISMATCH,
|
||||
AMOUNT_MISMATCH,
|
||||
OFFER_MISMATCH,
|
||||
TARGET_MISMATCH,
|
||||
UNPARSEABLE_OFFER,
|
||||
UNPARSEABLE_PROOF,
|
||||
PROOF_NOTE_MISMATCH,
|
||||
MISSING_INVOICE_AMOUNT,
|
||||
PROOF_AMOUNT_MISMATCH,
|
||||
OFFER_PROOF_MISMATCH,
|
||||
PROOF_MISSING_REQUIRED_FIELDS,
|
||||
PROOF_PREIMAGE_MISMATCH,
|
||||
PROOF_RECONSTRUCTION_FAILED,
|
||||
PROOF_INVOICE_SIGNATURE_INVALID,
|
||||
PROOF_SIGNATURE_INVALID,
|
||||
PROOF_MALFORMED_KEY,
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* NIP-XX validator: runs the spec's validation steps over a [Bolt12ZapEvent] and
|
||||
* returns a [Bolt12ZapValidation]. A client MUST validate an event with this (and
|
||||
* deduplicate the results by [Bolt12ZapValidation.Valid.paymentHashHex]) before
|
||||
* counting a BOLT12 zap.
|
||||
*
|
||||
* The steps mirror the spec:
|
||||
* 1. kind 9736 and a valid event signature;
|
||||
* 2. zap-event structure (exactly one `description`/`p`, positive `amount`,
|
||||
* canonical `offer`, `lnp` `proof`, at most one `e`/`a` and never both,
|
||||
* `P` == `pubkey` when present);
|
||||
* 3. the embedded kind 9737 intent parses, is signed by the same pubkey, and is
|
||||
* itself well-formed;
|
||||
* 4. the zap and intent match on `content`, `p`, `amount`, `offer`, `e`, `a`, `k`;
|
||||
* 5. the raw BOLT12 offer parses;
|
||||
* 6. the `lnp` payer proof decodes and its crypto checks pass (see
|
||||
* [Bolt12ProofVerifier]);
|
||||
* 7. the proof binds to this zap: `invreq_payer_note == nostr:nipXX:<intent-id>`,
|
||||
* `invoice_amount == amount`, and the proof matches the offer.
|
||||
*/
|
||||
class Bolt12ZapValidator(
|
||||
private val proofVerifier: Bolt12ProofVerifier = Bolt12ProofVerifier(),
|
||||
) {
|
||||
/**
|
||||
* @param verifyEventSignature verify the zap event's own signature. Defaults to
|
||||
* true for standalone callers. The `LocalCache` ingest path passes false
|
||||
* because the relay-client pipeline already verified it before dispatch —
|
||||
* avoiding a redundant schnorr check on the hot path.
|
||||
*
|
||||
* Checks are ordered cheap-to-expensive: all structural, cross-event, and
|
||||
* binding checks (tag reads, string/number compares) run first, and the
|
||||
* expensive signature/crypto verifications run only once an event has passed
|
||||
* them — so a malformed or mismatched event is rejected without paying for a
|
||||
* schnorr verification. This cannot change an accept/reject outcome (a
|
||||
* badly-signed event still fails the later verify), only which reason a
|
||||
* doubly-invalid event reports.
|
||||
*/
|
||||
fun validate(
|
||||
event: Bolt12ZapEvent,
|
||||
verifyEventSignature: Boolean = true,
|
||||
): Bolt12ZapValidation {
|
||||
// --- Cheap checks (no crypto) --------------------------------------------
|
||||
|
||||
// Zap-event kind + structure.
|
||||
if (event.kind != Bolt12ZapEvent.KIND) return invalid(Reason.WRONG_KIND)
|
||||
if (event.tags.count(DescriptionTag::isTag) != 1) return invalid(Reason.NOT_EXACTLY_ONE_DESCRIPTION)
|
||||
if (event.description() == null) return invalid(Reason.MISSING_DESCRIPTION)
|
||||
|
||||
val recipientCount = event.tags.count { PTag.parseKey(it) != null }
|
||||
if (recipientCount != 1) return invalid(Reason.NOT_EXACTLY_ONE_RECIPIENT)
|
||||
val recipient = event.recipient() ?: return invalid(Reason.MISSING_RECIPIENT)
|
||||
|
||||
val amount = event.amount() ?: return invalid(Reason.MISSING_AMOUNT)
|
||||
if (amount <= 0) return invalid(Reason.NON_POSITIVE_AMOUNT)
|
||||
|
||||
val offer = event.offer() ?: return invalid(Reason.MISSING_OR_INVALID_OFFER)
|
||||
val proofStr = event.payerProof() ?: return invalid(Reason.MISSING_OR_INVALID_PROOF)
|
||||
|
||||
val cardinality = checkTargetCardinality(event.tags)
|
||||
if (cardinality != null) return invalid(cardinality)
|
||||
|
||||
val payer = event.payer()
|
||||
if (payer != null && payer != event.pubKey) return invalid(Reason.PAYER_TAG_MISMATCH)
|
||||
|
||||
// Embedded intent — parse + structure (signature verified later).
|
||||
val intent = event.zapIntent ?: return invalid(Reason.MISSING_OR_INVALID_INTENT)
|
||||
if (intent.pubKey != event.pubKey) return invalid(Reason.INTENT_PUBKEY_MISMATCH)
|
||||
if (intent.zapId() == null) return invalid(Reason.INVALID_ZAP_ID)
|
||||
|
||||
val intentRecipientCount = intent.tags.count { PTag.parseKey(it) != null }
|
||||
if (intentRecipientCount != 1) return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
val intentAmount = intent.amount() ?: return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
if (intentAmount <= 0) return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
if (intent.offer() == null) return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
if (checkTargetCardinality(intent.tags) != null) return invalid(Reason.INTENT_STRUCTURE_INVALID)
|
||||
|
||||
// The zap and its intent must agree.
|
||||
if (event.content != intent.content) return invalid(Reason.CONTENT_MISMATCH)
|
||||
if (recipient != intent.recipient()) return invalid(Reason.RECIPIENT_MISMATCH)
|
||||
if (amount != intentAmount) return invalid(Reason.AMOUNT_MISMATCH)
|
||||
if (offer != intent.offer()) return invalid(Reason.OFFER_MISMATCH)
|
||||
if (event.zappedEvent() != intent.zappedEvent()) return invalid(Reason.TARGET_MISMATCH)
|
||||
if (event.zappedAddress() != intent.zappedAddress()) return invalid(Reason.TARGET_MISMATCH)
|
||||
if (event.zappedKind() != intent.zappedKind()) return invalid(Reason.TARGET_MISMATCH)
|
||||
|
||||
// Parse the raw offer + payer proof.
|
||||
val offerParsed = Bolt12Offer.parse(offer) ?: return invalid(Reason.UNPARSEABLE_OFFER)
|
||||
val proof = Bolt12PayerProof.parse(proofStr) ?: return invalid(Reason.UNPARSEABLE_PROOF)
|
||||
|
||||
// Bind the proof to this zap. Uses intent.id — a forged id can't survive the
|
||||
// intent signature check below, so binding-before-verify is safe.
|
||||
val expectedNote = NIP_URI_PREFIX + intent.id
|
||||
if (proof.invreqPayerNote() != expectedNote) return invalid(Reason.PROOF_NOTE_MISMATCH)
|
||||
|
||||
val invoiceAmount = proof.invoiceAmount() ?: return invalid(Reason.MISSING_INVOICE_AMOUNT)
|
||||
if (invoiceAmount != amount) return invalid(Reason.PROOF_AMOUNT_MISMATCH)
|
||||
|
||||
offerBindingHardFailure(offerParsed, proof)?.let { return invalid(it) }
|
||||
|
||||
// --- Expensive checks (signatures + proof crypto) ------------------------
|
||||
|
||||
if (verifyEventSignature && !event.verify()) return invalid(Reason.BAD_EVENT_SIGNATURE)
|
||||
if (!intent.verify()) return invalid(Reason.BAD_INTENT_SIGNATURE)
|
||||
|
||||
val cryptoResult = proofVerifier.verify(proof)
|
||||
val cryptoOk =
|
||||
when (cryptoResult) {
|
||||
is Bolt12ProofResult.Valid -> true
|
||||
is Bolt12ProofResult.Invalid -> return invalid(mapProofReason(cryptoResult.reason))
|
||||
}
|
||||
|
||||
// "Crypto verified" requires BOTH that the invoice signature was checked AND
|
||||
// that the signed invoice is provably the offer's — i.e. signed by the offer's
|
||||
// `offer_issuer_id`. When the offer hides its destination behind blinded paths,
|
||||
// or publishes no issuer id, the invoice node key is one the payer chose and
|
||||
// can't be tied to the offer here, so "paid this offer" is NOT proven; such a
|
||||
// proof is downgraded to unverified rather than asserted verified.
|
||||
//
|
||||
// NB: even a bound proof only proves payment to the *embedded* offer. The NIP
|
||||
// has no offer↔recipient-identity binding, so nothing here proves the offer
|
||||
// belongs to the p-tagged recipient — see
|
||||
// quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md.
|
||||
val cryptoVerified = cryptoOk && isInvoiceBoundToOffer(offerParsed, proof)
|
||||
|
||||
val paymentHash = proof.invoicePaymentHash() ?: return invalid(Reason.PROOF_MISSING_REQUIRED_FIELDS)
|
||||
|
||||
return Bolt12ZapValidation.Valid(
|
||||
recipient = recipient,
|
||||
payer = payer,
|
||||
amountMillisats = amount,
|
||||
paymentHashHex = Hex.encode(paymentHash),
|
||||
zappedEventId = event.zappedEvent(),
|
||||
zappedAddress = event.zappedAddress(),
|
||||
zappedKind = event.zappedKind(),
|
||||
proofCryptoVerified = cryptoVerified,
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns the relevant reason when the `e`/`a` target cardinality is illegal, or null when fine. */
|
||||
private fun checkTargetCardinality(tags: Array<Array<String>>): Reason? {
|
||||
val eCount = tags.count(ETag::isTagged)
|
||||
val aCount = tags.count(ATag::isTagged)
|
||||
if (eCount > 1) return Reason.MULTIPLE_EVENT_TARGETS
|
||||
if (aCount > 1) return Reason.MULTIPLE_ADDRESS_TARGETS
|
||||
if (eCount >= 1 && aCount >= 1) return Reason.BOTH_EVENT_AND_ADDRESS_TARGET
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A definite contradiction between the proof and the offer — the proof either
|
||||
* copies a different `offer_issuer_id`, or (for a directly-addressed offer)
|
||||
* carries an `invoice_node_id` that isn't the offer's issuer. These are hard
|
||||
* rejects. Note the *absence* of a check is NOT a pass here — that only means
|
||||
* we can't bind, which [isInvoiceBoundToOffer] reports separately.
|
||||
*/
|
||||
private fun offerBindingHardFailure(
|
||||
offer: Bolt12Offer,
|
||||
proof: Bolt12PayerProof,
|
||||
): Reason? {
|
||||
val issuerId = offer.issuerId() ?: return null
|
||||
|
||||
proof.offerIssuerId()?.let { proofIssuer ->
|
||||
if (!proofIssuer.contentEquals(issuerId)) return Reason.OFFER_PROOF_MISMATCH
|
||||
}
|
||||
|
||||
if (!offer.hasPaths()) {
|
||||
val nodeId = proof.invoiceNodeId() ?: return Reason.PROOF_MISSING_REQUIRED_FIELDS
|
||||
if (!nodeId.contentEquals(issuerId)) return Reason.OFFER_PROOF_MISMATCH
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when the settled invoice can be cryptographically tied to the offer:
|
||||
* the offer publishes an `offer_issuer_id`, uses no blinded paths, and the proof's
|
||||
* `invoice_node_id` equals that issuer (so the invoice signature the verifier
|
||||
* checks was made by the offer's own node). Blinded-path or issuer-less offers
|
||||
* expose a payer-chosen node key that can't be bound to the offer here, so a
|
||||
* self-consistent proof against such an offer proves nothing about paying it.
|
||||
*/
|
||||
private fun isInvoiceBoundToOffer(
|
||||
offer: Bolt12Offer,
|
||||
proof: Bolt12PayerProof,
|
||||
): Boolean {
|
||||
val issuerId = offer.issuerId() ?: return false
|
||||
if (offer.hasPaths()) return false
|
||||
val nodeId = proof.invoiceNodeId() ?: return false
|
||||
return nodeId.contentEquals(issuerId)
|
||||
}
|
||||
|
||||
private fun mapProofReason(reason: Bolt12ProofResult.Reason): Reason =
|
||||
when (reason) {
|
||||
Bolt12ProofResult.Reason.MISSING_REQUIRED_FIELDS -> Reason.PROOF_MISSING_REQUIRED_FIELDS
|
||||
Bolt12ProofResult.Reason.PREIMAGE_MISMATCH -> Reason.PROOF_PREIMAGE_MISMATCH
|
||||
Bolt12ProofResult.Reason.RECONSTRUCTION_FAILED -> Reason.PROOF_RECONSTRUCTION_FAILED
|
||||
Bolt12ProofResult.Reason.INVOICE_SIGNATURE_INVALID -> Reason.PROOF_INVOICE_SIGNATURE_INVALID
|
||||
Bolt12ProofResult.Reason.PROOF_SIGNATURE_INVALID -> Reason.PROOF_SIGNATURE_INVALID
|
||||
Bolt12ProofResult.Reason.MALFORMED_KEY -> Reason.PROOF_MALFORMED_KEY
|
||||
}
|
||||
|
||||
private fun invalid(reason: Reason) = Bolt12ZapValidation.Invalid(reason)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The NIP binds the Lightning payment to the signed intent through the
|
||||
* BOLT12 `invreq_payer_note`, which MUST equal this prefix followed by the
|
||||
* intent event id. The `nipXX` segment tracks the final NIP number.
|
||||
*/
|
||||
const val NIP_URI_PREFIX = "nostr:nipXX:"
|
||||
}
|
||||
}
|
||||
|
||||
private typealias Reason = Bolt12ZapValidation.Reason
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.zap
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
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.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-XX: BOLT12 Zaps — the **zap event** (kind 9736).
|
||||
*
|
||||
* A public, self-verifying proof that a BOLT12 payment was made to the author of
|
||||
* a profile, event, or addressable event. It carries:
|
||||
* - the serialized kind 9737 zap intent in its `description` tag,
|
||||
* - the recipient / amount / offer copied from that intent, and
|
||||
* - the settled BOLT12 `lnp` payer proof in its `proof` tag.
|
||||
*
|
||||
* This is the only event counted as a BOLT12 zap. Counting clients MUST run it
|
||||
* through the validator (structure + intent match + payer-proof binding) and
|
||||
* deduplicate by the proof's `invoice_payment_hash` before adding its amount.
|
||||
*/
|
||||
@Immutable
|
||||
class Bolt12ZapEvent(
|
||||
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,
|
||||
SearchableEvent {
|
||||
// The public zap comment; it mirrors the embedded intent's content.
|
||||
override fun indexableContent() = content
|
||||
|
||||
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 raw serialized zap intent JSON from the `description` tag. */
|
||||
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
|
||||
|
||||
/** The parsed & typed embedded zap intent, or null if it isn't a valid kind 9737 event. */
|
||||
val zapIntent: Bolt12ZapIntentEvent? by lazy { containedIntent() }
|
||||
|
||||
private fun containedIntent(): Bolt12ZapIntentEvent? =
|
||||
try {
|
||||
description()?.ifBlank { null }?.let { Event.fromJson(it) } as? Bolt12ZapIntentEvent
|
||||
} catch (e: Exception) {
|
||||
Log.w("Bolt12ZapEvent", "Failed to parse embedded zap intent in event $id", e)
|
||||
null
|
||||
}
|
||||
|
||||
/** The recipient pubkey (`p` tag). */
|
||||
fun recipient() = tags.firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The claimed amount in millisatoshis (`amount` tag). Verified against the payer proof. */
|
||||
fun amount() = tags.firstNotNullOfOrNull(AmountTag::parse)
|
||||
|
||||
/** The canonical raw BOLT12 offer (`offer` tag). */
|
||||
fun offer() = tags.firstNotNullOfOrNull(OfferTag::parse)
|
||||
|
||||
/** The bech32 `lnp` payer proof (`proof` tag). */
|
||||
fun payerProof() = tags.firstNotNullOfOrNull(ProofTag::parse)
|
||||
|
||||
/** The payer pubkey (`P` tag), present only for publicly-attributed zaps. */
|
||||
fun payer() = tags.firstNotNullOfOrNull(PayerTag::parse)
|
||||
|
||||
/** The event being zapped, if any (`e` tag). */
|
||||
fun zappedEvent() = tags.firstNotNullOfOrNull(ETag::parseId)
|
||||
|
||||
/** The addressable event being zapped, if any (`a` tag). */
|
||||
fun zappedAddress() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
|
||||
|
||||
/** The kind of the target event, if declared (`k` tag). */
|
||||
fun zappedKind() = tags.firstNotNullOfOrNull(KindTag::parse)
|
||||
|
||||
/** True when neither `e` nor `a` is present — the zap targets the recipient's profile. */
|
||||
fun isProfileZap() = zappedEvent() == null && zappedAddress() == null
|
||||
|
||||
/** True when the zap is anonymous: it carries no `P` tag. */
|
||||
fun isAnonymous() = payer() == null
|
||||
|
||||
companion object {
|
||||
const val KIND = 9736
|
||||
|
||||
/**
|
||||
* Assemble a kind 9736 zap event from a **signed** zap intent and a settled
|
||||
* payer proof. The recipient, amount, offer, and target (`e`/`a`/`k`) tags
|
||||
* and the content are copied from the intent so they match, as the NIP
|
||||
* requires.
|
||||
*
|
||||
* @param payerPubKey when non-null, added as the uppercase `P` tag (a
|
||||
* publicly-attributed zap). It MUST be the same key that will sign this
|
||||
* event. Anonymous zaps pass null and sign with an ephemeral key.
|
||||
*/
|
||||
fun build(
|
||||
signedIntent: Bolt12ZapIntentEvent,
|
||||
payerProof: String,
|
||||
payerPubKey: HexKey? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<Bolt12ZapEvent>.() -> Unit = {},
|
||||
): EventTemplate<Bolt12ZapEvent> {
|
||||
val recipient = requireNotNull(signedIntent.recipient()) { "zap intent is missing its p tag" }
|
||||
val amount = requireNotNull(signedIntent.amount()) { "zap intent is missing its amount tag" }
|
||||
val offer = requireNotNull(signedIntent.offer()) { "zap intent is missing its offer tag" }
|
||||
|
||||
return eventTemplate(KIND, signedIntent.content, createdAt) {
|
||||
description(signedIntent.toJson())
|
||||
recipient(recipient)
|
||||
amountInMillisats(amount)
|
||||
offer(offer)
|
||||
proof(payerProof)
|
||||
payerPubKey?.let { payer(it) }
|
||||
// Copy the target tags verbatim so the zap and intent match exactly.
|
||||
signedIntent.tags.firstOrNull { ETag.isTagged(it) }?.let { add(it) }
|
||||
signedIntent.tags.firstOrNull { ATag.isTagged(it) }?.let { add(it) }
|
||||
signedIntent.tags.firstOrNull { KindTag.match(it) }?.let { add(it) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.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.nipXXBolt12Zaps.tags.AmountTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.OfferTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.PayerTag
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.tags.ProofTag
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.description(serializedIntentEventJson: String) = addUnique(DescriptionTag.assemble(serializedIntentEventJson))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.amountInMillisats(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.offer(canonicalOffer: String) = addUnique(OfferTag.assemble(canonicalOffer))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.proof(payerProof: String) = addUnique(ProofTag.assemble(payerProof))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.payer(payerPubKey: HexKey) = addUnique(PayerTag.assemble(payerPubKey))
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.zappedEvent(tag: ETag) = addUnique(tag.toTagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.zappedAddress(tag: ATag) = addUnique(tag.toATagArray())
|
||||
|
||||
fun TagArrayBuilder<Bolt12ZapEvent>.zappedKind(kind: Int) = addUnique(KindTag.assemble(kind))
|
||||
@@ -395,6 +395,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
@@ -731,6 +734,9 @@ class EventFactory {
|
||||
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)
|
||||
Bolt12ZapEvent.KIND -> Bolt12ZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
Bolt12ZapIntentEvent.KIND -> Bolt12ZapIntentEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
Bolt12OfferListEvent.KIND -> Bolt12OfferListEvent(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)
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.nip47WalletConnect
|
||||
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47RequestKSerializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47ResponseKSerializer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Exercises the **kotlinx** (native/iOS) NWC serializers directly — not through
|
||||
* `OptimizedJsonMapper`, whose JVM actual is Jackson — to cover the cross-backend
|
||||
* asymmetry: Jackson (JVM/Android) writes explicit `null` for every null field, and a
|
||||
* native peer parsing that output must read those as real nulls, not the string "null",
|
||||
* and must not crash on a null `metadata` object.
|
||||
*/
|
||||
class Nip47KotlinSerializationNullTest {
|
||||
@Test
|
||||
fun payRequestWithExplicitNullMetadataParsesWithoutCrashing() {
|
||||
val input = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc","amount":21000,"payer_note":"note","metadata":null}}"""
|
||||
val req = Json.decodeFromString(Nip47RequestKSerializer, input)
|
||||
assertIs<PayMethod>(req)
|
||||
assertEquals("bitcoin:?lno=lno1abc", req.params?.payment)
|
||||
assertEquals("note", req.params?.payer_note)
|
||||
assertNull(req.params?.metadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiveRequestWithExplicitNullMetadataParsesWithoutCrashing() {
|
||||
val input = """{"method":"receive","params":{"amount":21000,"description":null,"metadata":null}}"""
|
||||
val req = Json.decodeFromString(Nip47RequestKSerializer, input)
|
||||
assertIs<ReceiveMethod>(req)
|
||||
assertNull(req.params?.description)
|
||||
assertNull(req.params?.metadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paySuccessWithExplicitNullFieldsYieldsRealNullsNotTheStringNull() {
|
||||
val input =
|
||||
"""{"result_type":"pay","result":{"state":"settled","preimage":"abc","transaction_id":null,"txid":null,"failure_reason":null,"payer_proof":null,"instruction_type":null}}"""
|
||||
val resp = Json.decodeFromString(Nip47ResponseKSerializer, input)
|
||||
assertIs<PaySuccessResponse>(resp)
|
||||
assertEquals("settled", resp.result?.state)
|
||||
assertEquals("abc", resp.result?.preimage)
|
||||
assertNull(resp.result?.transaction_id)
|
||||
assertNull(resp.result?.txid)
|
||||
assertNull(resp.result?.failure_reason)
|
||||
assertNull(resp.result?.payer_proof)
|
||||
assertNull(resp.result?.instruction_type)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -48,6 +48,8 @@ class NwcMethodTest {
|
||||
assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE)
|
||||
assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE)
|
||||
assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE)
|
||||
assertEquals("pay", NwcMethod.PAY)
|
||||
assertEquals("receive", NwcMethod.RECEIVE)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,7 +62,7 @@ class NwcMethodTest {
|
||||
@Test
|
||||
fun testErrorCodeValues() {
|
||||
val codes = NwcErrorCode.entries
|
||||
assertEquals(13, codes.size)
|
||||
assertEquals(15, codes.size)
|
||||
assertEquals(NwcErrorCode.RATE_LIMITED, NwcErrorCode.valueOf("RATE_LIMITED"))
|
||||
assertEquals(NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.valueOf("NOT_IMPLEMENTED"))
|
||||
assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, NwcErrorCode.valueOf("INSUFFICIENT_BALANCE"))
|
||||
@@ -73,6 +75,8 @@ class NwcMethodTest {
|
||||
assertEquals(NwcErrorCode.BAD_REQUEST, NwcErrorCode.valueOf("BAD_REQUEST"))
|
||||
assertEquals(NwcErrorCode.NOT_FOUND, NwcErrorCode.valueOf("NOT_FOUND"))
|
||||
assertEquals(NwcErrorCode.EXPIRED, NwcErrorCode.valueOf("EXPIRED"))
|
||||
assertEquals(NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, NwcErrorCode.valueOf("UNSUPPORTED_PAYMENT_INSTRUCTION"))
|
||||
assertEquals(NwcErrorCode.UNSUPPORTED_NETWORK, NwcErrorCode.valueOf("UNSUPPORTED_NETWORK"))
|
||||
assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER"))
|
||||
}
|
||||
|
||||
|
||||
+63
@@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
|
||||
@@ -89,6 +91,67 @@ class RequestTest {
|
||||
assertEquals(1000L, request.params?.amount)
|
||||
}
|
||||
|
||||
// --- Pay (nwc#2 BOLT12/BIP321) ---
|
||||
|
||||
@Test
|
||||
fun testPayCreateWithOfferAndPayerNote() {
|
||||
val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef")
|
||||
assertEquals(NwcMethod.PAY, request.method)
|
||||
assertEquals("bitcoin:?lno=lno1abc", request.params?.payment)
|
||||
assertEquals(21_000L, request.params?.amount)
|
||||
assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPaySerialization() {
|
||||
val request = PayMethod.create("bitcoin:?lno=lno1abc", amount = 21_000L, payerNote = "nostr:nipXX:deadbeef")
|
||||
val json = OptimizedJsonMapper.toJson(request)
|
||||
assertTrue(json.contains("\"method\":\"pay\""))
|
||||
assertTrue(json.contains("\"payment\":\"bitcoin:?lno=lno1abc\""))
|
||||
assertTrue(json.contains("\"amount\":21000"))
|
||||
assertTrue(json.contains("\"payer_note\":\"nostr:nipXX:deadbeef\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPayDeserialization() {
|
||||
val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc","amount":21000,"payer_note":"nostr:nipXX:deadbeef"}}"""
|
||||
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
|
||||
assertIs<PayMethod>(request)
|
||||
assertEquals("bitcoin:?lno=lno1abc", request.params?.payment)
|
||||
assertEquals(21_000L, request.params?.amount)
|
||||
assertEquals("nostr:nipXX:deadbeef", request.params?.payer_note)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPayAmountlessDeserialization() {
|
||||
val json = """{"method":"pay","params":{"payment":"bitcoin:?lno=lno1abc"}}"""
|
||||
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
|
||||
assertIs<PayMethod>(request)
|
||||
assertEquals("bitcoin:?lno=lno1abc", request.params?.payment)
|
||||
assertNull(request.params?.amount)
|
||||
assertNull(request.params?.payer_note)
|
||||
}
|
||||
|
||||
// --- Receive (nwc#2) ---
|
||||
|
||||
@Test
|
||||
fun testReceiveSerialization() {
|
||||
val request = ReceiveMethod.create(amount = 21_000L, description = "tip jar")
|
||||
val json = OptimizedJsonMapper.toJson(request)
|
||||
assertTrue(json.contains("\"method\":\"receive\""))
|
||||
assertTrue(json.contains("\"amount\":21000"))
|
||||
assertTrue(json.contains("\"description\":\"tip jar\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReceiveDeserialization() {
|
||||
val json = """{"method":"receive","params":{"amount":21000,"description":"tip jar"}}"""
|
||||
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
|
||||
assertIs<ReceiveMethod>(request)
|
||||
assertEquals(21_000L, request.params?.amount)
|
||||
assertEquals("tip jar", request.params?.description)
|
||||
}
|
||||
|
||||
// --- PayKeysend ---
|
||||
|
||||
@Test
|
||||
|
||||
+49
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
|
||||
@@ -91,6 +93,53 @@ class ResponseTest {
|
||||
assertEquals(NwcErrorCode.PAYMENT_FAILED, response.error?.code)
|
||||
}
|
||||
|
||||
// --- Pay Success (nwc#2 BOLT12) ---
|
||||
|
||||
@Test
|
||||
fun testPaySuccessWithPayerProofDeserialization() {
|
||||
val json =
|
||||
"""{"result_type":"pay","result":{"state":"settled","instruction_type":"bolt12","amount":21000,"fees_paid":100,"payment_hash":"abc","preimage":"def","payer_proof":"lnp1xyz","settled_at":1693876497}}"""
|
||||
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
|
||||
assertIs<PaySuccessResponse>(response)
|
||||
assertEquals("settled", response.result?.state)
|
||||
assertEquals("bolt12", response.result?.instruction_type)
|
||||
assertEquals(21_000L, response.result?.amount)
|
||||
assertEquals(100L, response.result?.fees_paid)
|
||||
assertEquals("abc", response.result?.payment_hash)
|
||||
assertEquals("def", response.result?.preimage)
|
||||
assertEquals("lnp1xyz", response.result?.payer_proof)
|
||||
assertEquals(1693876497L, response.result?.settled_at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPaySuccessWithoutPayerProof() {
|
||||
// payer_proof is best-effort ("optional if unavailable") — absence must parse fine.
|
||||
val json = """{"result_type":"pay","result":{"state":"settled","preimage":"def"}}"""
|
||||
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
|
||||
assertIs<PaySuccessResponse>(response)
|
||||
assertEquals("def", response.result?.preimage)
|
||||
assertNull(response.result?.payer_proof)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPayErrorUnsupportedInstruction() {
|
||||
val json = """{"result_type":"pay","error":{"code":"UNSUPPORTED_PAYMENT_INSTRUCTION","message":"no bolt12"}}"""
|
||||
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
|
||||
assertIs<NwcErrorResponse>(response)
|
||||
assertEquals(NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, response.error?.code)
|
||||
}
|
||||
|
||||
// --- Receive Success (nwc#2) ---
|
||||
|
||||
@Test
|
||||
fun testReceiveSuccessDeserialization() {
|
||||
val json = """{"result_type":"receive","result":{"bip321":"bitcoin:?lightning=lnbc1&lno=lno1","transaction_id":"tx1"}}"""
|
||||
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
|
||||
assertIs<ReceiveSuccessResponse>(response)
|
||||
assertEquals("bitcoin:?lightning=lnbc1&lno=lno1", response.result?.bip321)
|
||||
assertEquals("tx1", response.result?.transaction_id)
|
||||
}
|
||||
|
||||
// --- PayKeysend Success ---
|
||||
|
||||
@Test
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12ZapEventTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
private val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4, 5, 6))
|
||||
private val proof = Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, byteArrayOf(7, 8, 9, 10))
|
||||
private val zapId = "ab".repeat(16)
|
||||
|
||||
private fun Array<Array<String>>.tag(name: String) = firstOrNull { it.isNotEmpty() && it[0] == name }
|
||||
|
||||
@Test
|
||||
fun profileZapIntentCarriesTheRequiredTags() {
|
||||
val template = Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "excellent note")
|
||||
|
||||
assertEquals(Bolt12ZapIntentEvent.KIND, template.kind)
|
||||
assertEquals("excellent note", template.content)
|
||||
assertEquals(listOf("p", recipient), template.tags.tag("p")?.toList())
|
||||
assertEquals(listOf("amount", "21000"), template.tags.tag("amount")?.toList())
|
||||
assertEquals(listOf("offer", offer), template.tags.tag("offer")?.toList())
|
||||
assertEquals(listOf("zap_id", zapId), template.tags.tag("zap_id")?.toList())
|
||||
assertNull(template.tags.tag("e"))
|
||||
assertNull(template.tags.tag("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventTargetedIntentCarriesEAndKTags() {
|
||||
val zapped =
|
||||
Event("b".repeat(64), "c".repeat(64), 1_700_000_000L, 1, emptyArray(), "hi", "d".repeat(128))
|
||||
val template = Bolt12ZapIntentEvent.build(recipient, 21_000L, offer, zapId, EventHintBundle(zapped))
|
||||
|
||||
val eTag = template.tags.tag("e")
|
||||
assertTrue(eTag != null && eTag[1] == "b".repeat(64))
|
||||
assertEquals(listOf("k", "1"), template.tags.tag("k")?.toList())
|
||||
assertNull(template.tags.tag("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zapEventEmbedsIntentAndCopiesFields() =
|
||||
runTest {
|
||||
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "nice"))
|
||||
val template = Bolt12ZapEvent.build(intent, proof, payerPubKey = signer.pubKey)
|
||||
|
||||
assertEquals(Bolt12ZapEvent.KIND, template.kind)
|
||||
assertEquals("nice", template.content)
|
||||
assertEquals(listOf("description", intent.toJson()), template.tags.tag("description")?.toList())
|
||||
assertEquals(listOf("p", recipient), template.tags.tag("p")?.toList())
|
||||
assertEquals(listOf("amount", "21000"), template.tags.tag("amount")?.toList())
|
||||
assertEquals(listOf("offer", offer), template.tags.tag("offer")?.toList())
|
||||
assertEquals(listOf("proof", proof), template.tags.tag("proof")?.toList())
|
||||
assertEquals(listOf("P", signer.pubKey), template.tags.tag("P")?.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anonymousZapOmitsThePayerTag() =
|
||||
runTest {
|
||||
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 1_000L, offer, zapId))
|
||||
val template = Bolt12ZapEvent.build(intent, proof, payerPubKey = null)
|
||||
assertNull(template.tags.tag("P"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsedBackAccessorsAndFactoryTypesAreCorrect() =
|
||||
runTest {
|
||||
val intent = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, 21_000L, offer, zapId, comment = "nice"))
|
||||
val zap = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = signer.pubKey))
|
||||
|
||||
assertEquals(recipient, zap.recipient())
|
||||
assertEquals(21_000L, zap.amount())
|
||||
assertEquals(offer, zap.offer())
|
||||
assertEquals(proof, zap.payerProof())
|
||||
assertEquals(signer.pubKey, zap.payer())
|
||||
assertTrue(zap.isProfileZap())
|
||||
assertEquals(intent.id, zap.zapIntent?.id)
|
||||
|
||||
// The factory (used by fromJson / relay ingestion) resolves the right types.
|
||||
assertTrue(Event.fromJson(zap.toJson()) is Bolt12ZapEvent)
|
||||
assertTrue(Event.fromJson(intent.toJson()) is Bolt12ZapIntentEvent)
|
||||
}
|
||||
}
|
||||
+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.quartz.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12Bech32Test {
|
||||
@Test
|
||||
fun canonicalizeStripsContinuationsAndWhitespaceAndLowercases() {
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("LNO1ABC+ DEF"))
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("lno1abc+\n def"))
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize("lno1abc + def"))
|
||||
assertEquals("lno1abcdef", Bolt12Bech32.canonicalize(" lno1abcdef "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripsArbitraryBytesWithoutChecksumOrLengthCap() {
|
||||
// 200 bytes — far beyond the BIP-173 90-char cap that plain bech32 enforces.
|
||||
val bytes = ByteArray(200) { (it * 7 + 3).toByte() }
|
||||
val encoded = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, bytes)
|
||||
assertTrue(encoded.startsWith("lno1"))
|
||||
assertContentEquals(bytes, Bolt12Bech32.decodeToBytes(encoded, Bolt12Bech32.OFFER_HRP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recognizesOfferAndProofPrefixes() {
|
||||
val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4))
|
||||
val proof = Bolt12Bech32.encode(Bolt12Bech32.PAYER_PROOF_HRP, byteArrayOf(1, 2, 3, 4))
|
||||
|
||||
assertTrue(Bolt12Bech32.isOffer(offer))
|
||||
assertFalse(Bolt12Bech32.isPayerProof(offer))
|
||||
assertTrue(Bolt12Bech32.isPayerProof(proof))
|
||||
assertFalse(Bolt12Bech32.isOffer(proof))
|
||||
|
||||
// Non-BOLT12 or malformed strings are rejected.
|
||||
assertFalse(Bolt12Bech32.isOffer("not an offer"))
|
||||
assertFalse(Bolt12Bech32.isOffer("lno1"))
|
||||
assertFalse(Bolt12Bech32.isPayerProof("lnbc1abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodingWithMismatchedPrefixFails() {
|
||||
val offer = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(9, 9, 9))
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
Bolt12Bech32.decodeToBytes(offer, Bolt12Bech32.PAYER_PROOF_HRP)
|
||||
}
|
||||
assertEquals(null, Bolt12Bech32.decodeToBytesOrNull("garbage", Bolt12Bech32.OFFER_HRP))
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12MerkleTest {
|
||||
@Test
|
||||
fun taggedHashMatchesTheDefinition() {
|
||||
val tag = "LnLeaf".encodeToByteArray()
|
||||
val msg = byteArrayOf(1, 2, 3, 4)
|
||||
val tagHash = sha256(tag)
|
||||
val expected = sha256(tagHash + tagHash + msg)
|
||||
assertContentEquals(expected, Bolt12Merkle.taggedHash(tag, msg))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rootHashIsDeterministicAndDependsOnEveryRecord() {
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(22, ByteArray(33) { 2 }),
|
||||
TlvRecord(170, Bolt12Values.tu64ToBytes(21_000)),
|
||||
TlvRecord(176, ByteArray(33) { 3 }),
|
||||
)
|
||||
val root = Bolt12Merkle.rootHash(records)
|
||||
assertEquals(32, root.size)
|
||||
assertContentEquals(root, Bolt12Merkle.rootHash(records))
|
||||
|
||||
val altered =
|
||||
records.toMutableList().also {
|
||||
it[1] = TlvRecord(170, Bolt12Values.tu64ToBytes(21_001))
|
||||
}
|
||||
assertFalse(root.contentEquals(Bolt12Merkle.rootHash(altered)))
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end check of the tagged-hash → merkle-root → signature-digest → BIP-340
|
||||
* pipeline: a signature made over the digest of a record set verifies, and any
|
||||
* tampering with the records (which changes the root) makes it fail. This
|
||||
* validates the composition; byte-exact interop with CLN/LDK proofs additionally
|
||||
* needs the lightning/bolts#1346 test vectors.
|
||||
*/
|
||||
@Test
|
||||
fun signatureOverTheMerkleRootVerifiesAndTamperingBreaksIt() {
|
||||
val key = KeyPair()
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(88, ByteArray(33) { 2 }),
|
||||
TlvRecord(168, ByteArray(32) { it.toByte() }),
|
||||
TlvRecord(176, ByteArray(33) { 3 }),
|
||||
)
|
||||
|
||||
val root = Bolt12Merkle.rootHash(records)
|
||||
val digest = Bolt12Merkle.signatureDigest("invoice", "signature", root)
|
||||
val sig = Nip01Crypto.sign(digest, key.privKey!!)
|
||||
|
||||
assertTrue(Nip01Crypto.verify(sig, digest, key.pubKey))
|
||||
|
||||
val tamperedRoot = Bolt12Merkle.rootHash(records.dropLast(1))
|
||||
val tamperedDigest = Bolt12Merkle.signatureDigest("invoice", "signature", tamperedRoot)
|
||||
assertFalse(Nip01Crypto.verify(sig, tamperedDigest, key.pubKey))
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.bolt12
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class TlvTest {
|
||||
private fun hex(bytes: ByteArray) = Hex.encode(bytes)
|
||||
|
||||
@Test
|
||||
fun bigSizeEncodesTheFourFormsAtTheirBoundaries() {
|
||||
assertEquals("00", hex(BigSize.encode(0)))
|
||||
assertEquals("fc", hex(BigSize.encode(0xfc)))
|
||||
assertEquals("fd00fd", hex(BigSize.encode(0xfd)))
|
||||
assertEquals("fdffff", hex(BigSize.encode(0xffff)))
|
||||
assertEquals("fe00010000", hex(BigSize.encode(0x10000)))
|
||||
assertEquals("feffffffff", hex(BigSize.encode(0xffffffffL)))
|
||||
assertEquals("ff0000000100000000", hex(BigSize.encode(0x100000000L)))
|
||||
|
||||
assertEquals(1, BigSize.encodedSize(0xfc))
|
||||
assertEquals(3, BigSize.encodedSize(0xfd))
|
||||
assertEquals(5, BigSize.encodedSize(0x10000))
|
||||
assertEquals(9, BigSize.encodedSize(0x100000000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bigSizeRoundTripsThroughTheReader() {
|
||||
for (v in listOf(0L, 1L, 0xfcL, 0xfdL, 0x1234L, 0xffffL, 0x10000L, 0xdeadbeefL, 9736L, 1001L)) {
|
||||
val reader = TlvReader(BigSize.encode(v))
|
||||
assertEquals(v, reader.readBigSize())
|
||||
assertEquals(0, reader.remaining())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tu64StripsAndRestoresLeadingZeroes() {
|
||||
assertEquals(0, Bolt12Values.tu64ToBytes(0).size)
|
||||
assertContentEquals(byteArrayOf(0x03, 0xe8.toByte()), Bolt12Values.tu64ToBytes(1000))
|
||||
for (v in listOf(0L, 1L, 21_000L, 0xffffffL, Long.MAX_VALUE)) {
|
||||
assertEquals(v, Bolt12Values.tu64(Bolt12Values.tu64ToBytes(v)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tu64FieldReturnsNullForAnOversizedValueInsteadOfThrowing() {
|
||||
// A tu64 value must be at most 8 bytes. A hostile proof/offer can carry a 9-byte
|
||||
// amount that parses as a TLV; reading it via the stream accessor must degrade to
|
||||
// null (a clean rejection on the validation path), not throw.
|
||||
val stream = TlvStream(listOf(TlvRecord(170, ByteArray(9) { 1 })))
|
||||
val decoded = TlvStream.read(stream.encode())
|
||||
assertNull(decoded.tu64(170))
|
||||
// The exact-8-byte boundary still decodes.
|
||||
assertEquals(Long.MAX_VALUE, TlvStream(listOf(TlvRecord(170, Bolt12Values.tu64ToBytes(Long.MAX_VALUE)))).tu64(170))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tlvStreamRoundTrips() {
|
||||
val records =
|
||||
listOf(
|
||||
TlvRecord(8, Bolt12Values.tu64ToBytes(21_000)),
|
||||
TlvRecord(22, ByteArray(33) { it.toByte() }),
|
||||
TlvRecord(1001, ByteArray(32) { (it + 1).toByte() }),
|
||||
)
|
||||
val stream = TlvStream(records)
|
||||
val decoded = TlvStream.read(stream.encode())
|
||||
|
||||
assertEquals(records.map { it.type }, decoded.records.map { it.type })
|
||||
assertEquals(21_000L, decoded.tu64(8))
|
||||
assertContentEquals(records[1].value, decoded.value(22))
|
||||
assertNull(decoded.get(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tlvStreamRejectsNonAscendingTypes() {
|
||||
val outOfOrder = TlvRecord(22, byteArrayOf(1)).encoded + TlvRecord(8, byteArrayOf(2)).encoded
|
||||
assertFailsWith<IllegalArgumentException> { TlvStream.read(outOfOrder) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bigSizeRejectsNonMinimalEncodings() {
|
||||
// 5 encoded in the 3-byte (0xfd) form instead of a single byte.
|
||||
assertFailsWith<IllegalArgumentException> { TlvReader(byteArrayOf(0xfd.toByte(), 0x00, 0x05)).readBigSize() }
|
||||
// 0x100 encoded in the 5-byte (0xfe) form instead of 3.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
TlvReader(byteArrayOf(0xfe.toByte(), 0x00, 0x00, 0x01, 0x00)).readBigSize()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tlvStreamRejectsAnOversizedOrHighBitLength() {
|
||||
// type=1 (minimal), length=0xff FF FF FF FF 00 00 00 05 — reads back negative / huge.
|
||||
val hostile =
|
||||
byteArrayOf(0x01) +
|
||||
byteArrayOf(0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0x00, 0x00, 0x00, 0x05)
|
||||
assertFailsWith<IllegalArgumentException> { TlvStream.read(hostile) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatureElementRangeIsRecognized() {
|
||||
assertEquals(false, TlvRecord(176, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(true, TlvRecord(240, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(true, TlvRecord(241, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(true, TlvRecord(1000, byteArrayOf()).isSignatureElement())
|
||||
assertEquals(false, TlvRecord(1001, byteArrayOf()).isSignatureElement())
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.builder
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ProofFixture
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Proves the send-side assembly ([Bolt12ZapBuilder.buildProfileIntent] →
|
||||
* [Bolt12ZapBuilder.payerNote] → [Bolt12ZapBuilder.buildZap]) — the exact path
|
||||
* `Account.sendBolt12Zap` drives — produces a kind:9736 the validator accepts. Uses
|
||||
* a self-consistent fixture proof bound to the built intent; byte-exact wallet
|
||||
* interop is covered separately by [Bolt12PayerProofVectorTest].
|
||||
*/
|
||||
class Bolt12ZapBuilderTest {
|
||||
private val validator = Bolt12ZapValidator()
|
||||
private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
private val amount = 21_000L
|
||||
|
||||
@Test
|
||||
fun payerNoteBindsToTheIntentId() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val offer = Bolt12ProofFixture.buildOffer(KeyPair(), amount)
|
||||
val intent = Bolt12ZapBuilder.buildProfileIntent(signer, recipient, amount, offer, comment = "nice")
|
||||
|
||||
assertEquals(Bolt12ZapValidator.NIP_URI_PREFIX + intent.id, Bolt12ZapBuilder.payerNote(intent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun builderProducesAValidatorAcceptedAttributedZap() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 7).toByte() }
|
||||
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = Bolt12ZapBuilder.buildProfileIntent(signer, recipient, amount, offer, comment = "nice")
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, Bolt12ZapBuilder.payerNote(intent))
|
||||
|
||||
val zap = Bolt12ZapBuilder.buildZap(signer, intent, proof, anonymous = false)
|
||||
|
||||
val result = validator.validate(zap)
|
||||
assertIs<Bolt12ZapValidation.Valid>(result)
|
||||
assertTrue(result.proofCryptoVerified)
|
||||
assertEquals(recipient, result.recipient)
|
||||
assertEquals(signer.pubKey, result.payer)
|
||||
assertEquals(amount, result.amountMillisats)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anonymousBuilderProducesAValidZapWithNoPayerTag() =
|
||||
runTest {
|
||||
// Anonymous zaps sign intent AND zap with one ephemeral key and carry no P tag.
|
||||
val ephemeral = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 11).toByte() }
|
||||
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = Bolt12ZapBuilder.buildProfileIntent(ephemeral, recipient, amount, offer)
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, Bolt12ZapBuilder.payerNote(intent))
|
||||
|
||||
val zap = Bolt12ZapBuilder.buildZap(ephemeral, intent, proof, anonymous = true)
|
||||
|
||||
val result = validator.validate(zap)
|
||||
assertIs<Bolt12ZapValidation.Valid>(result)
|
||||
assertTrue(result.proofCryptoVerified)
|
||||
assertNull(result.payer, "an anonymous BOLT12 zap carries no P tag")
|
||||
}
|
||||
}
|
||||
+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.nipXXBolt12Zaps.offer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12OfferListEventTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val offerA = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(1, 2, 3, 4))
|
||||
private val offerB = Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, byteArrayOf(5, 6, 7, 8))
|
||||
|
||||
@Test
|
||||
fun holdsOffersAndResolvesTheFactoryType() =
|
||||
runTest {
|
||||
val event = Bolt12OfferListEvent.create(listOf(offerA, offerB), signer)
|
||||
|
||||
assertEquals(Bolt12OfferListEvent.KIND, event.kind)
|
||||
assertEquals("", event.content)
|
||||
assertEquals(listOf(offerA, offerB), event.offers())
|
||||
assertEquals(offerA, event.firstOffer())
|
||||
assertTrue(Event.fromJson(event.toJson()) is Bolt12OfferListEvent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresMalformedOfferTags() =
|
||||
runTest {
|
||||
// Manually build a list with one good and one junk offer tag.
|
||||
val event =
|
||||
Bolt12OfferListEvent(
|
||||
id = "a".repeat(64),
|
||||
pubKey = "b".repeat(64),
|
||||
createdAt = 1_700_000_000L,
|
||||
tags = arrayOf(arrayOf("offer", offerA), arrayOf("offer", "not-an-offer")),
|
||||
content = "",
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
assertEquals(listOf(offerA), event.offers())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateOffersReplacesTheOfferSetKeepingOtherTags() =
|
||||
runTest {
|
||||
val original =
|
||||
signer.sign(
|
||||
1_700_000_000L,
|
||||
Bolt12OfferListEvent.KIND,
|
||||
arrayOf(arrayOf("offer", offerA), arrayOf("alt", "my offers")),
|
||||
"",
|
||||
) as Bolt12OfferListEvent
|
||||
|
||||
val updated = Bolt12OfferListEvent.updateOffers(original, listOf(offerB), signer)
|
||||
|
||||
assertEquals(listOf(offerB), updated.offers())
|
||||
// The non-offer tag survives the update.
|
||||
assertTrue(updated.tags.any { it.isNotEmpty() && it[0] == "alt" && it[1] == "my offers" })
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.TestResourceLoader
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12ProofBuilder
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Byte-exact interop against the lightning/bolts#1346 conformance suite
|
||||
* (`bolt12/payer-proof-test.json`), the draft's own CLN/LDK-generated vectors.
|
||||
*
|
||||
* - Every `valid_vectors` entry must fully verify ([Bolt12ProofVerifier] returns
|
||||
* [Bolt12ProofResult.Valid]) — this exercises the compressed-proof merkle
|
||||
* reconstruction and both BIP-340 signature checks against real proofs.
|
||||
* - Every `invalid_vectors` entry must be rejected (never [Bolt12ProofResult.Valid]).
|
||||
* - The writer ([Bolt12ProofBuilder]) reproduces each valid vector's
|
||||
* `proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` exactly.
|
||||
*/
|
||||
class Bolt12PayerProofVectorTest {
|
||||
private val vectors by lazy {
|
||||
Json.parseToJsonElement(TestResourceLoader().loadString("bolt12/payer-proof-test.json")).jsonObject
|
||||
}
|
||||
|
||||
private val verifier = Bolt12ProofVerifier()
|
||||
|
||||
@Test
|
||||
fun everyValidVectorVerifies() {
|
||||
val valid = vectors["valid_vectors"]!!.jsonArray
|
||||
assertTrue(valid.size >= 5, "expected the full valid vector set")
|
||||
for (vector in valid) {
|
||||
val obj = vector.jsonObject
|
||||
val name = obj["name"]!!.jsonPrimitive.content
|
||||
val bech32 = obj["result"]!!.jsonObject["bech32"]!!.jsonPrimitive.content
|
||||
val proof = Bolt12PayerProof.parse(bech32) ?: fail("valid vector '$name' failed to parse")
|
||||
val result = verifier.verify(proof)
|
||||
assertIs<Bolt12ProofResult.Valid>(result, "valid vector '$name' did not verify: $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyInvalidVectorIsRejected() {
|
||||
val invalid = vectors["invalid_vectors"]!!.jsonArray
|
||||
assertTrue(invalid.size >= 20, "expected the full invalid vector set")
|
||||
for (vector in invalid) {
|
||||
val obj = vector.jsonObject
|
||||
val reason = obj["reason"]?.jsonPrimitive?.content ?: "?"
|
||||
val bech32 = obj["bech32"]!!.jsonPrimitive.content
|
||||
val proof = Bolt12PayerProof.parse(bech32)
|
||||
// Rejection is either an unparseable stream or any non-Valid crypto result.
|
||||
val verified = proof?.let { verifier.verify(it) }
|
||||
assertTrue(
|
||||
verified !is Bolt12ProofResult.Valid,
|
||||
"invalid vector '$reason' was accepted",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writerReproducesEveryValidVectorCompressionFields() {
|
||||
val valid = vectors["valid_vectors"]!!.jsonArray
|
||||
for (vector in valid) {
|
||||
val obj = vector.jsonObject
|
||||
val name = obj["name"]!!.jsonPrimitive.content
|
||||
val working = obj["working"]!!.jsonObject
|
||||
|
||||
val invoiceFields =
|
||||
obj["input"]!!
|
||||
.jsonObject["invoice_fields"]!!
|
||||
.jsonArray
|
||||
.map { it.jsonObject }
|
||||
.filter { it["type"]!!.jsonPrimitive.content.toLong() !in 240L..1000L }
|
||||
.map {
|
||||
Bolt12ProofBuilder.InvoiceField(
|
||||
type = it["type"]!!.jsonPrimitive.content.toLong(),
|
||||
value = Hex.decode(it["hex"]!!.jsonPrimitive.content),
|
||||
include = it["included"]!!.jsonPrimitive.content.toBoolean(),
|
||||
)
|
||||
}
|
||||
|
||||
// Deterministic compression fields don't depend on the signatures, so
|
||||
// dummy signers suffice; we read the minted proof back and compare.
|
||||
val note = obj["input"]!!.jsonObject["note"]?.jsonPrimitive?.content
|
||||
val minted =
|
||||
Bolt12ProofBuilder.build(
|
||||
invoiceFields = invoiceFields,
|
||||
preimage = Hex.decode(obj["input"]!!.jsonObject["preimage"]!!.jsonPrimitive.content),
|
||||
proofNote = note,
|
||||
signInvoiceDigest = { ByteArray(64) },
|
||||
signProofDigest = { ByteArray(64) },
|
||||
)
|
||||
val proof = Bolt12PayerProof.parse(minted) ?: fail("writer output for '$name' failed to parse")
|
||||
|
||||
val expectedMarkers = working["proof_omitted_tlvs"]!!.jsonArray.map { it.jsonPrimitive.content.toLong() }
|
||||
assertEquals(expectedMarkers, proof.omittedTlvMarkers(), "proof_omitted_tlvs mismatch for '$name'")
|
||||
|
||||
val expectedMissing = working["proof_missing_hashes"]!!.jsonArray.map { it.jsonPrimitive.content }
|
||||
assertEquals(expectedMissing, proof.missingHashList()!!.map { Hex.encode(it) }, "proof_missing_hashes mismatch for '$name'")
|
||||
|
||||
val expectedLeaves = working["proof_leaf_hashes"]!!.jsonArray.map { it.jsonPrimitive.content }
|
||||
assertEquals(expectedLeaves, proof.leafHashList()!!.map { Hex.encode(it) }, "proof_leaf_hashes mismatch for '$name'")
|
||||
|
||||
// The optional proof_note (1005) must round-trip when the vector carries one.
|
||||
assertEquals(note, proof.proofNote(), "proof_note mismatch for '$name'")
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.verify
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12ProofBuilder
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* Builds matched BOLT12 offers and payer proofs for tests, minting spec-compliant
|
||||
* (lightning/bolts#1346) proofs through [Bolt12ProofBuilder] and self-signing them
|
||||
* with Quartz's own secp256k1 so the whole reconstruction + merkle + BIP-340 path
|
||||
* is exercised. This is a self-consistent construction; byte-exact CLN/LDK interop
|
||||
* is covered separately by [Bolt12PayerProofVectorTest].
|
||||
*/
|
||||
object Bolt12ProofFixture {
|
||||
/** A 33-byte compressed point (even parity) wrapping an x-only key. */
|
||||
private fun point(xOnly: ByteArray) = byteArrayOf(0x02) + xOnly
|
||||
|
||||
fun buildOffer(
|
||||
nodeKey: KeyPair,
|
||||
amountMillisats: Long,
|
||||
withIssuerId: Boolean = true,
|
||||
): String {
|
||||
// TLV types must be strictly ascending: amount(8), description(10), issuer_id(22).
|
||||
val records =
|
||||
buildList {
|
||||
add(TlvRecord(Bolt12Offer.TYPE_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats)))
|
||||
add(TlvRecord(Bolt12Offer.TYPE_DESCRIPTION, "zap".encodeToByteArray()))
|
||||
if (withIssuerId) add(TlvRecord(Bolt12Offer.TYPE_ISSUER_ID, point(nodeKey.pubKey)))
|
||||
}
|
||||
return Bolt12Bech32.encode(Bolt12Bech32.OFFER_HRP, TlvStream(records).encode())
|
||||
}
|
||||
|
||||
fun buildProof(
|
||||
nodeKey: KeyPair,
|
||||
payerLightningKey: KeyPair,
|
||||
preimage: ByteArray,
|
||||
amountMillisats: Long,
|
||||
payerNote: String,
|
||||
compressed: Boolean = false,
|
||||
breakProofSignature: Boolean = false,
|
||||
breakInvoiceSignature: Boolean = false,
|
||||
corruptPaymentHash: Boolean = false,
|
||||
): String {
|
||||
val nodePoint = point(nodeKey.pubKey)
|
||||
val payerPoint = point(payerLightningKey.pubKey)
|
||||
// A corrupt hash still yields a valid signature over the corrupted records —
|
||||
// the preimage check (SHA256(preimage) != invoice_payment_hash) is what rejects it.
|
||||
val paymentHash = sha256(preimage).also { if (corruptPaymentHash) it[0] = (it[0] + 1).toByte() }
|
||||
|
||||
// The full invoice's non-signature TLVs, ascending. invreq_metadata (type 0)
|
||||
// is always present and always withheld; invreq_amount (82) is the field the
|
||||
// `compressed` flag selectively omits.
|
||||
val invoiceFields =
|
||||
listOf(
|
||||
Bolt12ProofBuilder.InvoiceField(TYPE_INVREQ_METADATA, ByteArray(16), include = false),
|
||||
Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_OFFER_ISSUER_ID, nodePoint, include = true),
|
||||
Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVREQ_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats), include = !compressed),
|
||||
Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVREQ_PAYER_ID, payerPoint, include = true),
|
||||
Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVREQ_PAYER_NOTE, payerNote.encodeToByteArray(), include = true),
|
||||
Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVOICE_PAYMENT_HASH, paymentHash, include = true),
|
||||
Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVOICE_AMOUNT, Bolt12Values.tu64ToBytes(amountMillisats), include = true),
|
||||
Bolt12ProofBuilder.InvoiceField(Bolt12PayerProof.TYPE_INVOICE_NODE_ID, nodePoint, include = true),
|
||||
)
|
||||
|
||||
val invoiceSigningKey = if (breakInvoiceSignature) payerLightningKey else nodeKey
|
||||
val proofSigningKey = if (breakProofSignature) nodeKey else payerLightningKey
|
||||
|
||||
return Bolt12ProofBuilder.build(
|
||||
invoiceFields = invoiceFields,
|
||||
preimage = preimage,
|
||||
signInvoiceDigest = { digest -> Nip01Crypto.sign(digest, invoiceSigningKey.privKey!!) },
|
||||
signProofDigest = { digest -> Nip01Crypto.sign(digest, proofSigningKey.privKey!!) },
|
||||
)
|
||||
}
|
||||
|
||||
private const val TYPE_INVREQ_METADATA = 0L
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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.nipXXBolt12Zaps.verify
|
||||
|
||||
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.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Bolt12ZapValidatorTest {
|
||||
private val validator = Bolt12ZapValidator()
|
||||
private val recipient = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
private val amount = 21_000L
|
||||
private val zapId = "ab".repeat(16)
|
||||
|
||||
private suspend fun signedIntent(
|
||||
signer: NostrSigner,
|
||||
offer: String,
|
||||
) = signer.sign(Bolt12ZapIntentEvent.buildProfileZap(recipient, amount, offer, zapId, comment = "nice"))
|
||||
|
||||
private suspend fun signedZap(
|
||||
signer: NostrSigner,
|
||||
intent: Bolt12ZapIntentEvent,
|
||||
proof: String,
|
||||
attributed: Boolean = true,
|
||||
) = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = if (attributed) signer.pubKey else null))
|
||||
|
||||
@Test
|
||||
fun acceptsAWellFormedFullyVerifiedZap() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val payerLnKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 7).toByte() }
|
||||
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, payerLnKey, preimage, amount, note)
|
||||
val zap = signedZap(signer, intent, proof)
|
||||
|
||||
val result = validator.validate(zap)
|
||||
assertIs<Bolt12ZapValidation.Valid>(result)
|
||||
assertTrue(result.proofCryptoVerified)
|
||||
assertEquals(recipient, result.recipient)
|
||||
assertEquals(signer.pubKey, result.payer)
|
||||
assertEquals(amount, result.amountMillisats)
|
||||
assertEquals(Hex.encode(sha256(preimage)), result.paymentHashHex)
|
||||
assertTrue(result.isProfileZap)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifiesACompressedProofThroughMerkleReconstruction() =
|
||||
runTest {
|
||||
// The proof withholds `invreq_amount`, forcing the verifier to rebuild the
|
||||
// invoice merkle root from `proof_missing_hashes` before checking the
|
||||
// invoice signature. Bound to a directly-addressed offer, it is fully verified.
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 1).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, compressed = true)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertIs<Bolt12ZapValidation.Valid>(result)
|
||||
assertTrue(result.proofCryptoVerified, "a compressed proof bound to the offer must verify")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAProofBoundToTheWrongIntent() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 2).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val wrongNote = Bolt12ZapValidator.NIP_URI_PREFIX + "f".repeat(64)
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, wrongNote)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_NOTE_MISMATCH), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenTheProofAmountDiffersFromTheZapAmount() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 3).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount + 1, note)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_AMOUNT_MISMATCH), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAnInvalidPayerProofSignature() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 4).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, breakProofSignature = true)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_SIGNATURE_INVALID), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenTheEmbeddedIntentWasSignedByAnotherKey() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val otherSigner = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 5).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
// Intent signed by someone other than the zap author.
|
||||
val intent = signedIntent(otherSigner, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.INTENT_PUBKEY_MISMATCH), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenThePreimageDoesNotHashToThePaymentHash() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 6).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, corruptPaymentHash = true)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_PREIMAGE_MISMATCH), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAnInvalidInvoiceSignature() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 8).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note, breakInvoiceSignature = true)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PROOF_INVOICE_SIGNATURE_INVALID), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAPayerTagThatIsNotTheEventAuthor() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 9).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
|
||||
|
||||
// Attribute the zap to someone other than its signer.
|
||||
val zap = signer.sign(Bolt12ZapEvent.build(intent, proof, payerPubKey = "c".repeat(64)))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.PAYER_TAG_MISMATCH), validator.validate(zap))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIssuerlessOfferCannotBindTheInvoiceSoTheZapIsAcceptedButUnverified() =
|
||||
runTest {
|
||||
// The offer publishes no offer_issuer_id, so the invoice's node key can't be
|
||||
// tied to the offer — a self-consistent proof proves nothing about paying it.
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 12).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount, withIssuerId = false)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertIs<Bolt12ZapValidation.Valid>(result)
|
||||
assertTrue(!result.proofCryptoVerified, "an offer with no issuer id cannot be crypto-verified")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenTheProofDoesNotMatchTheOffer() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val offerNodeKey = KeyPair()
|
||||
val proofNodeKey = KeyPair() // a different node than the offer's issuer
|
||||
val preimage = ByteArray(32) { (it + 10).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(offerNodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(proofNodeKey, KeyPair(), preimage, amount, note)
|
||||
|
||||
val result = validator.validate(signedZap(signer, intent, proof))
|
||||
assertEquals(Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.OFFER_PROOF_MISMATCH), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsTheEventSignatureCheckWhenTheCallerAlreadyVerifiedIt() =
|
||||
runTest {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val nodeKey = KeyPair()
|
||||
val preimage = ByteArray(32) { (it + 11).toByte() }
|
||||
val offer = Bolt12ProofFixture.buildOffer(nodeKey, amount)
|
||||
val intent = signedIntent(signer, offer)
|
||||
val note = Bolt12ZapValidator.NIP_URI_PREFIX + intent.id
|
||||
val proof = Bolt12ProofFixture.buildProof(nodeKey, KeyPair(), preimage, amount, note)
|
||||
val validZap = signedZap(signer, intent, proof)
|
||||
|
||||
// Same event, but its own signature is corrupted (id still matches content).
|
||||
val tamperedSig =
|
||||
Bolt12ZapEvent(validZap.id, validZap.pubKey, validZap.createdAt, validZap.tags, validZap.content, "0".repeat(128))
|
||||
|
||||
// Default: the bad event signature is caught.
|
||||
assertEquals(
|
||||
Bolt12ZapValidation.Invalid(Bolt12ZapValidation.Reason.BAD_EVENT_SIGNATURE),
|
||||
validator.validate(tamperedSig),
|
||||
)
|
||||
// Ingest path: the pipeline already verified the event, so skipping is safe and it validates.
|
||||
assertIs<Bolt12ZapValidation.Valid>(validator.validate(tamperedSig, verifyEventSignature = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
{
|
||||
"payer_secret": "4242424242424242424242424242424242424242424242424242424242424242",
|
||||
"keys": {
|
||||
"offer_issuer_id": {
|
||||
"secret": "4646464646464646464646464646464646464646464646464646464646464646",
|
||||
"pubkey": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
"invreq_payer_id": {
|
||||
"secret": "4242424242424242424242424242424242424242424242424242424242424242",
|
||||
"pubkey": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
|
||||
},
|
||||
"first_node_id": {
|
||||
"secret": "4343434343434343434343434343434343434343434343434343434343434343",
|
||||
"pubkey": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007"
|
||||
},
|
||||
"first_path_key": {
|
||||
"secret": "4444444444444444444444444444444444444444444444444444444444444444",
|
||||
"pubkey": "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991"
|
||||
},
|
||||
"blinded_node_id": {
|
||||
"secret": "4545454545454545454545454545454545454545454545454545454545454545",
|
||||
"pubkey": "02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145"
|
||||
},
|
||||
"invoice_node_id": {
|
||||
"secret": "4646464646464646464646464646464646464646464646464646464646464646",
|
||||
"pubkey": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
}
|
||||
},
|
||||
"valid_vectors": [
|
||||
{
|
||||
"name": "full_disclosure",
|
||||
"input": {
|
||||
"invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazhq6zqqqqqqqqqqqqqqqqqqqzczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyp7aextn2n4d5mzx2phwfe70cejyqaaq78my4wndgna3ymwyc4vlf60kke258g33nhp23vldqpygemxp54ecl0vr0qfejm3xpm6atq4mlavkstcqszss",
|
||||
"invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8ae0d08000000000000000000000000b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f040fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577feb2d05e010142",
|
||||
"preimage": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"invoice_fields": [
|
||||
{
|
||||
"type": 0,
|
||||
"len": 16,
|
||||
"hex": "00000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 22,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 82,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 160,
|
||||
"len": 118,
|
||||
"hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 162,
|
||||
"len": 28,
|
||||
"hex": "00000001000000020003000000000000000400000000000000050000",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 164,
|
||||
"len": 4,
|
||||
"hex": "67527988",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 174,
|
||||
"len": 13,
|
||||
"hex": "08000000000000000000000000",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 3000000001,
|
||||
"len": 1,
|
||||
"hex": "42",
|
||||
"included": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"working": {
|
||||
"invoice_merkle_root": "cb9e0c81bb39fc244f9f523c748ab4de0e09f1a5fef74359c2e1f7cc7cdc7447",
|
||||
"invoice_sighash": "538aab18f82285032db132cecf7275ba2cbb462ef1f4d151588f836d0ce47aae",
|
||||
"invoice_signature": "fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577",
|
||||
"proof_merkle_root": "d461a2dd3099e2c43ccef4c3c8d36f720948fe21712ea94dceccb4bddb0c9e5e",
|
||||
"proof_leaf_hashes": [
|
||||
"8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f461",
|
||||
"8dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283c",
|
||||
"f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67",
|
||||
"f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa",
|
||||
"7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e",
|
||||
"19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997",
|
||||
"f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a",
|
||||
"dc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb",
|
||||
"c14cfffaa314261bcbb2ed4ca24d5717bb608d8a6cc9910790bc1d49af7858ab",
|
||||
"7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb",
|
||||
"abaab91b367e30fea7026daf9f2590bb7e9cc31db8221f4013c67289e38f22c8"
|
||||
],
|
||||
"proof_omitted_tlvs": [],
|
||||
"proof_missing_hashes": [
|
||||
"0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"payer_sig": "4961333409f453b5518fcbc662936fcb46e6c1db8d963c44b67d5677f0ffce3ac5c42293d1bc1298b0d67320a772e20a06c069dfa7079df9207b675357735dd7",
|
||||
"proof_fields": [
|
||||
{
|
||||
"type": 22,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
{
|
||||
"type": 82,
|
||||
"len": 2,
|
||||
"hex": "03e8"
|
||||
},
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
|
||||
},
|
||||
{
|
||||
"type": 160,
|
||||
"len": 118,
|
||||
"hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"type": 162,
|
||||
"len": 28,
|
||||
"hex": "00000001000000020003000000000000000400000000000000050000"
|
||||
},
|
||||
{
|
||||
"type": 164,
|
||||
"len": 4,
|
||||
"hex": "67527988"
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793"
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8"
|
||||
},
|
||||
{
|
||||
"type": 174,
|
||||
"len": 13,
|
||||
"hex": "08000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "fbb932e6a9d5b4d88ca0ddc9cf9f8cc880ef41e3ec9574da89f624db898ab3e9d3ed6caa8744633b855167da009119d9834ae71f7b06f02732dc4c1debab0577"
|
||||
},
|
||||
{
|
||||
"type": 241,
|
||||
"len": 64,
|
||||
"hex": "4961333409f453b5518fcbc662936fcb46e6c1db8d963c44b67d5677f0ffce3ac5c42293d1bc1298b0d67320a772e20a06c069dfa7079df9207b675357735dd7"
|
||||
},
|
||||
{
|
||||
"type": 1001,
|
||||
"len": 32,
|
||||
"hex": "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
},
|
||||
{
|
||||
"type": 1003,
|
||||
"len": 32,
|
||||
"hex": "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1"
|
||||
},
|
||||
{
|
||||
"type": 1004,
|
||||
"len": 352,
|
||||
"hex": "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f4618dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283cf2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffbc14cfffaa314261bcbb2ed4ca24d5717bb608d8a6cc9910790bc1d49af7858ab7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cbabaab91b367e30fea7026daf9f2590bb7e9cc31db8221f4013c67289e38f22c8"
|
||||
},
|
||||
{
|
||||
"type": 3000000001,
|
||||
"len": 1,
|
||||
"hex": "42"
|
||||
}
|
||||
],
|
||||
"bech32": "lnp1zcssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz2gpq86zcyypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k89qwcp87v0tc4rzc87uuxmn0m8l2tfh6aw75s7wz8r56fd299ckt74zqpcr9s9he72nyjs86pfe3vjqzaxups47g3xedv2e4fk877c7v6rgpxgszqhd4w73ddqusdcmjthj7pxprpd57qakmn2jh2dh3kwhezwg7gs3g5qpqqqqqqqqqqqqqqqqqqqqqqqqqq9zrsqqqqqpqqqqqqsqqvqqqqqqqqqqqpqqqqqqqqqqqqzsqq9yq3n4y7vg4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73tsdpqqqqqqqqqqqqqqqqqqqpvppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqlwun9e4f6k6d3r9qmhyul8uvezqw7s0raj2hfk5f7cjdhzv2k05a8mtv42r5gcems4gk0ksqjyvanq62uu0hkphsyuedcnqaaw4s2al3gpykzve5p8698d233l9uvc5ndl95dekpmwxev0zyke74valsll8r43wyy2far0qjnzcdvueq5aewyzsxcp5alfc8nhujq7m82dthxhwhl5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86eqpdgshfxx3pxkqv2eemf0pj3puaeyyj6eu54zrydml08swdmcqksl6qlvl5qkprys2lkc3u7956myg8w2cw67fnhmxc2eqntnv2uxu7zz07mftarp3hz549698hhx7grl55sk5v832e6yyufv4xy990rcndecs5pf9q709h40tuctu08d3878cfx5y2qehur27rjg5v2z8w7suf3570pauel4f7qvjj588qlj4rhhc0jxr33tv7j3mfdueakdj6nah2efh6j3lfuynw9c2msa9f3annnacxncupwtkt00rawhwzwy36rs0c99nlj3ux08unhwd06kcmzcnljsqd2fpsd8eydh209cqp7yk55r3fnh97vh7qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psddczuduql35zc9xxllz35c947kz0hku8hc6w7ajkgfw87p3kp4l77pfnll4gc5ycduhvhdfj3y64chhdsgmznvexgs0y9ur4y677zc4dlf9dmmncuyxeg0dnt7a99kw5l2nhe4xdcskpx7u6r26dm9zkjuh2a2hydnvl3sl6nsymd0nujepwm7nnp3mwpzraqp83nj383c7gkgl6edqhspq9pq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "minimal_disclosure",
|
||||
"input": {
|
||||
"invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs",
|
||||
"invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"preimage": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"invoice_fields": [
|
||||
{
|
||||
"type": 0,
|
||||
"len": 16,
|
||||
"hex": "00000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 22,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 82,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 160,
|
||||
"len": 118,
|
||||
"hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 162,
|
||||
"len": 28,
|
||||
"hex": "00000001000000020003000000000000000400000000000000050000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 164,
|
||||
"len": 4,
|
||||
"hex": "67527988",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"included": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"working": {
|
||||
"invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e",
|
||||
"invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5",
|
||||
"invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"proof_merkle_root": "ccc704b73e8190c71bf2ac3661d631c8c5ac502b78641708c8e61c0a1c8ebb72",
|
||||
"proof_leaf_hashes": [
|
||||
"f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67",
|
||||
"f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a",
|
||||
"7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
],
|
||||
"proof_omitted_tlvs": [
|
||||
1,
|
||||
2,
|
||||
89,
|
||||
90,
|
||||
91,
|
||||
169
|
||||
],
|
||||
"proof_missing_hashes": [
|
||||
"bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41",
|
||||
"b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02ab",
|
||||
"cd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e316",
|
||||
"9f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9",
|
||||
"998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"payer_sig": "cebc25d40a2d927b5ebcab8400f542fbb7a8f462e8dd802bb13e050b9bf293b7457c19b46a476740f97c9d6ec141f23434c5d4fa253a1d2eb8896ebad99455cc",
|
||||
"proof_fields": [
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793"
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319"
|
||||
},
|
||||
{
|
||||
"type": 241,
|
||||
"len": 64,
|
||||
"hex": "cebc25d40a2d927b5ebcab8400f542fbb7a8f462e8dd802bb13e050b9bf293b7457c19b46a476740f97c9d6ec141f23434c5d4fa253a1d2eb8896ebad99455cc"
|
||||
},
|
||||
{
|
||||
"type": 1001,
|
||||
"len": 32,
|
||||
"hex": "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
},
|
||||
{
|
||||
"type": 1002,
|
||||
"len": 6,
|
||||
"hex": "0102595a5ba9"
|
||||
},
|
||||
{
|
||||
"type": 1003,
|
||||
"len": 160,
|
||||
"hex": "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db"
|
||||
},
|
||||
{
|
||||
"type": 1004,
|
||||
"len": 96,
|
||||
"hex": "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
}
|
||||
],
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "with_note",
|
||||
"input": {
|
||||
"invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs",
|
||||
"invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"preimage": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"note": "test note",
|
||||
"invoice_fields": [
|
||||
{
|
||||
"type": 0,
|
||||
"len": 16,
|
||||
"hex": "00000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 22,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 82,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 160,
|
||||
"len": 118,
|
||||
"hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 162,
|
||||
"len": 28,
|
||||
"hex": "00000001000000020003000000000000000400000000000000050000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 164,
|
||||
"len": 4,
|
||||
"hex": "67527988",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"included": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"working": {
|
||||
"invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e",
|
||||
"invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5",
|
||||
"invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"proof_merkle_root": "327c38426d1d557f3669f19cdb440187e312679eadcfb61a9a8dcb0098639467",
|
||||
"proof_leaf_hashes": [
|
||||
"f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67",
|
||||
"f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a",
|
||||
"7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
],
|
||||
"proof_omitted_tlvs": [
|
||||
1,
|
||||
2,
|
||||
89,
|
||||
90,
|
||||
91,
|
||||
169
|
||||
],
|
||||
"proof_missing_hashes": [
|
||||
"bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41",
|
||||
"b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02ab",
|
||||
"cd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e316",
|
||||
"9f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9",
|
||||
"998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"payer_sig": "2a47f98770ae814119d682a7b19f7ce9e050a3bb49d9b0a031c46d9cb57a3075ddc23a742f6d33bc4ec8e1778327494bea76d2ee564625e99bfb95cc209a7722",
|
||||
"proof_fields": [
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793"
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319"
|
||||
},
|
||||
{
|
||||
"type": 241,
|
||||
"len": 64,
|
||||
"hex": "2a47f98770ae814119d682a7b19f7ce9e050a3bb49d9b0a031c46d9cb57a3075ddc23a742f6d33bc4ec8e1778327494bea76d2ee564625e99bfb95cc209a7722"
|
||||
},
|
||||
{
|
||||
"type": 1001,
|
||||
"len": 32,
|
||||
"hex": "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
},
|
||||
{
|
||||
"type": 1002,
|
||||
"len": 6,
|
||||
"hex": "0102595a5ba9"
|
||||
},
|
||||
{
|
||||
"type": 1003,
|
||||
"len": 160,
|
||||
"hex": "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db"
|
||||
},
|
||||
{
|
||||
"type": 1004,
|
||||
"len": 96,
|
||||
"hex": "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
},
|
||||
{
|
||||
"type": 1005,
|
||||
"len": 9,
|
||||
"hex": "74657374206e6f7465"
|
||||
}
|
||||
],
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qz53lesac2aq2pr8tg9fa3na7wnczs5wa5nkds5qcugmvuk4arqawacga8gtmdxw7yaj8pw7pjwj2tafmd9mjkgcj7nxlmjhxzpxnhyt7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9e07s8mgfw3jhxapqdehhgeg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "left_subtree_omitted",
|
||||
"input": {
|
||||
"invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs",
|
||||
"invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"preimage": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"invoice_fields": [
|
||||
{
|
||||
"type": 0,
|
||||
"len": 16,
|
||||
"hex": "00000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 22,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 82,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 160,
|
||||
"len": 118,
|
||||
"hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 162,
|
||||
"len": 28,
|
||||
"hex": "00000001000000020003000000000000000400000000000000050000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 164,
|
||||
"len": 4,
|
||||
"hex": "67527988",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"included": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"working": {
|
||||
"invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e",
|
||||
"invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5",
|
||||
"invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"proof_merkle_root": "1b0bab09a5fcccec19eb3aaafbbfa4075944fd1f65691ed715fbbc17a59b3651",
|
||||
"proof_leaf_hashes": [
|
||||
"f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67",
|
||||
"f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a",
|
||||
"dc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb",
|
||||
"7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
],
|
||||
"proof_omitted_tlvs": [
|
||||
1,
|
||||
2,
|
||||
89,
|
||||
90,
|
||||
91
|
||||
],
|
||||
"proof_missing_hashes": [
|
||||
"bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41",
|
||||
"b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02ab",
|
||||
"cd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e316",
|
||||
"9f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"payer_sig": "ed03ec58d5ac676539486bb6e8d6f389b6375b6693ffdf30a93919590d744fa393f945a5de3bb57d65c7f61def1cefa8aa038725b15bf0fa797dfd08ca97538a",
|
||||
"proof_fields": [
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793"
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8"
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319"
|
||||
},
|
||||
{
|
||||
"type": 241,
|
||||
"len": 64,
|
||||
"hex": "ed03ec58d5ac676539486bb6e8d6f389b6375b6693ffdf30a93919590d744fa393f945a5de3bb57d65c7f61def1cefa8aa038725b15bf0fa797dfd08ca97538a"
|
||||
},
|
||||
{
|
||||
"type": 1001,
|
||||
"len": 32,
|
||||
"hex": "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
},
|
||||
{
|
||||
"type": 1002,
|
||||
"len": 5,
|
||||
"hex": "0102595a5b"
|
||||
},
|
||||
{
|
||||
"type": 1003,
|
||||
"len": 128,
|
||||
"hex": "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9"
|
||||
},
|
||||
{
|
||||
"type": 1004,
|
||||
"len": 128,
|
||||
"hex": "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
}
|
||||
],
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqpgejy3tgk64wdmf9yqft6llpqukq867u5layf72mq0cu6zd79zc2s0yuxgg9j7xdsrdqdztevckgp7sqlujsenwy6x9hp8lar3awxx03grks8mzc6kkxwefefp4md6xk7wymvd6mv6fllhes4yu3jkgdw3868ylegkjauwa404ju0asaauwwl292qwrjtv2m7ra8jl0apr9fw5u2l5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86s9qyp9jkjml5p7hq9l3jetr4h6n0xu4dgpkk0c93ju2p4h7s63gumlwxtlrl8746adgxu5qm6vu5n2dgx5uze6v0kcn2pjuvwtnyualcd8khwhyvkn9sp2hnvugj6nkvtspj0dpcenpnjztal337kzlsw4v635g6zrjf60pcckn7vrpukrqux0htgnlh3sacmv6u2rtygkfmgjqs9fe4v4e95yptyl6qlvsredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq6ms9cmcplrg9s2vdl79rfsttavyl0dc0035aam9vsju0urrvrtlahay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty_proof_omitted_tlvs_explicit",
|
||||
"input": {
|
||||
"invoice": "lni1qqgqqqqqqqqqqqqqqqqqqqqqqqqqq93pqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyecy5szq059sggry3jnatzrgjyqqtxqdwlm0ug0uxyerc6lnljrqtd75mfr20wq4vw2qasz0uc7h32x9s0aecdhxlk075kn046aafpuuyw8f5j652t3vha2yqrsxtqt0nu4xf9q05znnzeyq96dcrptu3zdj6c4n2nv0aa3ue5xszv3qypwm2aaz66peqm3hyh09uzvzxzmfupmdhx49w5m0rva0jyu3u3pz3gqzqqqqqqqqqqqqqqqqqqqqqqqqqq2y8qqqqqqzqqqqqpqqqcqqqqqqqqqqqzqqqqqqqqqqqq9qqq2gpr82fuc32pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7f65qsrazczzqjtc233yeg48ur7wrst4vy8ynntsh3p07xdv2xwkc5hgfrmkjfnstcyqz3nyfzk3d42umkj2gqjh4l7zpevq04aefl6gnu4kql3e5ymu29s4q7fcvsst9uvmqx6q6yhje3vsraqple9pnxuf5vtwz0l68r6uvvs",
|
||||
"invoice_hex": "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f0400a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"preimage": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"invoice_fields": [
|
||||
{
|
||||
"type": 0,
|
||||
"len": 16,
|
||||
"hex": "00000000000000000000000000000000",
|
||||
"included": false
|
||||
},
|
||||
{
|
||||
"type": 22,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 82,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 160,
|
||||
"len": 118,
|
||||
"hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 162,
|
||||
"len": 28,
|
||||
"hex": "00000001000000020003000000000000000400000000000000050000",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 164,
|
||||
"len": 4,
|
||||
"hex": "67527988",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"included": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"working": {
|
||||
"invoice_merkle_root": "0501ea6d4ad9fe7fce7edd5e3795987bd409d66c5709c2a17f9c0dfb839e3d8e",
|
||||
"invoice_sighash": "41ce7b274b0e73e60dd6abf4fa51ccae892b161adc24d4099f620b12c59a03e5",
|
||||
"invoice_signature": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319",
|
||||
"proof_merkle_root": "a98cbee700b100ede32c19f9525264d51a105751fb27e790dad011bb38415b89",
|
||||
"proof_leaf_hashes": [
|
||||
"8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f461",
|
||||
"8dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283c",
|
||||
"f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67",
|
||||
"f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa",
|
||||
"7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e",
|
||||
"19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997",
|
||||
"f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a",
|
||||
"dc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb",
|
||||
"7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
],
|
||||
"proof_omitted_tlvs": [],
|
||||
"proof_missing_hashes": [
|
||||
"0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"payer_sig": "dffe62d6446c182f2503e780fce09fc5cf310ef14a54f65dd1df124039b7a897f2940470ac0f9857f826b232289c0ac4e6927ca67d805167fb0add352f88add1",
|
||||
"proof_fields": [
|
||||
{
|
||||
"type": 22,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
{
|
||||
"type": 82,
|
||||
"len": 2,
|
||||
"hex": "03e8"
|
||||
},
|
||||
{
|
||||
"type": 88,
|
||||
"len": 33,
|
||||
"hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
|
||||
},
|
||||
{
|
||||
"type": 160,
|
||||
"len": 118,
|
||||
"hex": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"type": 162,
|
||||
"len": 28,
|
||||
"hex": "00000001000000020003000000000000000400000000000000050000"
|
||||
},
|
||||
{
|
||||
"type": 164,
|
||||
"len": 4,
|
||||
"hex": "67527988"
|
||||
},
|
||||
{
|
||||
"type": 168,
|
||||
"len": 32,
|
||||
"hex": "72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793"
|
||||
},
|
||||
{
|
||||
"type": 170,
|
||||
"len": 2,
|
||||
"hex": "03e8"
|
||||
},
|
||||
{
|
||||
"type": 176,
|
||||
"len": 33,
|
||||
"hex": "024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382"
|
||||
},
|
||||
{
|
||||
"type": 240,
|
||||
"len": 64,
|
||||
"hex": "0a33224568b6aae6ed252012bd7fe1072c03ebdca7fa44f95b03f1cd09be28b0a83c9c32105978cd80da068979662c80fa00ff250ccdc4d18b709ffd1c7ae319"
|
||||
},
|
||||
{
|
||||
"type": 241,
|
||||
"len": 64,
|
||||
"hex": "dffe62d6446c182f2503e780fce09fc5cf310ef14a54f65dd1df124039b7a897f2940470ac0f9857f826b232289c0ac4e6927ca67d805167fb0add352f88add1"
|
||||
},
|
||||
{
|
||||
"type": 1001,
|
||||
"len": 32,
|
||||
"hex": "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
},
|
||||
{
|
||||
"type": 1002,
|
||||
"len": 0,
|
||||
"hex": ""
|
||||
},
|
||||
{
|
||||
"type": 1003,
|
||||
"len": 32,
|
||||
"hex": "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1"
|
||||
},
|
||||
{
|
||||
"type": 1004,
|
||||
"len": 288,
|
||||
"hex": "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f4618dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283cf2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb"
|
||||
}
|
||||
],
|
||||
"bech32": "lnp1zcssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz2gpq86zcyypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k89qwcp87v0tc4rzc87uuxmn0m8l2tfh6aw75s7wz8r56fd299ckt74zqpcr9s9he72nyjs86pfe3vjqzaxups47g3xedv2e4fk877c7v6rgpxgszqhd4w73ddqusdcmjthj7pxprpd57qakmn2jh2dh3kwhezwg7gs3g5qpqqqqqqqqqqqqqqqqqqqqqqqqqq9zrsqqqqqpqqqqqqsqqvqqqqqqqqqqqpqqqqqqqqqqqqzsqq9yq3n4y7vg4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqpgejy3tgk64wdmf9yqft6llpqukq867u5layf72mq0cu6zd79zc2s0yuxgg9j7xdsrdqdztevckgp7sqlujsenwy6x9hp8lar3awxx03gr0luckkg3kpste9q0ncpl8qnlzu7vgw7999faja6803yspek75f0u55q3c2cruc2luzdv3j9zwq438xjf72vlvq29nlkzkax5hc3tw3l5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86sql5p7kgqt2y96f35gf4srzkww6tcv5g08wfpykk099gserwlmeurnw7q9587s8m8aqysgeyzhaky083dxkezpmjkrkhjva7ekzkgy6umzhph8ssnlk62lgcvdc49fw3faaehjqla9y94rpu2kw3p8zt9f3pftc7ymwwy9q2fg8nedat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0a20sry54pec8u4gaa7ru3suv2m855w6t0x0dnvk5ld6k2d755060pym3wzku8f2v0vuulwp578qtjajmmclt4msn3ywsur7pfvlu50pnelyamnt74kxckylu5qr2jgvrf7frd6newqq03949qu2vae0n9lsrywr2qqzga25hrg2r95f3fu5hu773xvz2ugh3kf34lak2ncvrtwqhr0q8udqkpf3hlc5dxpd04snaahpa7xnhhv4jzt3lsvdsd0lkl5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"invalid_vectors": [
|
||||
{
|
||||
"reason": "missing_invreq_payer_id",
|
||||
"bech32": "lnp14qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "missing_invoice_payment_hash",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cukqssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz7pqq5vezg45td2hxa5jjqy4a0lsswtqra0w207jyl9ds8uwdpxlz3v9g8jwryyze0rxcpksx39ukvtyqlgq07fgvehzdrzmsnl73c7hrr8c5pn4uyh2q5tvj0d0te2uyqr6597ah4r6x96xasq4mz0s9pwdl9yahg47pndr2gan5p7tun4hvzs0jxs6vt486y5ap6t4c39ht4kv52hx06qlfyqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsrlgragrqzqjetfd6nlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s"
|
||||
},
|
||||
{
|
||||
"reason": "missing_invoice_node_id",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ylsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "missing_signature",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qh3gr8tcfw5pgkey767hj4cgq84gtam0285vt5dmqptkylq2zum72fmw3turx6x53m8gruhe8twc9qlydp5ch205ff6r5ht3ztwhtveg4wvl5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86sxqyp9jkjm487s86aqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kml5p7cc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt"
|
||||
},
|
||||
{
|
||||
"reason": "missing_proof_preimage",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86sxqyp9jkjm487s86aqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kml5p7cc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt"
|
||||
},
|
||||
{
|
||||
"reason": "missing_proof_missing_hashes",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s"
|
||||
},
|
||||
{
|
||||
"reason": "missing_proof_leaf_hashes",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjmv"
|
||||
},
|
||||
{
|
||||
"reason": "missing_proof_signature",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84ccel5p7jgqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpq87s86sxqyp9jkjm487s86aqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kml5p7cc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwt"
|
||||
},
|
||||
{
|
||||
"reason": "wrong_proof_preimage",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq06ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "proof_omitted_tlvs_not_ascending",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk2649dl6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "proof_omitted_tlvs_contains_zero",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2quqqzqjetfd6nlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s"
|
||||
},
|
||||
{
|
||||
"reason": "proof_omitted_tlvs_contains_signature_field",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2quqsyk26tw5lrlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s"
|
||||
},
|
||||
{
|
||||
"reason": "proof_omitted_tlvs_contains_proof_field",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2pyqsyk26tw5l6qlfl5p7hg9l3jetr4h6n0xu4dgpkk0c93ju2p4h7s63gumlwxtlrl8746adgxu5qm6vu5n2dgx5uze6v0kcn2pjuvwtnyualcd8khwhyvkn9sp2hnvugj6nkvtspj0dpcenpnjztal337kzlsw4v635g6zrjf60pcckn7vrpukrqux0htgnlh3sacmv6u2rtygkfmgjqs9fe4v4e95yptyenz4hl2w8g0aem0dsmr2xl0366ve5qz7s0uegmndkqzrep0ya9klaq0kxpuk74a0np03uakylclpy6s3grxlsdtcwfz33ggam6r3xxneu8hn87qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psd8ay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc"
|
||||
},
|
||||
{
|
||||
"reason": "proof_omitted_tlvs_contains_high_field",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2pvqsyk26tw5lamnt9qq06qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "proof_omitted_tlvs_contains_included_tlv_field",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2quqsykzetfd6nlgrawstlr9jk8t04x7de26srdvlstr9c5rt0ap4z3eh7uvh7870at466sdegph5eefx56sdfc9n5cld3x5r9ccuhxfemls60dwawgedxtqz40xec3948vchqry76r3nxr8yyhmlrrav9lqa2e4rg35y8yn57r33d8ucxrevxpcvlwk38l0rpm3ke4c5xkg3vnk3ypq2nn2etjtggzkfnx9t075uwslmnk7mpkx5d7lr45engq9aqlej3hxmvqy8jz7f6tdl6qlvvredat6lxzlremvfl37zf4pzsxdlq6hsuj9rzs3mh58zvd8nc00x0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq606ftw7u78ppk2rmv6lhffdn4865a7dfnwy9sfhhxs6knweg45h9s"
|
||||
},
|
||||
{
|
||||
"reason": "proof_omitted_tlvs_not_sequential",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyktyv4n06qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "proof_leaf_hashes_too_few",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mzq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxs"
|
||||
},
|
||||
{
|
||||
"reason": "proof_leaf_hashes_too_many",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8myq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9evqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
|
||||
},
|
||||
{
|
||||
"reason": "proof_missing_hashes_too_few",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qltszlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4j0aq0kxpuk74a0np03uakylclpy6s3grxlsdtcwfz33ggam6r3xxneu8hn87qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psd8ay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc"
|
||||
},
|
||||
{
|
||||
"reason": "proof_missing_hashes_too_many",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qltczlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjmvqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplgra3s09h40tuctu08d3878cfx5y2qehur27rjg5v2z8w7suf3570pauelsrywr2qqzga25hrg2r95f3fu5hu773xvz2ugh3kf34lak2ncvrflf9dmmncuyxeg0dnt7a99kw5l2nhe4xdcskpx7u6r26dm9zkjuk"
|
||||
},
|
||||
{
|
||||
"reason": "wrong_invoice_signature",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9nxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qva0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "wrong_proof_signature",
|
||||
"bech32": "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgq9rxgj9dzm24ehdy5sp90tluyrjcqltmjnl538etvplrngfhc5tp2punsepqktcekqd5p5f09nzeq86qrlj2rxdcngckuyll5w84cce79qvl0p96s9zmynmt672hpqq74p0hdag733w3hvq9wcnupgtn0ef8d690svmg6j8vaq0jlyadmq5ru35xnzaf7398gwjawyfd6adn9z4en7s86fqqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyql6ql2qcqsyk26tw5l6qlt5zlcev436mafhnw2k5qmt8uzcew9q6mlgdg5wdlhr9l3lnl2awk5rw2qdaxw2f4x5r2wpvax8mvf4qewx89ejwwluxnmthtjxtfjcq4tekwyfdfmx9cqe8ksuveseep97lccltp0c82kdg6ydppeya8suvtflxps7tpswr8m45flmccwudkdw9p4jytya5fqgz5u6k2uj6zq4jve32ml48r587uahkcd34r0hcadxv6qp0g87v5dekmqppushjwjm07s8mrq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxn7j2mhh83cgdjs7mxha62tvaf7480n2vm3pvzdae5x45mk29d9ev"
|
||||
},
|
||||
{
|
||||
"reason": "contains_invreq_metadata",
|
||||
"bech32": "lnp1qqq5ykppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43e2pqwtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7fmqggzf0p2xyn9z5ls0ecwpw4ssujwdwz7y9lce43ge6mzjapy0w6fxwp0qsq2xv3y269k4tnw6ffqz27hlcg89sp7hh98lfz0jkcr78xsn03gkz5re8pjzpvh3nvqmgrgj7tx9jq05q8ly5xvm3x33dcfllgu0t33nu2qe67zt4q29kf8kh4u4wzqpa2zlwm63arzarwcq2a38czshxljjwm52lqek34ywe6ql97f6mkpg8ergdx96naz2wsa96ugjm46mx29tn8aq05jqqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpl5p75pspqfv45kafl5p7hg9l3jetr4h6n0xu4dgpkk0c93ju2p4h7s63gumlwxtlrl8746adgxu5qm6vu5n2dgx5uze6v0kcn2pjuvwtnyualcd8khwhyvkn9sp2hnvugj6nkvtspj0dpcenpnjztal337kzlsw4v635g6zrjf60pcckn7vrpukrqux0htgnlh3sacmv6u2rtygkfmgjqs9fe4v4e95yptyenz4hl2w8g0aem0dsmr2xl0366ve5qz7s0uegmndkqzrep0ya9klaq0kxpuk74a0np03uakylclpy6s3grxlsdtcwfz33ggam6r3xxneu8hn87qv3cdgqqfr42judpgvk3x98jjlnm6yesft3z7xexxhlke20psd8ay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjc"
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
@@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
|
||||
@@ -51,6 +53,8 @@ class RequestDeserializer : StdDeserializer<Request>(Request::class.java) {
|
||||
|
||||
return when (method) {
|
||||
NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java)
|
||||
NwcMethod.PAY -> jp.codec.treeToValue(jsonObject, PayMethod::class.java)
|
||||
NwcMethod.RECEIVE -> jp.codec.treeToValue(jsonObject, ReceiveMethod::class.java)
|
||||
NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java)
|
||||
NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java)
|
||||
NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user