diff --git a/src/lnurlcash/nwc/pay.ts b/src/lnurlcash/nwc/pay.ts index c03cbda..d05ea4e 100644 --- a/src/lnurlcash/nwc/pay.ts +++ b/src/lnurlcash/nwc/pay.ts @@ -23,18 +23,19 @@ import {errResult, okResult} from './protocol' // maps a PayResult onto the store delta, mirroring PayInvoiceDialog's // semantics: carve inputs lock spent, change is tracked, returned funds -// are tracked UNMARKED, rescued secrets are tracked unverified. One +// are tracked UNMARKED, rescued secrets are tracked unverified. The +// returned funds live in `rotatedNote` when the classification rotate +// re-secured them (burning the carved note in the process); one // deliberate divergence: in the exact-match + funds-returned case the -// original bearer (old k1, burned server-side by the classification -// rotate) is left for the next refresh to reconcile, exactly as the UI -// leaves it - the money itself sits in the re-secured note, which IS -// tracked. +// original bearer (old k1, burned server-side by that rotate) is left for +// the next refresh to reconcile, exactly as the UI leaves it - the money +// itself sits in the re-secured note, which IS tracked. export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { const add: NewBearer[] = [] const markSpent: string[] = result.carve.consumed.map((b) => b.id) if (result.carve.change) add.push(result.carve.change) if (result.outcome === 'failed-funds-returned') { - add.push(result.carve.note) + add.push(result.rotatedNote ?? result.carve.note) } else { // settled / still-pending / already-spent: the carved note is gone or // locked. When it was one of the wallet's own bearers (an exact-match diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts index bef3e74..c9a8327 100644 --- a/src/lnurlcash/ops.carve.cases.ts +++ b/src/lnurlcash/ops.carve.cases.ts @@ -2,6 +2,7 @@ import {describe, expect, it} from 'vitest' import {fetchNoteInfo, noteK1} from 'lnurlcash-kit' import type {Bearer} from './types' +import type {CarveResult} from './ops' import {UncertainOutcomeError, ensureExactAmount} from './ops' import {requiredValue} from './test-utils' import {makeBearer, mint, noteUrl, secret} from './ops.testHarness' @@ -119,4 +120,44 @@ describe('ensureExactAmount', () => { (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable, ).toBe(16_000) }) + + it('reports a mutation-bearing carve through onCarve before returning', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('12'), 21_000) + const seen: CarveResult[] = [] + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: (carve) => { + seen.push(carve) + }, + }) + // the hook saw exactly the returned result, once + expect(seen).toHaveLength(1) + expect(seen[0]).toBe(result) + expect(result.consumed).toHaveLength(1) + }) + + it('never fires onCarve for an already-exact note - nothing was burned', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('13'), 21_000) + let called = false + const result = await ensureExactAmount([bearer], 21_000, { + onCarve: () => { + called = true + }, + }) + expect(called).toBe(false) + expect(result.consumed).toEqual([]) + }) + + it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('14'), 21_000) + await expect( + ensureExactAmount([bearer], 5_000, { + onCarve: () => { + throw new Error('commit failed') + }, + }), + ).rejects.toThrow(/commit failed/) + }) }) diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts index 5c27833..08b1e7c 100644 --- a/src/lnurlcash/ops.pay.cases.ts +++ b/src/lnurlcash/ops.pay.cases.ts @@ -51,9 +51,14 @@ describe('payWithBearers', () => { }) expect(result.outcome).toBe('failed-funds-returned') expect(instance.state.noteState(secret('33'))).toBe('burned') - const returnedK1 = requiredValue(noteK1(result.carve.note.url)) + // the returned funds come back re-secured at the classification + // rotate's fresh secret - carried as rotatedNote, NOT folded into the + // carve (which an onCarve checkpoint may already have committed) + const rotatedNote = requiredValue(result.rotatedNote) + expect(rotatedNote.verified).toBe(true) + const returnedK1 = requiredValue(noteK1(rotatedNote.url)) expect(instance.state.noteState(returnedK1)).toBe('outstanding') - expect(result.carve.note.amount).toBe(21_000) + expect(rotatedNote.amount).toBe(21_000) }) it('classifies a never-settling melt as unknown-still-pending', async () => { @@ -72,4 +77,22 @@ describe('payWithBearers', () => { await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/) await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i) }) + + it('aborts before the melt when the carve commit hook fails', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('36'), 21_000) + // paying 10_500 off a 21_000 note forces a split, which must commit + // through the hook before the melt may start + await expect( + payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', { + onCarve: () => { + throw new Error('commit failed') + }, + }), + ).rejects.toThrow(/commit failed/) + // the split landed (the input is burned) but the melt never happened: + // nothing sits pending at the mint + expect(instance.state.noteState(secret('36'))).toBe('burned') + expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) + }) }) diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 131f1b4..7ef3767 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -3,6 +3,7 @@ import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {hashK1, noteK1} from 'lnurlcash-kit' import {transferBetweenMints} from './ops' +import type {CarveResult} from './ops' import {requiredValue} from './test-utils' import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' @@ -128,10 +129,15 @@ describe('transferBetweenMints', () => { expect(result.outcome).toBe('failed-funds-returned') expect(result.mintedAtTarget).toBeUndefined() expect(source.state.noteState(k1)).toBe('burned') - const returnedK1 = requiredValue(noteK1(result.carve.note.url)) + // the returned funds come back re-secured at the source-probe rotate's + // fresh secret - carried as rotatedNote, NOT folded into the carve + // (which an onCarve checkpoint may already have committed) + const rotatedNote = requiredValue(result.rotatedNote) + expect(rotatedNote.verified).toBe(true) + const returnedK1 = requiredValue(noteK1(rotatedNote.url)) expect(returnedK1).not.toBe(k1) expect(source.state.noteState(returnedK1)).toBe('outstanding') - expect(result.carve.note.amount).toBe(21_000) + expect(rotatedNote.amount).toBe(21_000) }) it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => { @@ -212,4 +218,43 @@ describe('transferBetweenMints', () => { expect(newK1).not.toBe(preimage) expect(target.state.noteState(newK1)).toBe('outstanding') }) + + it('fires onCarve after the carve landed and before the melt starts', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const bearer = await makeBearer(source, secret('51'), 21_000) + const seen: CarveResult[] = [] + const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + onCarve: (carve) => { + seen.push(carve) + // at hook time the melt must NOT have started: the carved note is + // still outstanding (a melt would leave it pending) + expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding') + }, + }) + await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + expect(seen).toHaveLength(1) + expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + }) + + it('aborts before the melt when the carve commit hook fails', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const k1 = secret('52') + const bearer = await makeBearer(source, k1, 21_000) + await expect( + transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, { + onCarve: () => { + throw new Error('commit failed') + }, + }), + ).rejects.toThrow(/commit failed/) + // the split landed (the input is burned) but the melt never happened: + // nothing sits pending at the source, nothing was ever paid + expect(source.state.noteState(k1)).toBe('burned') + expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) + }) }) diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index 265eed2..b8b65c2 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -1,6 +1,11 @@ // 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. +// +// A carve whose mutation landed reports itself through options.onCarve +// BEFORE returning, so a caller about to do something slow (a settlement +// wait) can durably commit first - a flow aborted mid-wait must never +// leave burned inputs looking spendable or strand the fresh outputs. import { AmbiguousMutationError, @@ -81,6 +86,14 @@ export const ensureExactAmount = async ( const total = pick.reduce((sum, b) => sum + b.amount, 0) const k1s = pick.map((b) => requireNoteK1(b.url)) + // a carve that burned inputs reports itself the moment its outcome is + // known, before the caller's flow moves on to anything slow or + // uncertain - see FundOperationOptions.onCarve + const checkpoint = async (result: CarveResult): Promise => { + await options.onCarve?.(result) + return result + } + if (pick.length === 1 && total === amountMsat) { // already exact - hand over the note itself, untouched return { @@ -113,10 +126,10 @@ export const ensureExactAmount = async ( // 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} + if (merged.rescued) return checkpoint({note: unverified, consumed: pick}) try { const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) - return { + return checkpoint({ note: { url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), callback: settled.callback, @@ -125,9 +138,9 @@ export const ensureExactAmount = async ( mintPubkey: base.mintPubkey, }, consumed: pick, - } + }) } catch { - return {note: unverified, consumed: pick} + return checkpoint({note: unverified, consumed: pick}) } } @@ -264,7 +277,7 @@ export const ensureExactAmount = async ( if (!(error instanceof Error)) throw error } } - return {note, change, consumed: pick} + return checkpoint({note, change, consumed: pick}) } // smallest-first accumulation until the target is covered; null when the diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index c1fd878..f5d397b 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -41,6 +41,13 @@ export type PayResult = { invoice: string amountMsat: number verifyUrl: string | null + // on failed-funds-returned, when the classification rotate succeeded: + // the returned funds re-secured at a fresh secret. Kept OUT of `carve` + // (which always describes the carve as committed by the onCarve + // checkpoint) so an early-committed carve and a post-wait rotation can + // both be persisted - the carved note the rotate burned must be marked + // spent, this note added + rotatedNote?: NewBearer // 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 @@ -56,6 +63,10 @@ export type PayOptions = { // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions assertOwner?: () => void + // carve checkpoint - committed before the melt and the settlement wait, + // so an abort mid-wait never strands the carve (see + // FundOperationOptions.onCarve) + onCarve?: FundOperationOptions['onCarve'] } // A melt's resolved promise only means the payment is in flight; the @@ -71,9 +82,13 @@ export type PayOptions = { export const payWithBearers = async ( bearers: Bearer[], input: string, - {amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {}, + {amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {}, ): Promise => { - const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit + const options: FundOperationOptions = { + ...kit, + ...(assertOwner ? {assertOwner} : {}), + ...(onCarve ? {onCarve} : {}), + } let invoice: string let amount: number const trimmed = input.trim() @@ -150,15 +165,18 @@ export const payWithBearers = async ( 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 + // required re-securing of the returned funds. Reported separately + // from `carve` (see PayResult.rotatedNote): the carve may already be + // committed, and its note is what this rotate burned return { outcome: 'failed-funds-returned', - carve: { - ...carve, - note: { - ...carve.note, - url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), - }, + carve, + rotatedNote: { + url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), + callback: carve.note.callback, + amount, + verified: true, + ...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}), }, invoice, amountMsat: amount, diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index 62f989a..68573d2 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -6,6 +6,7 @@ import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit' import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit' import type {NewBearer} from '../types' +import type {CarveResult} from './carve' // 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 @@ -31,6 +32,16 @@ export class PollAbortedError extends Error { export type FundOperationOptions = LnurlcashOptions & { readonly assertOwner?: () => void + // called by a carve the moment its mutation has LANDED server-side (the + // inputs are burned, the outputs are known), before the flow moves on to + // anything slow or uncertain - the caller's one chance to durably commit + // the changeset so an abort during a long settlement wait can never + // strand the outputs or leave burned inputs looking spendable. If it + // rejects, the flow stops BEFORE anything further is spent and the + // rejection propagates: the carve state is then landed-but-maybe- + // uncommitted, which the caller must surface loudly. Never called for a + // carve that mutated nothing. + readonly onCarve?: (carve: CarveResult) => void | Promise } export const assertFundOwner = (options: FundOperationOptions): void => { diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index 6141214..c41a344 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -108,6 +108,13 @@ export type TransferResult = { targetServer: string // the fresh target note, on 'settled' mintedAtTarget?: ClaimedNote + // on failed-funds-returned, when the source-probe rotate succeeded: the + // returned funds re-secured at a fresh secret. Kept OUT of `carve` + // (which always describes the carve as committed by the onCarve + // checkpoint) so an early-committed carve and a post-wait rotation can + // both be persisted - the carved note the rotate burned must be marked + // spent, this note added + rotatedNote?: NewBearer // present whenever the claim could still complete later claimMaterial?: TransferClaimMaterial // a fresh secret rescued from an ambiguous rotate while classifying the @@ -121,6 +128,10 @@ export type TransferOptions = { // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions assertOwner?: () => void + // carve checkpoint - committed before the melt and the target + // settlement wait, so an abort mid-wait never strands the carve (see + // FundOperationOptions.onCarve) + onCarve?: FundOperationOptions['onCarve'] } // the claim retry material for a named target: the note at the wallet's @@ -140,9 +151,13 @@ export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, - {poll = {}, kit = {}, assertOwner}: TransferOptions = {}, + {poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {}, ): Promise => { - const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit + const options: FundOperationOptions = { + ...kit, + ...(assertOwner ? {assertOwner} : {}), + ...(onCarve ? {onCarve} : {}), + } if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } @@ -331,15 +346,20 @@ export const transferBetweenMints = async ( // as the way back to the money if the invoice settles later try { const rotated = await rotateNote(carve.note.callback, k1, options) + // the rotate succeeded: the melt provably never landed, and the + // funds are back - re-secured, since the melt attempt put k1 on the + // wire. Reported separately from `carve` (see rotatedNote): the + // carve may already be committed, and its note is what this rotate + // burned return { ...base, outcome: 'failed-funds-returned', - carve: { - ...carve, - note: { - ...carve.note, - url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature), - }, + rotatedNote: { + url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature), + callback: carve.note.callback, + amount: carve.note.amount, + verified: true, + ...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}), }, } } catch (err) {