Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/composables/useMoveFundsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand All @@ -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.`,
Expand Down
25 changes: 22 additions & 3 deletions src/composables/usePayInvoiceDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand All @@ -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.`,
Expand Down
20 changes: 18 additions & 2 deletions src/composables/useSendTokenDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 7 additions & 6 deletions src/lnurlcash/nwc/pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/lnurlcash/ops.carve.cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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/)
})
})
27 changes: 25 additions & 2 deletions src/lnurlcash/ops.pay.cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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)
})
})
49 changes: 47 additions & 2 deletions src/lnurlcash/ops.transfer.cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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, `[email protected]:${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, `[email protected]:${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)
})
})
23 changes: 18 additions & 5 deletions src/lnurlcash/ops/carve.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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})
}
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading