mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(lnurlcash): claim mint outputs named by a wallet-chosen secret
lnurl-mint >= 0.4 refuses a mint quote without comment=sha256(secret) and keys the note by that secret instead of the payment preimage. prepareMint detects the capability per mint (commentAllowed >= 64 or mintToHash), binds the quote to a wallet-drawn secret, and claimMintedNote polls the note itself - no preimage, no rotate, since the secret never rode an invoice. Mints that never advertised the capability get the byte-identical old request and stay on the preimage path; a mint that took the hash but credited the preimage is caught by the claim poll and rescued through the quote's verify URL. The d.ts augments the conformance package's stale published declarations for its comment-naming flags.
This commit is contained in:
+165
-26
@@ -1,18 +1,39 @@
|
||||
// Minting: receiving over Lightning. prepareMint resolves a mint and
|
||||
// requests the invoice; paying it brings the note into existence;
|
||||
// claimMintedNote watches the payment and converts the revealed preimage
|
||||
// into a rotated, wallet-owned bearer note.
|
||||
// claimMintedNote watches the payment and converts it into a wallet-owned
|
||||
// bearer note.
|
||||
//
|
||||
// Two mint generations exist on the wire, and this module speaks both:
|
||||
//
|
||||
// - UNNAMED (old): the minted note's k1 IS the payment preimage. The
|
||||
// claim takes the preimage from the LUD-21 verify answer and rotates
|
||||
// immediately - every routing node and anyone who saw the invoice knew
|
||||
// that secret.
|
||||
// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the
|
||||
// wallet names the note at quote time with comment = sha256(secret) of
|
||||
// a secret it chose, and the note lands at THAT secret. The preimage
|
||||
// opens nothing, so no observer race exists and no rotate follows the
|
||||
// claim; the claim poll itself is the placement proof (see
|
||||
// claimFromSecret).
|
||||
//
|
||||
// The mode is decided per mint from the payRequest's capability
|
||||
// advertisement (commentAllowed >= 64 or mintToHash), never assumed.
|
||||
|
||||
import {
|
||||
AmbiguousMutationError,
|
||||
buildNoteUrl,
|
||||
claimMintedNote as fetchMintClaim,
|
||||
defaultRandomSecret,
|
||||
fetchInvoiceVerification,
|
||||
fetchMintAddress,
|
||||
fetchNoteInfo,
|
||||
fetchPayRequest,
|
||||
grossUpForMintFee,
|
||||
hashK1,
|
||||
isPreimage,
|
||||
lightningAddressUsername,
|
||||
mintAddressUrl,
|
||||
namesMintOutput,
|
||||
probeBurnedNote,
|
||||
requestInvoice,
|
||||
resolveMintInput,
|
||||
@@ -21,16 +42,26 @@ import {
|
||||
serverOf,
|
||||
withNewK1,
|
||||
} from 'lnurlcash-kit'
|
||||
import type {MintAddressInfo} from 'lnurlcash-kit'
|
||||
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit'
|
||||
import type {NewBearer} from '../types'
|
||||
import {ceilMsatToSat} from '../units'
|
||||
import type {PollOptions} from './shared'
|
||||
import type {FundOperationOptions} from './shared'
|
||||
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
|
||||
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled} from './shared'
|
||||
|
||||
export type PreparedMint = {
|
||||
invoice: string
|
||||
verifyUrl: string | null
|
||||
// how this mint keys the note the invoice pays for: 'named' mints credit
|
||||
// a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints
|
||||
// credit the payment preimage
|
||||
mode: 'named' | 'unnamed'
|
||||
// named mode only: the wallet-chosen secret the minted note lands at.
|
||||
// Drawn before the invoice is requested (its hash binds the quote) and
|
||||
// carried in memory only - never persisted, never logged. Losing it
|
||||
// after payment loses the note, but nothing here was ever recoverable
|
||||
// after a crash: the invoice and verify URL die in the same memory
|
||||
noteSecret?: string
|
||||
// the net note value asked for - the claim cross-checks the service's
|
||||
// authoritative maxWithdrawable against it
|
||||
expectedNoteValueMsat: number
|
||||
@@ -85,10 +116,25 @@ export const prepareMint = async (
|
||||
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
|
||||
throw new Error("Amount is outside this mint's sendable range.")
|
||||
}
|
||||
const invoice = await requestInvoice(info.callback, grossMsat, options)
|
||||
// a mint that names its outputs takes comment = sha256(secret) with the
|
||||
// quote; one that doesn't gets the byte-identical request this wallet
|
||||
// always sent, and its note stays keyed by the payment preimage
|
||||
const named = namesMintOutput(info)
|
||||
let noteSecret: string | undefined
|
||||
let h: string | undefined
|
||||
if (named) {
|
||||
noteSecret = defaultRandomSecret()
|
||||
h = hashK1(noteSecret)
|
||||
}
|
||||
const invoice = await requestInvoice(
|
||||
info.callback,
|
||||
grossMsat,
|
||||
h ? {...options, h} : options,
|
||||
)
|
||||
const prepared: PreparedMint = {
|
||||
invoice: invoice.pr,
|
||||
verifyUrl: invoice.verify ?? null,
|
||||
mode: named ? 'named' : 'unnamed',
|
||||
expectedNoteValueMsat: amountMsat,
|
||||
grossMsat,
|
||||
mintUrl: payUrl,
|
||||
@@ -97,15 +143,18 @@ export const prepareMint = async (
|
||||
username: lightningAddressUsername(payUrl),
|
||||
nodeInfo,
|
||||
}
|
||||
if (noteSecret) prepared.noteSecret = noteSecret
|
||||
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
|
||||
return prepared
|
||||
}
|
||||
|
||||
export type ClaimedNote = {
|
||||
note: NewBearer
|
||||
// false when the rotate after claim failed - the note is tracked either
|
||||
// way (it IS money), but the preimage was transmitted and the mint
|
||||
// necessarily knows it, so an unrotated note must be treated as exposed
|
||||
// false when the note may be exposed to someone besides this wallet and
|
||||
// the mint: an unnamed mint's fresh note whose rotate failed (its k1 was
|
||||
// the payment preimage, transmitted and mint-known). A named mint's
|
||||
// note always reports true - its secret never rode an invoice, so there
|
||||
// is nothing to rotate away from
|
||||
rotated: boolean
|
||||
// set when the rotate's answer was lost and the probe couldn't tell: the
|
||||
// possible rotated copy, to track unverified alongside `note`
|
||||
@@ -113,18 +162,119 @@ export type ClaimedNote = {
|
||||
rotationError?: string
|
||||
}
|
||||
|
||||
// Polls the mint invoice's LUD-21 verify URL until the payment settles,
|
||||
// then claims the note: the payment preimage IS the note secret. The claim
|
||||
// itself (an informational GET) puts that secret on the wire, and the mint
|
||||
// has known it since it generated the invoice - so the fresh note is
|
||||
// rotated immediately and unconditionally (observer race: anyone who saw
|
||||
// the unpaid invoice knows the payment hash), before anything else happens
|
||||
// with it.
|
||||
// Polls a named mint output's informational GET until the note is there:
|
||||
// 'minted' resolves the wait ('spent' resolves it too - surfaced for the
|
||||
// caller to reject, never retried); 'unminted' and 'pending' keep it
|
||||
// going. The poll IS the placement proof for a named output: the service
|
||||
// only answers 'minted' for the secret it actually credited.
|
||||
export const pollMintClaim = async (
|
||||
withdrawLink: string,
|
||||
noteSecret: string,
|
||||
poll: PollOptions = {},
|
||||
options: FundOperationOptions = {},
|
||||
): Promise<MintClaim> =>
|
||||
pollUntil(
|
||||
async (fetchOptions) => {
|
||||
const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions)
|
||||
return claim.state === 'minted' || claim.state === 'spent' ? claim : null
|
||||
},
|
||||
'Minted note not confirmed',
|
||||
poll,
|
||||
options,
|
||||
)
|
||||
|
||||
// what a claim needs once the preimage is known - PreparedMint satisfies
|
||||
// this, and so does the target side of an inter-mint transfer (see
|
||||
// transfer.ts)
|
||||
export type ClaimTarget = {
|
||||
withdrawLink: string
|
||||
// the net note value asked for - a claim, cross-checked against the
|
||||
// service's authoritative maxWithdrawable
|
||||
expectedNoteValueMsat: number
|
||||
mintPubkey?: string
|
||||
}
|
||||
|
||||
// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's
|
||||
// verify URL and invoice as rescue anchors for a mint that took the hash
|
||||
// but credited the preimage anyway. PreparedMint satisfies this.
|
||||
export type NamedClaimTarget = ClaimTarget & {
|
||||
noteSecret: string
|
||||
verifyUrl?: string | null
|
||||
invoice?: string
|
||||
}
|
||||
|
||||
// The named-mode claim: the note was credited to the wallet's own secret,
|
||||
// which rode to the mint as a HASH inside the invoice request - the
|
||||
// payment preimage opens nothing, so the claim needs no preimage and no
|
||||
// rotate follows it. The rescue covers the mint that claimed the
|
||||
// capability but credited the preimage (the kit's mintToHashIgnoresH
|
||||
// adversary: the wallet was told no rotate is needed while the preimage
|
||||
// is still the money): when that mint still serves the quote's verify, one
|
||||
// check recovers the preimage and the note is claimed through the old
|
||||
// path, WITH the rotate that path exists for. When it serves none there
|
||||
// is nothing to rescue with - the error stays loud.
|
||||
export const claimFromSecret = async (
|
||||
target: NamedClaimTarget,
|
||||
poll: PollOptions = {},
|
||||
options: FundOperationOptions = {},
|
||||
): Promise<ClaimedNote> => {
|
||||
assertFundOwner(options)
|
||||
let claim: MintClaim
|
||||
try {
|
||||
claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options)
|
||||
} catch (err) {
|
||||
if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) {
|
||||
try {
|
||||
const result = await fetchInvoiceVerification(target.verifyUrl, options)
|
||||
if (
|
||||
result.settled &&
|
||||
sameInvoice(result.pr, target.invoice) &&
|
||||
result.preimage &&
|
||||
isPreimage(result.preimage)
|
||||
) {
|
||||
return claimFromPreimage(target, result.preimage, options)
|
||||
}
|
||||
} catch {
|
||||
// the rescue is best-effort - the original error stands
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (claim.state === 'spent') {
|
||||
// a fresh wallet-chosen secret cannot already be spent - the service
|
||||
// is lying about the note it just credited
|
||||
throw new Error('The mint reports the freshly minted note as already spent.')
|
||||
}
|
||||
if (claim.amountMsat === null || !claim.callback) {
|
||||
throw new Error('The mint did not return the minted note details.')
|
||||
}
|
||||
const note: NewBearer = {
|
||||
url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat),
|
||||
callback: claim.callback,
|
||||
amount: claim.amountMsat,
|
||||
verified: true,
|
||||
}
|
||||
if (target.mintPubkey) note.mintPubkey = target.mintPubkey
|
||||
return {note, rotated: true}
|
||||
}
|
||||
|
||||
// Polls until the minted note is claimable, then claims it. Unnamed mints:
|
||||
// watch the LUD-21 verify URL for settlement, take the preimage (which IS
|
||||
// the note secret there), and claim through claimFromPreimage with its
|
||||
// mandatory rotate. Named mints: watch the note itself appear at the
|
||||
// wallet's own secret (see claimFromSecret).
|
||||
export const claimMintedNote = async (
|
||||
prepared: PreparedMint,
|
||||
poll: PollOptions = {},
|
||||
options: FundOperationOptions = {},
|
||||
): Promise<ClaimedNote> => {
|
||||
if (prepared.mode === 'named') {
|
||||
const noteSecret = prepared.noteSecret
|
||||
if (!noteSecret) {
|
||||
throw new Error('This mint was prepared in named mode without a note secret.')
|
||||
}
|
||||
return claimFromSecret({...prepared, noteSecret}, poll, options)
|
||||
}
|
||||
if (!prepared.verifyUrl) {
|
||||
throw new Error(
|
||||
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
|
||||
@@ -144,17 +294,6 @@ export const claimMintedNote = async (
|
||||
return claimFromPreimage(prepared, preimage, options)
|
||||
}
|
||||
|
||||
// what a claim needs once the preimage is known - PreparedMint satisfies
|
||||
// this, and so does the target side of an inter-mint transfer (see
|
||||
// transfer.ts)
|
||||
export type ClaimTarget = {
|
||||
withdrawLink: string
|
||||
// the net note value asked for - a claim, cross-checked against the
|
||||
// service's authoritative maxWithdrawable
|
||||
expectedNoteValueMsat: number
|
||||
mintPubkey?: string
|
||||
}
|
||||
|
||||
// The claim itself, once the payment's preimage is known: the preimage IS
|
||||
// the note secret. The claim (an informational GET) puts that secret on
|
||||
// the wire, and the mint has known it since it generated the invoice - so
|
||||
|
||||
Reference in New Issue
Block a user