Merge pull request #8 from tompro/fix/carve-checkpoint

fix: commit carves before the settlement wait (#6)
This commit is contained in:
2026-08-31 14:57:49 +02:00
committed by GitHub
11 changed files with 268 additions and 40 deletions
+24 -3
View File
@@ -7,7 +7,7 @@ import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops';
import type { TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees';
import type { NewBearer } from '@/lnurlcash/types';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints';
@@ -135,14 +135,28 @@ export const useMoveFundsPage = () => {
try {
const ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted };
// the carve commits the moment it lands server-side (onCarve), not
// after the melt + settlement wait - an abort mid-wait can then
// never leave burned inputs looking spendable or strand the outputs
const carveState: { committed?: Bearer } = {};
const transfer = await transferBetweenMints(
wallet.bearers,
satsToMsat(sats),
targetInput.value,
{ assertOwner: ownerFence },
{
assertOwner: ownerFence,
onCarve: async (carve) => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
},
},
);
stage.value = 'Confirming the result…';
const carved = await commitCarve(wallet, transfer.carve, commitContext);
// a carve that mutated nothing fires no hook - the note is already
// tracked; look it up
const carved =
carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
if (!carved) throw new Error('The carved note was not tracked.');
if (transfer.rescuedNote) {
await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext);
}
@@ -163,6 +177,13 @@ export const useMoveFundsPage = () => {
);
toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') {
if (transfer.rotatedNote) {
// the source-probe rotate re-secured the returned funds at a
// fresh secret, burning the carved note the checkpoint already
// committed - add the new note BEFORE marking the old one spent
await addCommittedBearers(wallet, [transfer.rotatedNote], commitContext);
await wallet.markSpent(carved.id, ownerFence);
}
await activity.log(
'transfer',
`A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`,
+22 -3
View File
@@ -5,6 +5,7 @@ import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnur
import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { PayOutcome } from '@/lnurlcash/ops';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { useActivityStore } from '@/stores/activity';
@@ -145,15 +146,26 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
try {
ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted };
// the carve commits the moment it lands server-side (onCarve), not
// after the settlement wait - an abort mid-wait can then never leave
// burned inputs looking spendable or strand the fresh outputs
const carveState: { committed?: Bearer } = {};
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
};
const paid = await payWithBearers(
wallet.bearers,
payment.input,
payment.kind === 'address'
? { amountMsat: payment.amountMsat, assertOwner: ownerFence }
: { assertOwner: ownerFence },
? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve }
: { assertOwner: ownerFence, onCarve },
);
stage.value = 'Confirming the result…';
const committed = await commitCarve(wallet, paid.carve, commitContext);
// a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up
const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.');
if (paid.rescuedNote) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
}
@@ -166,6 +178,13 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('positive', `Paid ${sats} sats.`);
emit('sent');
} else if (paid.outcome === 'failed-funds-returned') {
if (paid.rotatedNote) {
// the classification rotate re-secured the returned funds at a
// fresh secret, burning the carved note the checkpoint already
// committed - add the new note BEFORE marking the old one spent
await addCommittedBearers(wallet, [paid.rotatedNote], commitContext);
await wallet.markSpent(committed.id, ownerFence);
}
await activity.log(
'transfer',
`A ${sats} sat payment failed - funds are back in your wallet.`,
+18 -2
View File
@@ -82,10 +82,26 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
let ownerFence: WalletOwnerFence | undefined;
try {
ownerFence = wallet.captureOwnerFence();
const fence = ownerFence;
// the carve commits the moment it lands server-side (onCarve), so
// even a crash right after can never strand the fresh outputs or
// leave burned inputs looking spendable
const carveState: { committed?: Bearer } = {};
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
assertOwner: ownerFence,
assertOwner: fence,
onCarve: async (landed) => {
carveState.committed = await commitCarve(wallet, landed, {
ownerFence: fence,
warn: warnCommitted,
});
},
});
const note = await commitCarve(wallet, carve, { ownerFence, warn: warnCommitted });
// a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up
const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.');
const note = committed;
if (carve.change) {
await activity.log(
'split',
+7 -6
View File
@@ -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
+41
View File
@@ -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/)
})
})
+25 -2
View File
@@ -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)
})
})
+47 -2
View File
@@ -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)
})
})
+18 -5
View File
@@ -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<CarveResult> => {
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
+27 -9
View File
@@ -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<PayResult> => {
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,
+11
View File
@@ -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<void>
}
export const assertFundOwner = (options: FundOperationOptions): void => {
+28 -8
View File
@@ -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<TransferResult> => {
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) {