mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(lnurlcash): commit carves before the settlement wait (#6)
A flow that carves and then waits minutes on a settlement poll used to commit the carve only after the whole flow resolved - an abort mid-wait left burned inputs spendable locally and stranded the fresh outputs. ensureExactAmount now reports a mutation-bearing carve through the new FundOperationOptions.onCarve checkpoint the moment its outcome is known; payWithBearers and transferBetweenMints thread it through. Because the carve may now be committed before the flows' classification rotates run, those rotations no longer rewrite carve.note in the result - returned funds come back as a separate verified rotatedNote, so the committed carve and the re-secured note can both be persisted. NWC's payChangeset adapts to the rotatedNote field; its own commit timing is unchanged (follow-up).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user