mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: wallet operations engine and encrypted storage with tests
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
// ensureExactAmount: carve an exact amount out of the held notes, merging
|
||||
// and/or splitting as needed, into a single fresh note worth exactly the
|
||||
// target - the operation every send and every melt starts from.
|
||||
|
||||
import {
|
||||
AmbiguousMutationError,
|
||||
mergeNotes,
|
||||
noteK1,
|
||||
probeBurnedNote,
|
||||
requireNoteK1,
|
||||
serverOf,
|
||||
settleNote,
|
||||
splitNote,
|
||||
withNewK1
|
||||
} from 'lnurlcash-kit'
|
||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {UncertainOutcomeError} from './shared'
|
||||
|
||||
// the changeset stores apply after a mutation: `note`/`change` BEFORE
|
||||
// `consumed` - the mint call already burned every consumed input
|
||||
// server-side, so the outputs are the only money left and must be tracked
|
||||
// first; a crash between the two must strand a duplicate, never a secret
|
||||
export type CarveResult = {
|
||||
// the exact-amount note, ready to hand over or melt
|
||||
note: NewBearer
|
||||
// the remainder note, when the carve split a larger input
|
||||
change?: NewBearer
|
||||
// the input notes burned server-side by the carve (empty when a single
|
||||
// note already held exactly the target amount)
|
||||
consumed: Bearer[]
|
||||
}
|
||||
|
||||
// Selection: only notes that can actually take part - verified (callback
|
||||
// known), not locally spent, holding a real k1 (device-backed mirrors are
|
||||
// excluded; the ops engine cannot mutate a secret it doesn't hold). Notes
|
||||
// are grouped by issuing server (a mutation only ever spans one service),
|
||||
// picked greedily smallest-first within a group until the target is
|
||||
// covered, and the group with the least waste wins (ties: fewer notes).
|
||||
//
|
||||
// Execution, mirroring lnurl-wallet's SendDialog:
|
||||
// - one note already exact: returned as-is, nothing burned
|
||||
// - several notes summing exactly: one merge, then settle (reads the true
|
||||
// post-fee value back and rotates, since the read put k1 on the wire)
|
||||
// - total above target (one or many notes): a single split request (LUD-25
|
||||
// split takes many k1s - no merge round trip first), then the change is
|
||||
// settled for its true value; the target part carries the mint's
|
||||
// signature and needs no settle
|
||||
export const ensureExactAmount = async (
|
||||
bearers: Bearer[],
|
||||
amountMsat: number,
|
||||
options: LnurlcashOptions = {}
|
||||
): Promise<CarveResult> => {
|
||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||
throw new Error('Amount must be a positive whole number of msat.')
|
||||
}
|
||||
const eligible = bearers.filter(
|
||||
b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url)
|
||||
)
|
||||
// per-server greedy pick: smallest notes first until the target is
|
||||
// covered (an exact single-note match short-circuits - no mutation at
|
||||
// all is always better than carving)
|
||||
const byServer = new Map<string, Bearer[]>()
|
||||
for (const b of eligible) {
|
||||
const server = serverOf(b.url)
|
||||
byServer.set(server, [...(byServer.get(server) ?? []), b])
|
||||
}
|
||||
let pick: Bearer[] | null = null
|
||||
for (const group of byServer.values()) {
|
||||
const sorted = [...group].sort((a, b) => a.amount - b.amount)
|
||||
const exact = sorted.find(b => b.amount === amountMsat)
|
||||
const candidate = exact ? [exact] : accumulate(sorted, amountMsat)
|
||||
if (!candidate) continue
|
||||
if (!pick || better(candidate, pick, amountMsat)) pick = candidate
|
||||
}
|
||||
if (!pick) {
|
||||
throw new Error(
|
||||
'No mint holds enough verified, unspent balance to cover that amount.'
|
||||
)
|
||||
}
|
||||
const base = pick[0]
|
||||
const total = pick.reduce((sum, b) => sum + b.amount, 0)
|
||||
const k1s = pick.map(b => requireNoteK1(b.url))
|
||||
|
||||
if (pick.length === 1 && total === amountMsat) {
|
||||
// already exact - hand over the note itself, untouched
|
||||
return {
|
||||
note: {
|
||||
url: base.url,
|
||||
callback: base.callback,
|
||||
amount: base.amount,
|
||||
verified: base.verified,
|
||||
mintPubkey: base.mintPubkey
|
||||
},
|
||||
consumed: []
|
||||
}
|
||||
}
|
||||
|
||||
if (total === amountMsat) {
|
||||
// merge path: many notes, exact sum - merge into one, then settle it
|
||||
// (true value + fresh secret; a failed settle leaves an unverified
|
||||
// note a refresh can repair, not a lost secret)
|
||||
const merged = await mergeAmbiguitySafe(base, k1s, total, options)
|
||||
const unverified: NewBearer = {
|
||||
url: withNewK1(base.url, merged.k1, total, merged.signature),
|
||||
callback: base.callback,
|
||||
amount: total,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey
|
||||
}
|
||||
// a merge whose answer was lost leaves the service in an unknown
|
||||
// state from here - settling fires another mutation (the rotate
|
||||
// inside settleNote) at it, whose own ambiguous failure would strand
|
||||
// the rescued secret. Don't compound: return unverified and let a
|
||||
// refresh repair.
|
||||
if (merged.rescued) return {note: unverified, consumed: pick}
|
||||
try {
|
||||
const settled = await settleNote(
|
||||
base.url,
|
||||
merged.k1,
|
||||
total,
|
||||
merged.signature,
|
||||
options
|
||||
)
|
||||
return {
|
||||
note: {
|
||||
url: withNewK1(
|
||||
base.url,
|
||||
settled.k1,
|
||||
settled.amountMsat,
|
||||
settled.signature
|
||||
),
|
||||
callback: settled.callback,
|
||||
amount: settled.amountMsat,
|
||||
verified: true,
|
||||
mintPubkey: base.mintPubkey
|
||||
},
|
||||
consumed: pick
|
||||
}
|
||||
} catch {
|
||||
return {note: unverified, consumed: pick}
|
||||
}
|
||||
}
|
||||
|
||||
// split path: total above target - one split request across all picked
|
||||
// k1s, carving the target off and leaving the change as a fresh note
|
||||
let partK1: string
|
||||
let partSignature: string | undefined
|
||||
let changeK1: string
|
||||
let changeSignature: string | undefined
|
||||
let partVerified = false
|
||||
// true when the split's answer was lost and the probe proved the burn -
|
||||
// the carried secrets were rescued, but the service is in an unknown
|
||||
// state, so the change is NOT settled (that would fire another mutation
|
||||
// at it, whose own ambiguous failure would strand the rescued secret)
|
||||
let rescued = false
|
||||
try {
|
||||
const parts = await splitNote(base.callback, k1s, amountMsat, options)
|
||||
partK1 = parts.k1
|
||||
partSignature = parts.signature
|
||||
changeK1 = parts.change
|
||||
changeSignature = parts.changeSignature
|
||||
partVerified = true
|
||||
} catch (err) {
|
||||
if (!(err instanceof AmbiguousMutationError)) throw err
|
||||
// the split request may have landed despite the failure - probe one
|
||||
// input before deciding what the carried secrets are worth
|
||||
const outcome = await probeBurnedNote(base.url, options)
|
||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||
if (outcome === 'unknown') {
|
||||
// can't tell: surface both possible outputs unverified WITHOUT
|
||||
// consuming the inputs, and stop here rather than spend from limbo
|
||||
throw new UncertainOutcomeError(
|
||||
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
|
||||
[
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
|
||||
callback: base.callback,
|
||||
amount: amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey
|
||||
},
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
|
||||
callback: base.callback,
|
||||
amount: total - amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
// 'gone': the burn landed - the carried secrets are the only money
|
||||
partK1 = err.newSecrets[0]
|
||||
changeK1 = err.newSecrets[1]
|
||||
rescued = true
|
||||
}
|
||||
const note: NewBearer = {
|
||||
url: withNewK1(base.url, partK1, amountMsat, partSignature),
|
||||
callback: base.callback,
|
||||
amount: amountMsat,
|
||||
verified: partVerified,
|
||||
mintPubkey: base.mintPubkey
|
||||
}
|
||||
// settleNote: the change may be worth less than total - amount if this
|
||||
// mint charges split fees (LUD-25 deducts them from change, never the
|
||||
// split-off amount) - it comes back at its true value, or stays
|
||||
// unverified at the naive pre-fee one for a refresh to repair
|
||||
let change: NewBearer = {
|
||||
url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature),
|
||||
callback: base.callback,
|
||||
amount: total - amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey
|
||||
}
|
||||
if (!rescued) {
|
||||
try {
|
||||
const settled = await settleNote(
|
||||
base.url,
|
||||
changeK1,
|
||||
total - amountMsat,
|
||||
changeSignature,
|
||||
options
|
||||
)
|
||||
change = {
|
||||
url: withNewK1(
|
||||
base.url,
|
||||
settled.k1,
|
||||
settled.amountMsat,
|
||||
settled.signature
|
||||
),
|
||||
callback: settled.callback,
|
||||
amount: settled.amountMsat,
|
||||
verified: true,
|
||||
mintPubkey: base.mintPubkey
|
||||
}
|
||||
} catch {
|
||||
// settle is best-effort - the unverified change above is still tracked
|
||||
}
|
||||
}
|
||||
return {note, change, consumed: pick}
|
||||
}
|
||||
|
||||
// smallest-first accumulation until the target is covered; null when the
|
||||
// whole group can't reach it
|
||||
const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => {
|
||||
const picked: Bearer[] = []
|
||||
let total = 0
|
||||
for (const b of sorted) {
|
||||
picked.push(b)
|
||||
total += b.amount
|
||||
if (total >= amountMsat) return picked
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// the better carve plan: less waste first, then fewer notes burned
|
||||
const better = (a: Bearer[], b: Bearer[], target: number): boolean => {
|
||||
const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0)
|
||||
const wasteA = sum(a) - target
|
||||
const wasteB = sum(b) - target
|
||||
if (wasteA !== wasteB) return wasteA < wasteB
|
||||
return a.length < b.length
|
||||
}
|
||||
|
||||
// merge with the full ambiguity protocol: probe one input, rescue the
|
||||
// carried secret only once the burn is confirmed, surface it unverified
|
||||
// when the probe can't tell either
|
||||
const mergeAmbiguitySafe = async (
|
||||
base: Bearer,
|
||||
k1s: string[],
|
||||
total: number,
|
||||
options: LnurlcashOptions
|
||||
): Promise<{k1: string; signature?: string; rescued: boolean}> => {
|
||||
try {
|
||||
const merged = await mergeNotes(base.callback, k1s, options)
|
||||
return {k1: merged.k1, signature: merged.signature, rescued: false}
|
||||
} catch (err) {
|
||||
if (!(err instanceof AmbiguousMutationError)) throw err
|
||||
const outcome = await probeBurnedNote(base.url, options)
|
||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||
if (outcome === 'unknown') {
|
||||
throw new UncertainOutcomeError(
|
||||
'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
|
||||
[
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[0], total),
|
||||
callback: base.callback,
|
||||
amount: total,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
// 'gone': the burn landed - the carried secret is the only money left
|
||||
return {k1: err.newSecrets[0], rescued: true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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.
|
||||
|
||||
import {
|
||||
AmbiguousMutationError,
|
||||
buildNoteUrl,
|
||||
fetchMintAddress,
|
||||
fetchNoteInfo,
|
||||
fetchPayRequest,
|
||||
grossUpForMintFee,
|
||||
isPreimage,
|
||||
lightningAddressUsername,
|
||||
mintAddressUrl,
|
||||
probeBurnedNote,
|
||||
requestInvoice,
|
||||
resolveMintInput,
|
||||
rotateNote,
|
||||
sameInvoice,
|
||||
serverOf,
|
||||
withNewK1
|
||||
} from 'lnurlcash-kit'
|
||||
import type {LnurlcashOptions, MintAddressInfo} from 'lnurlcash-kit'
|
||||
import type {NewBearer} from '../types'
|
||||
import {ceilMsatToSat} from '../units'
|
||||
import type {PollOptions} from './shared'
|
||||
import {pollVerifyUntilSettled} from './shared'
|
||||
|
||||
export type PreparedMint = {
|
||||
invoice: string
|
||||
verifyUrl: string | null
|
||||
// the net note value asked for - the claim cross-checks the service's
|
||||
// authoritative maxWithdrawable against it
|
||||
expectedNoteValueMsat: number
|
||||
// the gross amount actually invoiced (net + mint fee, rounded up to a
|
||||
// whole sat - sub-sat invoices aren't reliably payable)
|
||||
grossMsat: number
|
||||
mintUrl: string
|
||||
withdrawLink: string
|
||||
mintPubkey?: string
|
||||
server: string
|
||||
username: string | null
|
||||
nodeInfo: MintAddressInfo | null
|
||||
}
|
||||
|
||||
// Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover
|
||||
// its mint address (best-effort LUD-25 experimental endpoint), read its
|
||||
// payRequest, gross the requested net amount up for the advertised mint
|
||||
// fee, and request the invoice. Paying the returned invoice is what brings
|
||||
// the note into existence - see claimMintedNote.
|
||||
export const prepareMint = async (
|
||||
mintInput: string,
|
||||
amountMsat: number,
|
||||
options: LnurlcashOptions = {}
|
||||
): Promise<PreparedMint> => {
|
||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||
throw new Error('Amount must be a positive whole number of msat.')
|
||||
}
|
||||
const url = resolveMintInput(mintInput)
|
||||
if (!url) throw new Error('Enter a mint LNURL or Lightning Address.')
|
||||
// best-effort mint-address discovery - derived from `url`'s own
|
||||
// .well-known/lnurlp/{name} path; when it succeeds, its payLink is the
|
||||
// authoritative place to fetch the payRequest from
|
||||
const addressUrl = mintAddressUrl(url)
|
||||
let nodeInfo: MintAddressInfo | null = null
|
||||
let payUrl = url
|
||||
if (addressUrl) {
|
||||
try {
|
||||
nodeInfo = await fetchMintAddress(addressUrl, options)
|
||||
payUrl = nodeInfo.payLink
|
||||
} catch {
|
||||
// no mint-address support here - proceed with just the guess
|
||||
}
|
||||
}
|
||||
const info = await fetchPayRequest(payUrl, options)
|
||||
if (!info.withdrawLink) {
|
||||
throw new Error(
|
||||
'This payRequest does not advertise lnurlcash minting (no withdrawLink).'
|
||||
)
|
||||
}
|
||||
const grossMsat = ceilMsatToSat(
|
||||
info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat
|
||||
)
|
||||
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)
|
||||
const prepared: PreparedMint = {
|
||||
invoice: invoice.pr,
|
||||
verifyUrl: invoice.verify ?? null,
|
||||
expectedNoteValueMsat: amountMsat,
|
||||
grossMsat,
|
||||
mintUrl: payUrl,
|
||||
withdrawLink: info.withdrawLink,
|
||||
server: serverOf(payUrl),
|
||||
username: lightningAddressUsername(payUrl),
|
||||
nodeInfo
|
||||
}
|
||||
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
|
||||
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`
|
||||
possibleCopy?: NewBearer
|
||||
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.
|
||||
export const claimMintedNote = async (
|
||||
prepared: PreparedMint,
|
||||
poll: PollOptions = {},
|
||||
options: LnurlcashOptions = {}
|
||||
): Promise<ClaimedNote> => {
|
||||
if (!prepared.verifyUrl) {
|
||||
throw new Error(
|
||||
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.'
|
||||
)
|
||||
}
|
||||
const verifyUrl = prepared.verifyUrl
|
||||
const result = await pollVerifyUntilSettled(verifyUrl, poll, options)
|
||||
// a settled report only means this wallet's invoice was paid if it's for
|
||||
// the invoice this wallet actually requested
|
||||
if (!sameInvoice(result.pr, prepared.invoice)) {
|
||||
throw new Error(
|
||||
"The service's verify response is for a different invoice than requested."
|
||||
)
|
||||
}
|
||||
const preimage = result.preimage
|
||||
if (!preimage || !isPreimage(preimage)) {
|
||||
throw new Error(
|
||||
'The payment settled but the service did not reveal the preimage.'
|
||||
)
|
||||
}
|
||||
// declare the invoiced amount (a claim - not yet confirmed) so the note
|
||||
// is self-describing even before the verifying GET below
|
||||
const declaredUrl = buildNoteUrl(
|
||||
prepared.withdrawLink,
|
||||
preimage,
|
||||
prepared.expectedNoteValueMsat
|
||||
)
|
||||
// the service's maxWithdrawable is authoritative - SERVICE's own fee
|
||||
// math might not match this wallet's estimate, and the note is worth
|
||||
// exactly maxWithdrawable regardless
|
||||
const noteInfo = await fetchNoteInfo(declaredUrl, options)
|
||||
const mintPubkey = noteInfo.mintPubkey ?? prepared.mintPubkey
|
||||
const base: NewBearer = {
|
||||
url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable),
|
||||
callback: noteInfo.callback,
|
||||
amount: noteInfo.maxWithdrawable,
|
||||
verified: true
|
||||
}
|
||||
if (mintPubkey) base.mintPubkey = mintPubkey
|
||||
|
||||
let url = base.url
|
||||
let rotated = true
|
||||
let possibleCopy: NewBearer | undefined
|
||||
let rotationError: string | undefined
|
||||
try {
|
||||
const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options)
|
||||
url = withNewK1(
|
||||
declaredUrl,
|
||||
rotatedNote.k1,
|
||||
noteInfo.maxWithdrawable,
|
||||
rotatedNote.signature
|
||||
)
|
||||
} catch (err) {
|
||||
rotated = false
|
||||
if (err instanceof AmbiguousMutationError) {
|
||||
// the rotate request may have landed despite the failure - the fresh
|
||||
// secret it carried is then the only copy of this note
|
||||
const outcome = await probeBurnedNote(declaredUrl, options)
|
||||
if (outcome === 'gone') {
|
||||
// the burn landed - adopt the fresh secret as the note
|
||||
url = withNewK1(
|
||||
declaredUrl,
|
||||
err.newSecrets[0],
|
||||
noteInfo.maxWithdrawable
|
||||
)
|
||||
rotated = true
|
||||
} else if (outcome === 'unknown') {
|
||||
// can't tell: the preimage note is returned either way - the
|
||||
// possible rotated copy goes alongside it, both refreshable
|
||||
possibleCopy = {
|
||||
url: withNewK1(
|
||||
declaredUrl,
|
||||
err.newSecrets[0],
|
||||
noteInfo.maxWithdrawable
|
||||
),
|
||||
callback: noteInfo.callback,
|
||||
amount: noteInfo.maxWithdrawable,
|
||||
verified: false
|
||||
}
|
||||
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
|
||||
rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`
|
||||
} else {
|
||||
rotationError = err.message
|
||||
}
|
||||
} else {
|
||||
rotationError = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
const claimed: ClaimedNote = {note: {...base, url}, rotated}
|
||||
if (possibleCopy) claimed.possibleCopy = possibleCopy
|
||||
if (rotationError) claimed.rotationError = rotationError
|
||||
return claimed
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Paying over Lightning: melt held notes into a bolt11 invoice or a
|
||||
// Lightning Address payment. melt demands an exact amount match and takes
|
||||
// a single k1, so the notes are carved first (see carve.ts's
|
||||
// ensureExactAmount).
|
||||
|
||||
import {
|
||||
AmbiguousMutationError,
|
||||
NoteSpentError,
|
||||
PendingNoteError,
|
||||
decodeBolt11AmountMsat,
|
||||
fetchPayRequest,
|
||||
isBolt11Invoice,
|
||||
meltNote,
|
||||
requestInvoice,
|
||||
requireNoteK1,
|
||||
resolveLnurlInput,
|
||||
rotateNote,
|
||||
sameInvoice,
|
||||
withNewK1
|
||||
} from 'lnurlcash-kit'
|
||||
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import type {CarveResult} from './carve'
|
||||
import {ensureExactAmount} from './carve'
|
||||
import type {PollOptions} from './shared'
|
||||
import {pollVerifyUntilSettled} from './shared'
|
||||
|
||||
export type PayOutcome =
|
||||
| 'settled'
|
||||
| 'failed-funds-returned'
|
||||
| 'unknown-still-pending'
|
||||
// the service reports the carved note as already spent before the melt
|
||||
// even started - nothing was paid, and the note is definitively gone
|
||||
| 'note-already-spent'
|
||||
|
||||
export type PayResult = {
|
||||
outcome: PayOutcome
|
||||
carve: CarveResult
|
||||
// the invoice that was (attempted to be) paid
|
||||
invoice: string
|
||||
amountMsat: number
|
||||
verifyUrl: string | null
|
||||
// a fresh secret rescued from an ambiguous rotate during outcome
|
||||
// classification - the caller must track it unverified; if the rotate
|
||||
// landed, this is the only copy of the (returned) funds
|
||||
rescuedNote?: NewBearer
|
||||
}
|
||||
|
||||
export type PayOptions = {
|
||||
// required when `input` is a Lightning Address / LNURL-pay (a bolt11
|
||||
// carries its own amount)
|
||||
amountMsat?: number
|
||||
// verify-poll budget - tests shrink this
|
||||
poll?: PollOptions
|
||||
// kit transport overrides (fetch injection, timeouts)
|
||||
kit?: LnurlcashOptions
|
||||
}
|
||||
|
||||
// A melt's resolved promise only means the payment is in flight; the
|
||||
// outcome is classified by polling the melt's LUD-25 verify URL, then - if
|
||||
// that budget runs out - by attempting a rotate on the melted note (a
|
||||
// failed melt is never reported through the callback; it is only
|
||||
// observable as the note becoming spendable again, which a rotate proves
|
||||
// by succeeding - and rotates, since the melt put k1 on the wire anyway):
|
||||
// - settled: the payment went through; the note is gone for good
|
||||
// - failed-funds-returned: the note was spendable again, nothing was paid
|
||||
// - unknown-still-pending: neither confirmed; the note stays locked spent
|
||||
// locally until a refresh reconciles it
|
||||
export const payWithBearers = async (
|
||||
bearers: Bearer[],
|
||||
input: string,
|
||||
{amountMsat, poll = {}, kit = {}}: PayOptions = {}
|
||||
): Promise<PayResult> => {
|
||||
const options = kit
|
||||
let invoice: string
|
||||
let amount: number
|
||||
const trimmed = input.trim()
|
||||
if (isBolt11Invoice(trimmed)) {
|
||||
const decoded = decodeBolt11AmountMsat(trimmed)
|
||||
if (decoded === null || decoded <= 0) {
|
||||
throw new Error(
|
||||
'Could not read this invoice\'s amount - amount-less invoices are not supported.'
|
||||
)
|
||||
}
|
||||
invoice = trimmed
|
||||
amount = decoded
|
||||
} else {
|
||||
// a Lightning Address (or LNURL-pay) has no invoice of its own yet -
|
||||
// resolving it gets a payRequest, and an amount is needed before an
|
||||
// actual invoice exists
|
||||
const url = resolveLnurlInput(trimmed)
|
||||
if (!url) {
|
||||
throw new Error('Not a valid bolt11 invoice or Lightning Address.')
|
||||
}
|
||||
if (amountMsat === undefined || !Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||
throw new Error('Enter an amount to pay to this address.')
|
||||
}
|
||||
const info = await fetchPayRequest(url, options)
|
||||
if (amountMsat < info.minSendable || amountMsat > info.maxSendable) {
|
||||
throw new Error('Amount is outside the payee\'s sendable range.')
|
||||
}
|
||||
const result = await requestInvoice(info.callback, amountMsat, options)
|
||||
invoice = result.pr
|
||||
amount = amountMsat
|
||||
}
|
||||
|
||||
const carve = await ensureExactAmount(bearers, amount, options)
|
||||
const k1 = requireNoteK1(carve.note.url)
|
||||
let melt: MeltResult
|
||||
try {
|
||||
melt = await meltNote(carve.note.callback, k1, invoice, options)
|
||||
} catch (err) {
|
||||
// this melt names a single note, so a NoteSpentError here is
|
||||
// unambiguous - it's already gone, and gets locked spent the same way
|
||||
// a successful melt would have locked it
|
||||
if (err instanceof NoteSpentError) {
|
||||
return {outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!melt.verify) {
|
||||
// no melt proof to poll - the note locking as spent locally is all the
|
||||
// confirmation there is
|
||||
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl: null}
|
||||
}
|
||||
const verifyUrl = melt.verify
|
||||
try {
|
||||
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
|
||||
// a settled report is only this payment's proof when it's for the
|
||||
// invoice this melt actually paid - a mint that mixes up proofs must
|
||||
// not confirm the wrong payment. The verify URL is already scoped to
|
||||
// this melt's payment hash, so an exact string match binds it; short
|
||||
// of that, a proof pr that decodes to a DIFFERENT amount definitely
|
||||
// belongs to another payment, while an undecodable one says nothing
|
||||
// either way (a service regenerating synthetic prs in proofs) and is
|
||||
// tolerated.
|
||||
const proofAmount = decodeBolt11AmountMsat(proof.pr)
|
||||
if (
|
||||
!sameInvoice(proof.pr, invoice) &&
|
||||
proofAmount !== null &&
|
||||
proofAmount !== amount
|
||||
) {
|
||||
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
|
||||
}
|
||||
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
|
||||
} catch {
|
||||
// the verify budget ran out - probe the note itself with a rotate: a
|
||||
// failed melt is only observable as the note becoming spendable again
|
||||
try {
|
||||
const rotated = await rotateNote(carve.note.callback, k1, options)
|
||||
// the rotate succeeded, so the mint restored the note - and k1 had
|
||||
// been on the wire since the melt, so the rotation doubles as the
|
||||
// required re-securing of the returned funds
|
||||
return {
|
||||
outcome: 'failed-funds-returned',
|
||||
carve: {
|
||||
...carve,
|
||||
note: {
|
||||
...carve.note,
|
||||
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature)
|
||||
}
|
||||
},
|
||||
invoice,
|
||||
amountMsat: amount,
|
||||
verifyUrl
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof PendingNoteError) {
|
||||
// still locked mid-melt - no outcome either way
|
||||
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
|
||||
}
|
||||
if (err instanceof NoteSpentError) {
|
||||
// burned without a settled proof - the money is gone either way
|
||||
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
|
||||
}
|
||||
if (err instanceof AmbiguousMutationError) {
|
||||
// the rotate's answer was lost. Had the note still been pending,
|
||||
// the service would have said so cleanly - so the funds ARE back,
|
||||
// but whether the rotation landed is unknown: the original k1 may
|
||||
// be live, or the fresh secret may be the only copy. Surface both.
|
||||
const rescuedNote: NewBearer = {
|
||||
url: withNewK1(carve.note.url, err.newSecrets[0], amount),
|
||||
callback: carve.note.callback,
|
||||
amount,
|
||||
verified: false
|
||||
}
|
||||
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
|
||||
return {
|
||||
outcome: 'failed-funds-returned',
|
||||
carve,
|
||||
invoice,
|
||||
amountMsat: amount,
|
||||
verifyUrl,
|
||||
rescuedNote
|
||||
}
|
||||
}
|
||||
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Receiving a bearer note: wraps receive.ts's receiveNote +
|
||||
// secureReceivedNote into one flow - resolve whatever came in (note URL,
|
||||
// bech32, lnurlw://), verify it with the issuing service, then rotate
|
||||
// immediately, since the previous holder (and anything that logged the URL
|
||||
// in transit) still knows the old secret.
|
||||
|
||||
import {
|
||||
AmbiguousMutationError,
|
||||
NoteSpentError,
|
||||
NoteUnknownError,
|
||||
PendingNoteError,
|
||||
probeBurnedNote,
|
||||
withNewK1
|
||||
} from 'lnurlcash-kit'
|
||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {receiveNote, secureReceivedNote} from '../receive'
|
||||
import type {ClaimedNote} from './mint'
|
||||
|
||||
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
|
||||
// definitive and propagate; an unreachable service still yields the note,
|
||||
// unverified, at the sender's declared amount.
|
||||
export const receiveBearer = async (
|
||||
input: string,
|
||||
existing: Bearer[],
|
||||
options: LnurlcashOptions = {}
|
||||
): Promise<ClaimedNote> => {
|
||||
const note = await receiveNote(input, existing)
|
||||
if (!note.verified || !note.callback) {
|
||||
return {note, rotated: false}
|
||||
}
|
||||
try {
|
||||
const rotatedUrl = await secureReceivedNote(note)
|
||||
return {note: {...note, url: rotatedUrl}, rotated: true}
|
||||
} catch (err) {
|
||||
// a definitive service state (dead/unknown/locked mid-melt) is not a
|
||||
// rotation failure to warn about - it tells the holder what this note
|
||||
// actually is, so it propagates distinctly
|
||||
if (
|
||||
err instanceof NoteSpentError ||
|
||||
err instanceof PendingNoteError ||
|
||||
err instanceof NoteUnknownError
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
if (err instanceof AmbiguousMutationError) {
|
||||
const outcome = await probeBurnedNote(note.url, options)
|
||||
if (outcome === 'gone') {
|
||||
return {
|
||||
note: {
|
||||
...note,
|
||||
url: withNewK1(note.url, err.newSecrets[0], note.amount)
|
||||
},
|
||||
rotated: true
|
||||
}
|
||||
}
|
||||
if (outcome === 'unknown') {
|
||||
const possibleCopy: NewBearer = {
|
||||
url: withNewK1(note.url, err.newSecrets[0], note.amount),
|
||||
callback: note.callback,
|
||||
amount: note.amount,
|
||||
verified: false
|
||||
}
|
||||
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
|
||||
return {
|
||||
note,
|
||||
rotated: false,
|
||||
possibleCopy,
|
||||
rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`
|
||||
}
|
||||
}
|
||||
}
|
||||
// a failed rotate never fails the receive - the note is money as it
|
||||
// is; the caller warns that it must be treated as exposed
|
||||
return {
|
||||
note,
|
||||
rotated: false,
|
||||
rotationError: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Shared plumbing for the operations engine: the bounded verify polling
|
||||
// every flow that waits on a payment uses, and the uncertainty type a lost
|
||||
// mutation answer surfaces as.
|
||||
|
||||
import {fetchInvoiceVerification} from 'lnurlcash-kit'
|
||||
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
|
||||
import type {NewBearer} from '../types'
|
||||
|
||||
// a mutation's answer was lost AND the probe could not tell whether it
|
||||
// landed - the possible outputs the fresh secrets would control, for the
|
||||
// caller to track unverified alongside the (kept) inputs. Never dropped:
|
||||
// if the mutation did land, these are the only money left.
|
||||
export class UncertainOutcomeError extends Error {
|
||||
readonly possibleOutputs: NewBearer[]
|
||||
constructor(message: string, possibleOutputs: NewBearer[]) {
|
||||
super(message)
|
||||
this.name = 'UncertainOutcomeError'
|
||||
this.possibleOutputs = possibleOutputs
|
||||
}
|
||||
}
|
||||
|
||||
export type PollOptions = {
|
||||
// first delay between checks (doubles each round up to intervalCapMs)
|
||||
intervalMs?: number
|
||||
intervalCapMs?: number
|
||||
// total budget before giving up
|
||||
maxWaitMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POLL: Required<PollOptions> = {
|
||||
intervalMs: 1000,
|
||||
intervalCapMs: 5000,
|
||||
maxWaitMs: 120_000
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
|
||||
// backoff, inside a total time budget. A single failed check isn't fatal -
|
||||
// the next round tries again. Returns the settled VerifyResult; throws on
|
||||
// budget exhaustion.
|
||||
export const pollVerifyUntilSettled = async (
|
||||
verifyUrl: string,
|
||||
poll: PollOptions,
|
||||
options: LnurlcashOptions
|
||||
): Promise<VerifyResult> => {
|
||||
const {intervalMs, intervalCapMs, maxWaitMs} = {...DEFAULT_POLL, ...poll}
|
||||
const deadline = Date.now() + maxWaitMs
|
||||
let delay = intervalMs
|
||||
let lastError: unknown = null
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const result = await fetchInvoiceVerification(verifyUrl, options)
|
||||
if (result.settled) return result
|
||||
lastError = null
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
}
|
||||
await sleep(Math.min(delay, Math.max(0, deadline - Date.now())))
|
||||
delay = Math.min(delay * 2, intervalCapMs)
|
||||
}
|
||||
if (lastError instanceof Error) {
|
||||
throw new Error(`Payment not confirmed: ${lastError.message}`)
|
||||
}
|
||||
throw new Error('Payment not confirmed within the time budget.')
|
||||
}
|
||||
Reference in New Issue
Block a user