From 3876791a0bc12851d74f7dd98424c4b30b788207 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 10:22:51 +0200 Subject: [PATCH] feat(funds): recover staged mutations --- src/lnurlcash/mintContract.test.ts | 90 ++++-- src/lnurlcash/ops.carve-crash.cases.ts | 165 +++++++++++ src/lnurlcash/ops.carve.cases.ts | 146 ++++++++-- src/lnurlcash/ops.mint-named.cases.ts | 117 +++----- src/lnurlcash/ops.mint-receive.cases.ts | 61 +--- src/lnurlcash/ops.pay.cases.ts | 61 +++- src/lnurlcash/ops.retry-refusal.cases.ts | 44 ++- src/lnurlcash/ops.signature.cases.ts | 145 ++++++++++ src/lnurlcash/ops.test.ts | 2 + src/lnurlcash/ops.testHarness.ts | 9 + src/lnurlcash/ops.transfer.cases.ts | 145 +++++----- src/lnurlcash/ops.ts | 33 ++- src/lnurlcash/ops/carve.ts | 330 +++++++++------------ src/lnurlcash/ops/carveRecovery.ts | 50 ++++ src/lnurlcash/ops/mint.ts | 345 +++++++--------------- src/lnurlcash/ops/pay.ts | 50 ++-- src/lnurlcash/ops/receiveBearer.ts | 10 +- src/lnurlcash/ops/shared.ts | 71 ++++- src/lnurlcash/ops/transfer.ts | 300 +++++++++---------- src/lnurlcash/types.ts | 15 + src/stores/walletFunds.recovery.test.ts | 350 +++++++++++++++++++++++ src/stores/walletFunds.ts | 268 ++++++++++++++++- 22 files changed, 1838 insertions(+), 969 deletions(-) create mode 100644 src/lnurlcash/ops.carve-crash.cases.ts create mode 100644 src/lnurlcash/ops.signature.cases.ts create mode 100644 src/lnurlcash/ops/carveRecovery.ts create mode 100644 src/stores/walletFunds.recovery.test.ts diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts index 36594f9..1b73be9 100644 --- a/src/lnurlcash/mintContract.test.ts +++ b/src/lnurlcash/mintContract.test.ts @@ -8,10 +8,10 @@ // mock emits either, selected by its withdrawLinkForm option, so do NOT // assume a default anywhere in the receive path); and the LUD-06 quote // request must carry the output-naming hash (as BOTH `comment` and `h`) -// exactly when - and only when - the payRequest advertised the capability -// (commentAllowed >= 64 or mintToHash): a new-generation mint -// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must -// see the byte-identical request this wallet always sent. +// only when the payRequest advertises commentAllowed >= 64. The wallet's +// output secret must be durably staged before that quote request is sent; +// mintToHash alone is only an additive receipt extension, not permission +// to mint. import {afterEach, describe, expect, it} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' @@ -47,6 +47,7 @@ const settleLastInvoice = async (m: Mint): Promise => { } const MINT_PUBKEY = `02${'ab'.repeat(32)}` +const persistOutput = async (): Promise => undefined // a mint-address (LUD-25) wire response exactly as lnurl-mint serves it: // node stats under their WIRE names - `nodeCapacity` is msat like every @@ -123,7 +124,7 @@ describe('mint-address wire contract', () => { it("surfaces the mock mint's mint-address node stats through prepareMint", async () => { const m = await mint() - const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput}) // the metadata is advertised at the mint-address endpoint itself - // the payRequest never carried it expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) @@ -140,7 +141,7 @@ describe('withdraw-link forms', () => { // conformance 0.7.0 defaults to plain https links; lnurlw:// is an // explicit LUD-17 transport form const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) - const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput}) expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) // and the link is fully usable: settle the invoice, claim the note @@ -150,9 +151,9 @@ describe('withdraw-link forms', () => { intervalCapMs: 50, maxWaitMs: 5_000, }) - expect(claimed.rotated).toBe(true) expect(claimed.note.amount).toBe(21_000) - expect(m.state.noteState(preimage)).toBe('burned') + expect(m.state.noteState(preimage)).toBeNull() + expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding') }) it('accepts an HTTPS withdraw link', async () => { @@ -166,13 +167,14 @@ describe('withdraw-link forms', () => { minSendable: 1_000, maxSendable: 100_000_000_000, withdrawLink: 'https://mint.example/note', + commentAllowed: 64, metadata: '[]', }, ], // amount-less invoice: the kit skips its amount cross-check ['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}], ]) - const prepared = await prepareMint('mint@mint.example', 21_000, {fetch}) + const prepared = await prepareMint('mint@mint.example', 21_000, {fetch, persistOutput}) expect(prepared.withdrawLink).toBe('https://mint.example/note') // the mint-address payLink is authoritative - the payRequest came from it expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint') @@ -210,27 +212,39 @@ describe('quote output-naming wire contract', () => { return new URL(hit) } - it('sends no comment/h to a mint that never advertised output naming', async () => { + it('rejects a mint that cannot carry the mandatory output commitment', async () => { const seen: string[] = [] - const prepared = await prepareMint('mint@mint.example', 21_000, { - fetch: capturingFetch(routesFor(payRequestFixture({})), seen), - }) - expect(prepared.mode).toBe('unnamed') - expect(prepared.noteSecret).toBeUndefined() - // an old mint sees the byte-identical request this wallet always sent - const url = callbackUrl(seen) - expect(url.searchParams.get('comment')).toBeNull() - expect(url.searchParams.get('h')).toBeNull() + await expect( + prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({})), seen), + persistOutput, + }), + ).rejects.toThrow(/64-character output commitment/i) + expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) }) - it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => { + it('persists the wallet output before sending its hash as comment and h', async () => { const seen: string[] = [] + const events: string[] = [] const prepared = await prepareMint('mint@mint.example', 21_000, { - fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), + fetch: (input, init) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery') + return capturingFetch( + routesFor(payRequestFixture({commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}`})), + seen, + )(input, init) + }, + persistOutput: async (note) => { + events.push('persist') + expect(note.verified).toBe(false) + expect(note.callback).toBe('') + expect(note.amount).toBe(0) + expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY) + }, }) - expect(prepared.mode).toBe('named') const noteSecret = prepared.noteSecret - if (!noteSecret) throw new Error('named mode without a note secret') const url = callbackUrl(seen) // both spellings go out together: `comment` is the LUD-25 form // lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read @@ -239,16 +253,30 @@ describe('quote output-naming wire contract', () => { expect(comment).toMatch(/^[0-9a-f]{64}$/) expect(h).toBe(comment) expect(comment).toBe(hashK1(noteSecret)) + expect(prepared.mintPubkey).toBe(MINT_PUBKEY) + expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0) + expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice')) }) - it('names the output when only the mintToHash spelling is advertised', async () => { + it('does not request an invoice when output persistence fails', async () => { const seen: string[] = [] - const prepared = await prepareMint('mint@mint.example', 21_000, { - fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), - }) - expect(prepared.mode).toBe('named') - const noteSecret = prepared.noteSecret - if (!noteSecret) throw new Error('named mode without a note secret') - expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret)) + await expect( + prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), + persistOutput: () => Promise.reject(new Error('storage full')), + }), + ).rejects.toThrow(/storage full/) + expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) + }) + + it('rejects mintToHash without the mandatory comment capacity', async () => { + const seen: string[] = [] + await expect( + prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), + persistOutput, + }), + ).rejects.toThrow(/64-character output commitment/i) + expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) }) }) diff --git a/src/lnurlcash/ops.carve-crash.cases.ts b/src/lnurlcash/ops.carve-crash.cases.ts new file mode 100644 index 0000000..ff236ee --- /dev/null +++ b/src/lnurlcash/ops.carve-crash.cases.ts @@ -0,0 +1,165 @@ +import {describe, expect, it} from 'vitest' +import {noteK1} from 'lnurlcash-kit' + +import type {CarveResult} from './ops' +import {ensureExactAmount, UnsupportedMultiBatchMergeError} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, secret} from './ops.testHarness' + +const secretSource = (...secrets: string[]): (() => string) => { + let index = 0 + return () => { + const next = secrets[index] + if (!next) throw new Error('The test exhausted its prepared secrets.') + index += 1 + return next + } +} + +describe('ensureExactAmount crash safety', () => { + it('stages both split secrets before either hash reaches the mint', async () => { + const instance = await mint() + const sourceSecret = secret('70') + const targetSecret = secret('71') + const changeSecret = secret('72') + const bearer = await makeBearer(instance, sourceSecret, 21_000) + let staged: CarveResult | undefined + const observingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) { + expect(staged).toBeDefined() + expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret) + expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret) + expect(instance.state.noteState(sourceSecret)).toBe('outstanding') + } + return fetch(input, init) + } + + const result = await ensureExactAmount([bearer], 5_000, { + fetch: observingFetch, + randomSecret: secretSource(targetSecret, changeSecret), + onCarve: (prepared) => { + staged = prepared + }, + }) + + expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + }) + + it('stages the merge secret before its hash reaches the mint', async () => { + const instance = await mint() + const firstSecret = secret('73') + const secondSecret = secret('74') + const mergedSecret = secret('75') + const first = await makeBearer(instance, firstSecret, 3_000) + const second = await makeBearer(instance, secondSecret, 4_000) + let staged: CarveResult | undefined + const observingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) { + expect(staged).toBeDefined() + expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret) + expect(instance.state.noteState(firstSecret)).toBe('outstanding') + expect(instance.state.noteState(secondSecret)).toBe('outstanding') + } + return fetch(input, init) + } + + await ensureExactAmount([first, second], 7_000, { + fetch: observingFetch, + randomSecret: secretSource(mergedSecret), + onCarve: (prepared) => { + staged = prepared + }, + }) + + expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]) + }) + + it('does not expose a staged split output to a second ambiguous rotation', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('76'), 21_000) + let mutationCount = 0 + const dropSecondMutation: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (!url.includes('/w/cb')) return fetch(input, init) + mutationCount += 1 + const response = await fetch(input, init) + if (mutationCount === 2) { + await response.arrayBuffer() + throw new Error('second mutation response lost') + } + return response + } + + const result = await ensureExactAmount([bearer], 5_000, { + fetch: dropSecondMutation, + mutationRetries: 0, + randomSecret: secretSource(secret('77'), secret('78')), + onCarve: () => undefined, + }) + + expect(mutationCount).toBe(1) + const targetSecret = requiredValue(noteK1(result.note.url)) + const changeSecret = requiredValue(noteK1(requiredValue(result.change).url)) + expect(instance.state.noteState(targetSecret)).toBe('outstanding') + expect(instance.state.noteState(changeSecret)).toBe('outstanding') + }) + + it('rejects a multi-batch merge before any partial fold can land', async () => { + const instance = await mint() + const bearers = await Promise.all( + Array.from({length: 21}, (_, index) => + makeBearer(instance, secret((128 + index).toString(16)), 1_000), + ), + ) + let mutationCount = 0 + const countingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) mutationCount += 1 + return fetch(input, init) + } + + const failure = ensureExactAmount(bearers, 21_000, { + fetch: countingFetch, + onCarve: () => undefined, + }) + await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError) + expect(mutationCount).toBe(0) + expect( + bearers.every( + (bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding', + ), + ).toBe(true) + }) + + it('checks ownership again immediately before a merge mutation', async () => { + const instance = await mint() + const firstSecret = secret('79') + const secondSecret = secret('7a') + const first = await makeBearer(instance, firstSecret, 3_000) + const second = await makeBearer(instance, secondSecret, 4_000) + let ownerChecks = 0 + let mutationCount = 0 + const countingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) mutationCount += 1 + return fetch(input, init) + } + + await expect( + ensureExactAmount([first, second], 7_000, { + fetch: countingFetch, + assertOwner: () => { + ownerChecks += 1 + if (ownerChecks === 3) throw new Error('wallet owner changed') + }, + onCarve: () => undefined, + }), + ).rejects.toThrow(/owner changed/) + + expect(mutationCount).toBe(0) + expect(instance.state.noteState(firstSecret)).toBe('outstanding') + expect(instance.state.noteState(secondSecret)).toBe('outstanding') + }) +}) diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts index c9a8327..897fa9f 100644 --- a/src/lnurlcash/ops.carve.cases.ts +++ b/src/lnurlcash/ops.carve.cases.ts @@ -1,11 +1,11 @@ import {describe, expect, it} from 'vitest' -import {fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {fetchNoteInfo, noteK1, noteSignature} from 'lnurlcash-kit' import type {Bearer} from './types' import type {CarveResult} from './ops' -import {UncertainOutcomeError, ensureExactAmount} from './ops' +import {CarveCheckpointRequiredError, UncertainOutcomeError, ensureExactAmount} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, secret} from './ops.testHarness' +import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness' describe('ensureExactAmount', () => { it('returns an already-exact note untouched, burning nothing', async () => { @@ -23,7 +23,10 @@ describe('ensureExactAmount', () => { const instance = await mint() const k1 = secret('02') const bearer = await makeBearer(instance, k1, 21_000) - const result = await ensureExactAmount([bearer], 5_000) + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) expect(result.note.amount).toBe(5_000) expect(result.note.verified).toBe(true) expect(result.change?.amount).toBe(16_000) @@ -39,7 +42,7 @@ describe('ensureExactAmount', () => { const instance = await mint() const first = await makeBearer(instance, secret('03'), 3_000) const second = await makeBearer(instance, secret('04'), 4_000) - const result = await ensureExactAmount([first, second], 7_000) + const result = await ensureExactAmount([first, second], 7_000, {onCarve: () => undefined}) expect(result.note.amount).toBe(7_000) expect(result.change).toBeUndefined() expect(result.consumed).toHaveLength(2) @@ -53,7 +56,7 @@ describe('ensureExactAmount', () => { const instance = await mint() const first = await makeBearer(instance, secret('05'), 3_000) const second = await makeBearer(instance, secret('06'), 4_000) - const result = await ensureExactAmount([first, second], 5_000) + const result = await ensureExactAmount([first, second], 5_000, {onCarve: () => undefined}) expect(result.note.amount).toBe(5_000) expect(result.change?.amount).toBe(2_000) expect(result.consumed).toHaveLength(2) @@ -68,7 +71,7 @@ describe('ensureExactAmount', () => { const spentBearer = await makeBearer(instance, secret('07'), 50_000) const unverified: Bearer = { ...(await makeBearer(instance, secret('08'), 50_000)), - callback: '', + verified: false, } await expect( ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000), @@ -85,7 +88,7 @@ describe('ensureExactAmount', () => { const instance = await mint({dropAfterMutation: true}) const k1 = secret('10') const bearer = await makeBearer(instance, k1, 21_000) - const result = await ensureExactAmount([bearer], 5_000) + const result = await ensureExactAmount([bearer], 5_000, {onCarve: () => undefined}) const partK1 = requiredValue(noteK1(result.note.url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) expect(partK1).not.toBe(k1) @@ -95,33 +98,40 @@ describe('ensureExactAmount', () => { }) it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => { - const instance = await mint({dropAfterMutation: true}) + const instance = await mint() const k1 = secret('11') const bearer = await makeBearer(instance, k1, 21_000) - const probeKillingFetch: typeof fetch = (input, init) => { + // every mutation answer is lost - the first attempt's AND the forced + // byte-identical replay's (the mint executed both; the replay is the + // recorded success served again, then dropped by the wrapper) - and + // every informational probe is unreachable, so neither the mutation + // nor the probe can be confirmed + const everythingDyingFetch: typeof fetch = async (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - if (url.includes('/w/cb')) return fetch(input, init) + if (url.includes('/w/cb')) { + const response = await fetch(input, init) + await response.arrayBuffer() + throw new Error('mutation response lost') + } return Promise.reject(new Error('probe unreachable')) } + const checkpoints: CarveResult[] = [] const failure = await ensureExactAmount([bearer], 5_000, { - fetch: probeKillingFetch, + fetch: everythingDyingFetch, + onCarve: (carve) => { + checkpoints.push(carve) + }, }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(UncertainOutcomeError) if (!(failure instanceof UncertainOutcomeError)) throw failure - expect(failure.possibleOutputs).toHaveLength(2) - const first = requiredValue(failure.possibleOutputs[0]) - const second = requiredValue(failure.possibleOutputs[1]) - expect(first.amount).toBe(5_000) - expect(second.amount).toBe(16_000) - expect( - (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable, - ).toBe(5_000) - expect( - (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable, - ).toBe(16_000) + expect(failure.possibleOutputs).toEqual([]) + expect(checkpoints).toHaveLength(1) + expect(checkpoints[0]?.consumed).toEqual([]) + expect(checkpoints[0]?.note.amount).toBe(5_000) + expect(checkpoints[0]?.change?.amount).toBe(16_000) }) - it('reports a mutation-bearing carve through onCarve before returning', async () => { + it('stages outputs before wire and retires inputs only after the mutation lands', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('12'), 21_000) const seen: CarveResult[] = [] @@ -129,13 +139,82 @@ describe('ensureExactAmount', () => { onCarve: (carve) => { seen.push(carve) }, + mintSignatureKeys: signatureKeysFor(instance), }) - // the hook saw exactly the returned result, once - expect(seen).toHaveLength(1) - expect(seen[0]).toBe(result) + expect(seen).toHaveLength(2) + expect(seen[0]?.note.verified).toBe(false) + expect(seen[0]?.consumed).toEqual([]) + expect(seen[0]?.change).toBeDefined() + expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url)) + expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe( + noteK1(requiredValue(result.change).url), + ) + // the landed checkpoint re-reports the SAME staged output (same secret) + // - its URL has since gained the mint's signature + expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url)) + expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull() + expect(seen[1]?.note.verified).toBe(true) + expect(seen[1]?.change).toBeUndefined() + expect(seen[1]?.consumed).toEqual(result.consumed) expect(result.consumed).toHaveLength(1) }) + it('does not stage the split change again during the landed checkpoint', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('17'), 21_000) + const trackedSecrets = new Set() + const retiredIds = new Set() + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: (carve) => { + // checkpoints identify an output by its secret: the landed phase's + // URL has gained a signature the staged phase could not have had + const k1 = noteK1(carve.note.url) + if (k1) trackedSecrets.add(k1) + const changeK1 = carve.change ? noteK1(carve.change.url) : null + if (changeK1) trackedSecrets.add(changeK1) + for (const consumed of carve.consumed) retiredIds.add(consumed.id) + }, + }) + + expect(trackedSecrets).toEqual( + new Set([ + requiredValue(noteK1(result.note.url)), + requiredValue(noteK1(requiredValue(result.change).url)), + ]), + ) + expect(retiredIds).toEqual(new Set([bearer.id])) + }) + + it('keeps staged outputs recoverable when the landed checkpoint fails', async () => { + const instance = await mint() + const sourceSecret = secret('15') + const bearer = await makeBearer(instance, sourceSecret, 21_000) + const seen: CarveResult[] = [] + + await expect( + ensureExactAmount([bearer], 5_000, { + onCarve: (carve) => { + seen.push(carve) + if (carve.consumed.length > 0) throw new Error('final commit failed') + }, + }), + ).rejects.toThrow(/final commit failed/) + + expect(seen).toHaveLength(2) + expect(seen[0]?.consumed).toEqual([]) + expect(seen[0]?.change).toBeDefined() + expect(instance.state.noteState(sourceSecret)).toBe('burned') + expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe( + 'outstanding', + ) + expect( + instance.state.noteState( + requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)), + ), + ).toBe('outstanding') + }) + 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) @@ -149,7 +228,17 @@ describe('ensureExactAmount', () => { expect(result.consumed).toEqual([]) }) - it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => { + it('requires a typed durable checkpoint before a mutating carve', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('16'), 21_000) + + await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf( + CarveCheckpointRequiredError, + ) + expect(instance.state.noteState(secret('16'))).toBe('outstanding') + }) + + it('propagates an onCarve failure before the carve reaches the mint', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('14'), 21_000) await expect( @@ -159,5 +248,6 @@ describe('ensureExactAmount', () => { }, }), ).rejects.toThrow(/commit failed/) + expect(instance.state.noteState(secret('14'))).toBe('outstanding') }) }) diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts index 5e2d3de..886f165 100644 --- a/src/lnurlcash/ops.mint-named.cases.ts +++ b/src/lnurlcash/ops.mint-named.cases.ts @@ -4,118 +4,65 @@ import {hashK1, noteK1} from 'lnurlcash-kit' import {claimMintedNote, prepareMint} from './ops' import {requiredValue} from './test-utils' import type {Mint} from './ops.testHarness' -import {mint, settleLastInvoice} from './ops.testHarness' +import {mint, persistOutput, settleLastInvoice} from './ops.testHarness' const HEX32 = /^[0-9a-f]{64}$/ -// the invoice whose quote the wallet bound to its own output hash const boundInvoice = (instance: Mint) => [...instance.state.invoices.values()].find((invoice) => invoice.boundTo) -describe('mint -> claim, named output (comment/mintToHash)', () => { +describe('mint -> claim, comment-bound output', () => { it('names the quote with the hash of a wallet-chosen secret', async () => { - const instance = await mint({commentAllowed: 64, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - expect(prepared.mode).toBe('named') - const noteSecret = requiredValue(prepared.noteSecret) - expect(noteSecret).toMatch(HEX32) - // the mint bound the quote to sha256 of the wallet's secret - the kit - // sends it as both comment and h, the mock read one of them - expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret)) + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + }) + expect(prepared.noteSecret).toMatch(HEX32) + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) }) - it('claims a named output at the wallet secret, without a rotate', async () => { - const instance = await mint({commentAllowed: 64, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - const noteSecret = requiredValue(prepared.noteSecret) + it('claims the output at the wallet secret without rotating it', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + }) const preimage = await settleLastInvoice(instance) const claimed = await claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000, }) - expect(claimed.rotated).toBe(true) expect(claimed.note.verified).toBe(true) expect(claimed.note.amount).toBe(21_000) expect(claimed.note.callback).toBe(`${instance.url}/w/cb`) - expect(noteK1(claimed.note.url)).toBe(noteSecret) - // no rotate happened: the note still stands at the wallet's own - // secret, and the payment preimage keys nothing at all - expect(instance.state.noteState(noteSecret)).toBe('outstanding') + expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) + expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding') expect(instance.state.noteState(preimage)).toBeNull() }) - it('names the output through the mintToHash spelling too', async () => { + it('times out cleanly when the invoice is never paid', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + }) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), + ).rejects.toThrow(/not confirmed/i) + }) + + it('uses the required comment even when the optional h extension is advertised', async () => { const instance = await mint({mintToHash: true, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - expect(prepared.mode).toBe('named') - expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret))) - const preimage = await settleLastInvoice(instance) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + }) + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) + await settleLastInvoice(instance) const claimed = await claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000, }) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) - expect(instance.state.noteState(preimage)).toBeNull() - }) - - it('keeps an unnamed mint on the preimage path', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - expect(prepared.mode).toBe('unnamed') - expect(prepared.noteSecret).toBeUndefined() - // the quote was NOT bound - nothing named an output - expect(boundInvoice(instance)).toBeUndefined() - }) - - it('times out cleanly when a named mint invoice is never paid', async () => { - const instance = await mint({commentAllowed: 64, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - await expect( - claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), - ).rejects.toThrow(/not confirmed/i) - }) - - it('rescues through the preimage when the mint credited it anyway and serves verify', async () => { - // the mintToHashIgnoresH adversary: claims the capability, binds - // nothing, credits the payment hash - the preimage is still the money - const instance = await mint({ - commentAllowed: 64, - mintToHashIgnoresH: true, - verifyOnUnnamedMint: true, - testHooks: true, - }) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - const preimage = await settleLastInvoice(instance) - expect(instance.state.noteState(preimage)).toBe('outstanding') - const claimed = await claimMintedNote(prepared, { - intervalMs: 10, - intervalCapMs: 20, - maxWaitMs: 300, - }) - expect(claimed.rotated).toBe(true) - expect(claimed.note.verified).toBe(true) - // claimed through the preimage path and rotated off it, because that - // preimage rode the invoice - expect(instance.state.noteState(preimage)).toBe('burned') - const k1 = requiredValue(noteK1(claimed.note.url)) - expect(k1).not.toBe(preimage) - expect(instance.state.noteState(k1)).toBe('outstanding') - }) - - it('fails loudly when the mint credits the preimage and serves no verify', async () => { - const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - // verify is withheld on the unnamed fallback - there the preimage IS - // the note, so a compliant mint never serves it - expect(prepared.verifyUrl).toBeNull() - const preimage = await settleLastInvoice(instance) - await expect( - claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}), - ).rejects.toThrow(/not confirmed/i) - // the money exists at the preimage but is unreachable without verify - - // the wallet must NOT report a success here - expect(instance.state.noteState(preimage)).toBe('outstanding') + expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32) }) }) diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts index 3989d7c..6c0635d 100644 --- a/src/lnurlcash/ops.mint-receive.cases.ts +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -10,33 +10,21 @@ import { import {claimMintedNote, prepareMint, receiveBearer} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness' - -describe('mint -> claim -> rotate', () => { - it('mints a note from a paid invoice and rotates it immediately', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - expect(prepared.invoice).toMatch(/^lnbc/) - expect(prepared.verifyUrl).toBeTruthy() - expect(prepared.expectedNoteValueMsat).toBe(21_000) - const preimage = await settleLastInvoice(instance) - const claimed = await claimMintedNote(prepared, { - intervalMs: 10, - intervalCapMs: 50, - maxWaitMs: 5_000, - }) - expect(claimed.rotated).toBe(true) - expect(claimed.note.amount).toBe(21_000) - expect(claimed.note.verified).toBe(true) - expect(instance.state.noteState(preimage)).toBe('burned') - const k1 = requiredValue(noteK1(claimed.note.url)) - expect(k1).not.toBe(preimage) - expect(instance.state.noteState(k1)).toBe('outstanding') - }) +import { + makeBearer, + mint, + noteUrl, + persistOutput, + secret, + settleLastInvoice, +} from './ops.testHarness' +describe('mint fee', () => { it('grosses the invoice up for an advertised mint fee', async () => { const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, { + persistOutput, + }) expect(prepared.grossMsat).toBeGreaterThan(100_000) await settleLastInvoice(instance) const claimed = await claimMintedNote(prepared, { @@ -48,31 +36,6 @@ describe('mint -> claim -> rotate', () => { // from the gross, so the note nets between the asked value and the gross expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000) expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat) - expect(claimed.rotated).toBe(true) - }) - - it('refuses a verify response naming a different invoice', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - await settleLastInvoice(instance) - // a verify answer that names any invoice but the requested one must - // stop the claim - the preimage it carries belongs to another payment - const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}` - await expect( - claimMintedNote({...prepared, invoice: other}, { - intervalMs: 10, - intervalCapMs: 20, - maxWaitMs: 5_000, - }), - ).rejects.toThrow(/different invoice/) - }) - - it('times out cleanly when the invoice is never paid', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - await expect( - claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), - ).rejects.toThrow(/not confirmed/i) }) }) diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts index 08b1e7c..bb770e6 100644 --- a/src/lnurlcash/ops.pay.cases.ts +++ b/src/lnurlcash/ops.pay.cases.ts @@ -18,10 +18,28 @@ describe('payWithBearers', () => { it('pays a Lightning Address by requesting an invoice first', async () => { const payer = await mint() - const payee = await mint() const bearer = await makeBearer(payer, secret('31'), 21_000) - const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, { + const payeeFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) { + return Promise.resolve( + Response.json({ + tag: 'payRequest', + callback: 'https://payee.example/invoice', + minSendable: 1_000, + maxSendable: 100_000_000, + metadata: '[]', + }), + ) + } + if (url.startsWith('https://payee.example/invoice')) { + return Promise.resolve(Response.json({pr: 'lnbc210n1pjqrstuvwxyz'})) + } + return fetch(input, init) + } + const result = await payWithBearers([bearer], 'alice@payee.example', { amountMsat: 21_000, + kit: {fetch: payeeFetch}, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, }) expect(result.outcome).toBe('settled') @@ -34,6 +52,7 @@ describe('payWithBearers', () => { const bearer = await makeBearer(instance, secret('32'), 50_000) const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + onCarve: () => undefined, }) expect(result.outcome).toBe('settled') expect(instance.state.noteState(secret('32'))).toBe('burned') @@ -51,14 +70,35 @@ describe('payWithBearers', () => { }) expect(result.outcome).toBe('failed-funds-returned') expect(instance.state.noteState(secret('33'))).toBe('burned') - // 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(rotatedNote.amount).toBe(21_000) + expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding') + }) + + it('waits for durable recovery-secret staging before rotating returned funds', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('39'), 21_000) + let releaseStage: (() => void) | undefined + const stageGate = new Promise((resolve) => { + releaseStage = resolve + }) + let stageStarted: (() => void) | undefined + const stageEntered = new Promise((resolve) => { + stageStarted = resolve + }) + const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + onReturnReady: async () => { + stageStarted?.() + await stageGate + }, + }) + + await stageEntered + expect(instance.state.noteState(secret('39'))).toBe('outstanding') + releaseStage?.() + expect((await payment).outcome).toBe('failed-funds-returned') + expect(instance.state.noteState(secret('39'))).toBe('burned') }) it('classifies a never-settling melt as unknown-still-pending', async () => { @@ -90,9 +130,8 @@ describe('payWithBearers', () => { }, }), ).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') + // The checkpoint failed before either split or melt reached the mint. + expect(instance.state.noteState(secret('36'))).toBe('outstanding') expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) }) }) diff --git a/src/lnurlcash/ops.retry-refusal.cases.ts b/src/lnurlcash/ops.retry-refusal.cases.ts index 9cf73c4..4d3263e 100644 --- a/src/lnurlcash/ops.retry-refusal.cases.ts +++ b/src/lnurlcash/ops.retry-refusal.cases.ts @@ -1,17 +1,10 @@ import {describe, expect, it} from 'vitest' import {noteK1} from 'lnurlcash-kit' +import type {CarveResult} from './ops' import {ensureExactAmount, receiveBearer} from './ops' -import {claimFromPreimage, prepareMint} from './ops/mint' import {requiredValue} from './test-utils' -import { - makeBearer, - mint, - noteUrl, - retryingCbFetch, - secret, - settleLastInvoice, -} from './ops.testHarness' +import {makeBearer, mint, noteUrl, retryingCbFetch, secret} from './ops.testHarness' // The redeem callback is a GET, and HTTP stacks retry GETs - so a mint // can execute the first attempt of a mutation and refuse the byte-identical @@ -24,7 +17,10 @@ describe('mutation retry refusals', () => { const instance = await mint() const k1 = secret('60') const bearer = await makeBearer(instance, k1, 50_000) - const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}) + const result = await ensureExactAmount([bearer], 25_000, { + fetch: retryingCbFetch(), + onCarve: () => undefined, + }) // the split landed on the first (unseen) attempt: the input is burned // and both outputs are live under the secrets the wallet carried expect(instance.state.noteState(k1)).toBe('burned') @@ -41,7 +37,10 @@ describe('mutation retry refusals', () => { const instance = await mint() const a = await makeBearer(instance, secret('61'), 20_000) const b = await makeBearer(instance, secret('62'), 5_000) - const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()}) + const result = await ensureExactAmount([a, b], 25_000, { + fetch: retryingCbFetch(), + onCarve: () => undefined, + }) expect(result.consumed).toHaveLength(2) expect(instance.state.noteState(secret('61'))).toBe('burned') expect(instance.state.noteState(secret('62'))).toBe('burned') @@ -50,19 +49,6 @@ describe('mutation retry refusals', () => { expect(instance.state.noteState(mergedK1)).toBe('outstanding') }) - it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - const preimage = await settleLastInvoice(instance) - const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()}) - expect(claimed.rotated).toBe(true) - expect(claimed.note.verified).toBe(true) - expect(instance.state.noteState(preimage)).toBe('burned') - const k1 = requiredValue(noteK1(claimed.note.url)) - expect(k1).not.toBe(preimage) - expect(instance.state.noteState(k1)).toBe('outstanding') - }) - it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => { const instance = await mint() const senderK1 = secret('63') @@ -83,8 +69,16 @@ describe('mutation retry refusals', () => { const k1 = secret('64') const bearer = await makeBearer(instance, k1, 50_000) instance.state.settleMelt(k1) // burned elsewhere, before we ever asked + const checkpoints: CarveResult[] = [] await expect( - ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}), + ensureExactAmount([bearer], 25_000, { + fetch: retryingCbFetch(), + onCarve: (carve) => { + checkpoints.push(carve) + }, + }), ).rejects.toThrow(/spent/i) + expect(checkpoints).toHaveLength(1) + expect(checkpoints[0]?.consumed).toEqual([]) }) }) diff --git a/src/lnurlcash/ops.signature.cases.ts b/src/lnurlcash/ops.signature.cases.ts new file mode 100644 index 0000000..4079533 --- /dev/null +++ b/src/lnurlcash/ops.signature.cases.ts @@ -0,0 +1,145 @@ +import {describe, expect, it} from 'vitest' +import {noteK1, noteSignature} from 'lnurlcash-kit' + +import {ensureExactAmount} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness' +import {fetchNoteInfo} from 'lnurlcash-kit' + +// Landed split/merge outputs carry the mint's signature in their URL (sig +// param), and earn verified:true only when that signature checks against a +// trusted CURRENT or PREVIOUS key for the mint. Invalid, missing, or +// wrong-key signatures - and mints with no trusted keys at all - keep the +// staged outputs unverified; the money is tracked either way (the notes +// exist at the mint), only the offline-verifiable badge is withheld. +describe('landed mutation signature verification', () => { + it('verifies a landed split against the current key and preserves its signature', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('a0'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + expect(result.note.verified).toBe(true) + const signature = noteSignature(result.note.url) + expect(signature).not.toBeNull() + expect(noteSignature(requiredValue(result.change).url)).not.toBeNull() + // change stays unverified (its true post-fee value is unknown until a + // refresh) even though its signature is preserved + expect(result.change?.verified).toBe(false) + }) + + it('verifies a landed merge against the current key and preserves its signature', async () => { + const instance = await mint() + const first = await makeBearer(instance, secret('a1'), 3_000) + const second = await makeBearer(instance, secret('a2'), 4_000) + + const result = await ensureExactAmount([first, second], 7_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + expect(result.note.verified).toBe(true) + expect(noteSignature(result.note.url)).not.toBeNull() + }) + + it('verifies against the previous key after the mint rotated its signing key', async () => { + // the mint signs every output under its RETIRED key while advertising + // the current one - exactly the rekey window a holder must tolerate + const instance = await mint({ + previousPrivateKey: '22'.repeat(32), + signWithPreviousKey: true, + }) + const bearer = await makeBearer(instance, secret('a3'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + expect(instance.state.previousPubkeys).toHaveLength(1) + expect(result.note.verified).toBe(true) + expect(noteSignature(result.note.url)).not.toBeNull() + }) + + it('rejects verification when the previous key is not trusted', async () => { + const instance = await mint({ + previousPrivateKey: '22'.repeat(32), + signWithPreviousKey: true, + }) + const bearer = await makeBearer(instance, secret('a4'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + // only the current key is trusted - the signature under the retired + // key must not verify + mintSignatureKeys: () => [instance.state.pubkey], + }) + + expect(result.note.verified).toBe(false) + // the note and its signature are still tracked, recoverable by refresh + expect(noteSignature(result.note.url)).not.toBeNull() + const partK1 = requiredValue(noteK1(result.note.url)) + expect(instance.state.noteState(partK1)).toBe('outstanding') + expect(instance.state.noteState(secret('a4'))).toBe('burned') + }) + + it('keeps a wrong-key signature staged unverified', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('a5'), 21_000) + const stranger = await mint({privateKey: '33'.repeat(32)}) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + // the only trusted key on file belongs to a DIFFERENT mint + mintSignatureKeys: () => [stranger.state.pubkey], + }) + + expect(result.note.verified).toBe(false) + expect(noteSignature(result.note.url)).not.toBeNull() + expect(instance.state.noteState(secret('a5'))).toBe('burned') + expect( + instance.state.noteState(requiredValue(noteK1(result.note.url))), + ).toBe('outstanding') + }) + + it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => { + const instance = await mint({signatures: false}) + const k1 = secret('a6') + const bearer = await makeBearer(instance, k1, 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + // the mutation landed (the kit refused the missing signature, the + // rescue probe proved the burn): the inputs are retired, the staged + // outputs are tracked at exactly their staged secrets, unverified + expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + expect(result.note.verified).toBe(false) + expect(noteSignature(result.note.url)).toBeNull() + expect(instance.state.noteState(k1)).toBe('burned') + const partK1 = requiredValue(noteK1(result.note.url)) + expect(instance.state.noteState(partK1)).toBe('outstanding') + expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) + }) + + it('never verifies a landed mutation without any trusted keys on file', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('a7'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + // no key source at all: nothing may be trusted blindly + }) + + expect(result.note.verified).toBe(false) + expect(noteSignature(result.note.url)).not.toBeNull() + expect( + instance.state.noteState(requiredValue(noteK1(result.note.url))), + ).toBe('outstanding') + }) +}) diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts index 97ce1bf..d9ca000 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -1,4 +1,6 @@ import './ops.carve.cases' +import './ops.carve-crash.cases' +import './ops.signature.cases' import './ops.mint-named.cases' import './ops.mint-receive.cases' import './ops.pay.cases' diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index ee6a1b1..2b4c281 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -24,6 +24,15 @@ afterEach(async () => { export const secret = (seed: string): string => bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed))) +export const persistOutput = async (): Promise => undefined + +// the trusted signing keys a production caller would serve from the +// trusted-mint registry: the mock mint's current key plus any it retired +export const signatureKeysFor = + (instance: Mint) => + (): readonly string[] => + [instance.state.pubkey, ...instance.state.previousPubkeys] + export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string => buildNoteUrl(`${instance.url}/w`, k1, amountMsat) diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 7ef3767..7f70578 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -2,15 +2,30 @@ import {describe, expect, it} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {hashK1, noteK1} from 'lnurlcash-kit' -import {transferBetweenMints} from './ops' -import type {CarveResult} from './ops' +import {transferBetweenMints as transferBetweenMintsEngine} from './ops' +import type {CarveResult, TransferOptions} from './ops' +import type {Bearer} from './types' import {requiredValue} from './test-utils' -import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' +import { + expectBurned, + makeBearer, + mint, + persistOutput, + secret, + settleWhenRequested, +} from './ops.testHarness' + +const transferBetweenMints = ( + bearers: Bearer[], + amountMsat: number, + targetMint: string, + options: Omit = {}, +) => transferBetweenMintsEngine(bearers, amountMsat, targetMint, {...options, persistOutput}) describe('transferBetweenMints', () => { const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} - it('moves value to another mint: melt at source, claim + rotate at target', async () => { + it('moves value to another mint at a wallet-chosen target secret', async () => { const source = await mint() const target = await mint({testHooks: true}) const k1 = secret('40') @@ -31,10 +46,9 @@ describe('transferBetweenMints', () => { expect(result.sourceServer).not.toBe(result.targetServer) await expectBurned(source, k1) const claimed = requiredValue(result.mintedAtTarget) - expect(claimed.rotated).toBe(true) expect(claimed.note.amount).toBe(21_000) expect(claimed.note.verified).toBe(true) - expect(target.state.noteState(preimage)).toBe('burned') + expect(target.state.noteState(preimage)).toBeNull() const newK1 = requiredValue(noteK1(claimed.note.url)) expect(newK1).not.toBe(preimage) expect(target.state.noteState(newK1)).toBe('outstanding') @@ -45,9 +59,15 @@ describe('transferBetweenMints', () => { const target = await mint() const k1 = secret('41') const bearer = await makeBearer(source, k1, 5_000) + let persisted = false await expect( - transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`), + transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, { + persistOutput: () => { + persisted = true + }, + }), ).rejects.toThrow(/enough/) + expect(persisted).toBe(false) expect(source.state.noteState(k1)).toBe('outstanding') }) @@ -72,6 +92,22 @@ describe('transferBetweenMints', () => { expect(source.state.noteState(k1)).toBe('outstanding') }) + it('reports a definitively spent source before waiting on the target', async () => { + const source = await mint() + const target = await mint() + const k1 = secret('54') + const bearer = await makeBearer(source, k1, 21_000) + source.state.settleMelt(k1) + + const result = await transferBetweenMints( + [bearer], + 21_000, + `mint@127.0.0.1:${target.port}`, + ) + + expect(result.outcome).toBe('note-already-spent') + }) + it('recovers from a melt whose answer was lost once the target invoice settles', async () => { const source = await mint({unconfirmedMutation: true}) const target = await mint({testHooks: true}) @@ -85,26 +121,6 @@ describe('transferBetweenMints', () => { expect(result.outcome).toBe('settled') await expectBurned(source, k1) expect(result.mintedAtTarget?.note.amount).toBe(21_000) - expect(result.mintedAtTarget?.rotated).toBe(true) - }) - - it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => { - const source = await mint() - const target = await mint({testHooks: true, echoWrongK1: true}) - const k1 = secret('45') - const bearer = await makeBearer(source, k1, 21_000) - const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { - poll: fastPoll, - }) - const preimage = await settleWhenRequested(target) - const result = await pending - expect(result.outcome).toBe('settled-claim-failed') - await expectBurned(source, k1) - const note = requiredValue(result.claimMaterial?.note) - expect(noteK1(note.url)).toBe(preimage) - expect(note.verified).toBe(false) - expect(note.amount).toBe(21_000) - expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`) }) it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => { @@ -140,9 +156,9 @@ describe('transferBetweenMints', () => { expect(rotatedNote.amount).toBe(21_000) }) - it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => { + it('lands the target note at the staged wallet secret without rotating', async () => { const source = await mint() - const target = await mint({commentAllowed: 64, testHooks: true}) + const target = await mint({testHooks: true}) const k1 = secret('48') const bearer = await makeBearer(source, k1, 21_000) const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { @@ -153,7 +169,6 @@ describe('transferBetweenMints', () => { expect(result.outcome).toBe('settled') await expectBurned(source, k1) const claimed = requiredValue(result.mintedAtTarget) - expect(claimed.rotated).toBe(true) expect(claimed.note.amount).toBe(21_000) expect(claimed.note.verified).toBe(true) const newK1 = requiredValue(noteK1(claimed.note.url)) @@ -166,7 +181,7 @@ describe('transferBetweenMints', () => { it('keeps the named claim material when the target never credits the note', async () => { const source = await mint({meltNeverSettles: true}) - const target = await mint({commentAllowed: 64, testHooks: true}) + const target = await mint({testHooks: true}) const k1 = secret('49') const bearer = await makeBearer(source, k1, 21_000) const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { @@ -190,36 +205,7 @@ describe('transferBetweenMints', () => { expect(noteK1(material.url)).toBe(noteSecret) }) - it('rescues a named transfer through the preimage when the target credited it anyway', async () => { - const source = await mint() - // the mintToHashIgnoresH adversary: advertises naming, credits the - // payment hash - and (non-compliantly) still serves verify - const target = await mint({ - commentAllowed: 64, - mintToHashIgnoresH: true, - verifyOnUnnamedMint: true, - testHooks: true, - }) - const k1 = secret('50') - const bearer = await makeBearer(source, k1, 21_000) - const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { - poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, - }) - const preimage = await settleWhenRequested(target) - const result = await pending - expect(result.outcome).toBe('settled') - await expectBurned(source, k1) - const claimed = requiredValue(result.mintedAtTarget) - expect(claimed.rotated).toBe(true) - // claimed through the preimage path and rotated off it, because that - // preimage rode the invoice - expect(target.state.noteState(preimage)).toBe('burned') - const newK1 = requiredValue(noteK1(claimed.note.url)) - 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 () => { + it('fires onCarve before the carve or melt reaches the mint', async () => { const source = await mint() const target = await mint({testHooks: true}) const bearer = await makeBearer(source, secret('51'), 21_000) @@ -228,16 +214,21 @@ describe('transferBetweenMints', () => { 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') + if (carve.consumed.length === 0) { + expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull() + expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding') + } else { + expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding') + expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned') + } }, }) 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]) + expect(seen).toHaveLength(2) + expect(seen[0]?.consumed).toEqual([]) + expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]) }) it('aborts before the melt when the carve commit hook fails', async () => { @@ -252,9 +243,25 @@ describe('transferBetweenMints', () => { }, }), ).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') + // The checkpoint failed before either split or melt reached the source. + expect(source.state.noteState(k1)).toBe('outstanding') expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) }) + + it('aborts before an exact-note melt when the final checkpoint fails', async () => { + const source = await mint() + const target = await mint() + const k1 = secret('53') + const bearer = await makeBearer(source, k1, 21_000) + + await expect( + transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + onMeltReady: () => { + throw new Error('source link commit failed') + }, + }), + ).rejects.toThrow(/source link commit failed/) + + expect(source.state.noteState(k1)).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index af69955..bc2efe9 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -5,12 +5,9 @@ // new/changed notes out - it never mutates wallet state itself. // // Fund-critical invariants enforced across the flows (see the project plan): -// - rotate on every receive, and immediately after claiming a fresh mint -// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice -// knows the payment hash, and the mint necessarily saw the preimage - -// the preimage IS the note secret). A NAMED mint (LUD-25 comment / -// mintToHash) credits the note to a wallet-chosen secret instead; that -// secret never rode an invoice, so no rotate follows its claim +// - rotate every externally received bearer. Minted notes are staged at a +// wallet-chosen secret before invoice creation and claimed there without +// rotating; the payment preimage is not bearer material // - a note's declared amount is a claim; the service's maxWithdrawable is // authoritative // - a melt's "OK" only means the payment is in flight; its verify URL (or @@ -29,15 +26,29 @@ // ops/shared.ts - bounded polling, UncertainOutcomeError export {UncertainOutcomeError} from './ops/shared' -export type {PollOptions} from './ops/shared' -export {ensureExactAmount} from './ops/carve' +export type {FundOperationOptions, PollOptions} from './ops/shared' +export { + CarveCheckpointRequiredError, + ensureExactAmount, + UnsupportedMultiBatchMergeError, +} from './ops/carve' export type {CarveResult} from './ops/carve' -export {prepareMint, claimMintedNote} from './ops/mint' -export type {PreparedMint, ClaimedNote} from './ops/mint' +export { + MintedNoteSpentError, + prepareMint, + claimMintedNote, + recoverStagedMintOutput, +} from './ops/mint' +export type { + PreparedMint, + PrepareMintOptions, + ClaimedNote, + StagedMintRecovery, +} from './ops/mint' export {receiveBearer} from './ops/receiveBearer' export {payWithBearers} from './ops/pay' export type {PayOutcome, PayResult, PayOptions} from './ops/pay' -export {transferBetweenMints} from './ops/transfer' +export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer' export type { TransferClaimMaterial, TransferOptions, diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index b8b65c2..3c107c9 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -2,31 +2,37 @@ // 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. +// Every output secret is chosen and reported through options.onCarve BEFORE +// its hash reaches the mint. That first checkpoint stages possible outputs +// without retiring inputs; a second checkpoint retires the inputs only after +// the mutation is known to have landed. import { AmbiguousMutationError, - mergeNotes, + defaultRandomSecret, + mergeBatches, newSecretsOf, noteK1, probeBurnedNote, requireNoteK1, serverOf, - settleNote, splitNote, withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import {mergeAmbiguitySafe} from './carveRecovery' import type {FundOperationOptions} from './shared' -import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared' +import { + assertFundOwner, + landedNoteVerifies, + probeMutationOutput, + UncertainOutcomeError, + withMutationSafety, +} 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 +// The same shape serves both checkpoint phases: the pre-wire phase carries +// note/change with an empty consumed list, and the landed phase carries the +// already-staged note plus the inputs the caller can now retire. export type CarveResult = { // the exact-amount note, ready to hand over or melt note: NewBearer @@ -37,6 +43,22 @@ export type CarveResult = { consumed: Bearer[] } +export class UnsupportedMultiBatchMergeError extends Error { + override readonly name = 'UnsupportedMultiBatchMergeError' + + constructor() { + super('This carve would require multiple merge requests and cannot be performed safely.') + } +} + +export class CarveCheckpointRequiredError extends Error { + override readonly name = 'CarveCheckpointRequiredError' + + constructor() { + super('A mutating carve requires a durable onCarve checkpoint.') + } +} + // 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 @@ -46,12 +68,12 @@ export type CarveResult = { // // 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) +// - several notes summing exactly: one merge; folds spanning multiple +// requests are rejected because partial progress needs a larger journal // - 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 +// split takes many k1s - no merge round trip first); change stays +// unverified until refresh because a second settle mutation would create +// another crash window export const ensureExactAmount = async ( bearers: Bearer[], amountMsat: number, @@ -60,8 +82,11 @@ export const ensureExactAmount = async ( if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } + // the forced mutation policy (signatures required, one byte-identical + // replay) applies to every mint call below regardless of caller options + const mutationOptions = withMutationSafety(options) const eligible = bearers.filter( - (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), + (b) => b.verified && !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 @@ -86,13 +111,17 @@ 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 + const checkpoint = async (result: CarveResult): Promise => { + if (!options.onCarve) { + throw new CarveCheckpointRequiredError() + } + assertFundOwner(options) + await options.onCarve(result) + assertFundOwner(options) } + const stage = (result: CarveResult): Promise => + checkpoint({...result, consumed: []}) + const retire = (note: NewBearer): Promise => checkpoint({note, consumed: pick}) if (pick.length === 1 && total === amountMsat) { // already exact - hand over the note itself, untouched @@ -109,94 +138,94 @@ export const ensureExactAmount = async ( } 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) - assertFundOwner(options) - const merged = await mergeAmbiguitySafe(base, k1s, total, options) - const unverified: NewBearer = { - url: withNewK1(base.url, merged.k1, total, merged.signature), + if (mergeBatches(base.callback, k1s).length !== 1) { + throw new UnsupportedMultiBatchMergeError() + } + const mergeSecret = (options.randomSecret ?? defaultRandomSecret)() + const staged: NewBearer = { + url: withNewK1(base.url, mergeSecret, total), 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 checkpoint({note: unverified, consumed: pick}) - try { - const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) - return checkpoint({ - 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 checkpoint({note: unverified, consumed: pick}) - } + await stage({note: staged, consumed: pick}) + assertFundOwner(options) + const merged = await mergeAmbiguitySafe(base, k1s, { + ...mutationOptions, + randomSecret: () => mergeSecret, + }) + // a landed merge reports its signature (carried in the URL's sig param) + // and earns verified only when that signature checks against a trusted + // current or previous key; a rescued merge saw no answer, so its note + // stays exactly as staged - unverified, same URL + const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature) + const result: CarveResult = merged.rescued + ? {note: staged, consumed: pick} + : { + note: { + ...staged, + url: landedUrl, + verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys), + }, + consumed: pick, + } + await retire(result.note) + return result } // 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 + const randomSecret = options.randomSecret ?? defaultRandomSecret + const partK1 = randomSecret() + const changeK1 = randomSecret() 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 + const stagedNote: NewBearer = { + url: withNewK1(base.url, partK1, amountMsat), + callback: base.callback, + amount: amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + } + const stagedChange: NewBearer = { + url: withNewK1(base.url, changeK1, total - amountMsat), + callback: base.callback, + amount: total - amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + } + await stage({note: stagedNote, change: stagedChange, consumed: pick}) + let secretIndex = 0 + const preparedSecret = (): string => { + secretIndex += 1 + if (secretIndex === 1) return partK1 + if (secretIndex === 2) return changeK1 + throw new Error('The split requested more output secrets than were staged.') + } assertFundOwner(options) try { - const parts = await splitNote(base.callback, k1s, amountMsat, options) - partK1 = parts.k1 + const parts = await splitNote(base.callback, k1s, amountMsat, { + ...mutationOptions, + randomSecret: preparedSecret, + }) partSignature = parts.signature - changeK1 = parts.change changeSignature = parts.changeSignature - partVerified = true } catch (err) { if (err instanceof AmbiguousMutationError) { // 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) + const outcome = await probeBurnedNote(base.url, mutationOptions) 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, - }, - ], + 'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.', + [], ) } // 'gone': the burn landed - the carried secrets are the only money - partK1 = err.newSecrets[0] - changeK1 = err.newSecrets[1] - rescued = true } else { // A classified refusal can still be a LANDED split: the callback is // a GET and HTTP stacks retry GETs, so the service may have executed @@ -205,79 +234,53 @@ export const ensureExactAmount = async ( // one output before deciding they are worthless. const carried = newSecretsOf(err) if (carried.length !== 2) throw err - const outcome = await probeMutationOutput(base.url, carried[0], options) + const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions) if (outcome === 'absent') throw err // never landed - a plain refusal if (outcome === 'unknown') { // can't tell whether the refusal named a retry - same limbo as // the ambiguous case above: track the possible outputs, consume // nothing, stop here throw new UncertainOutcomeError( - 'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.', - [ - { - url: withNewK1(base.url, carried[0], amountMsat), - callback: base.callback, - amount: amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - { - url: withNewK1(base.url, carried[1], total - amountMsat), - callback: base.callback, - amount: total - amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - ], + 'The split was refused, but the refusal may have named a retry that already landed - its possible outputs were already staged unverified alongside the originals.', + [], ) } // 'live': the split landed and this answer was its retried twin - // the carried secrets are the only money left - partK1 = carried[0] - changeK1 = carried[1] - rescued = true } } + // the landed answer's signatures ride in the URLs (sig param); a rescued + // split saw no answer, so its notes keep the staged sig-less URLs. Only a + // signature that verifies against a trusted current/previous key earns + // verified:true - anything else stays staged unverified for a refresh. + const noteUrl = + partSignature === undefined + ? stagedNote.url + : withNewK1(base.url, partK1, amountMsat, partSignature) const note: NewBearer = { - url: withNewK1(base.url, partK1, amountMsat, partSignature), + url: noteUrl, callback: base.callback, amount: amountMsat, - verified: partVerified, + verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys), 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), + // The change may be worth less than total - amount when the mint charges + // split fees. It remains unverified at that upper bound until refresh; + // settling it here would rotate to an unstaged secret in a second request. + const changeUrl = + changeSignature === undefined + ? stagedChange.url + : withNewK1(base.url, changeK1, total - amountMsat, changeSignature) + const change: NewBearer = { + url: changeUrl, 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 (error) { - // settle is best-effort - the unverified change above is still tracked - if (!(error instanceof Error)) throw error - } - } - return checkpoint({note, change, consumed: pick}) + const result = {note, change, consumed: pick} + await retire(note) + return result } // smallest-first accumulation until the target is covered; null when the @@ -301,60 +304,3 @@ const better = (a: Bearer[], b: Bearer[], target: number): boolean => { 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: FundOperationOptions, -): 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) { - 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} - } - // same retry-refusal rescue as the split path: the refusal may name a - // merge that already landed - probe the would-be combined note - const carried = newSecretsOf(err) - if (carried.length !== 1) throw err - const outcome = await probeMutationOutput(base.url, carried[0], options) - if (outcome === 'absent') throw err - if (outcome === 'unknown') { - throw new UncertainOutcomeError( - 'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.', - [ - { - url: withNewK1(base.url, carried[0], total), - callback: base.callback, - amount: total, - verified: false, - mintPubkey: base.mintPubkey, - }, - ], - ) - } - return {k1: carried[0], rescued: true} - } -} diff --git a/src/lnurlcash/ops/carveRecovery.ts b/src/lnurlcash/ops/carveRecovery.ts new file mode 100644 index 0000000..6985c1c --- /dev/null +++ b/src/lnurlcash/ops/carveRecovery.ts @@ -0,0 +1,50 @@ +// A merge failure can hide a landed mutation because callback GETs may be +// retried below the engine. This module owns the probes that distinguish a +// plain refusal from a live or still-uncertain wallet-chosen output. + +import {AmbiguousMutationError, mergeNotes, newSecretsOf, probeBurnedNote} from 'lnurlcash-kit' +import type {Bearer} from '../types' +import type {FundOperationOptions} from './shared' +import {probeMutationOutput, UncertainOutcomeError} from './shared' + +export type MergeOutcome = { + readonly rescued: boolean + // the mint's signature over the combined note - present only when the + // merge's own answer was seen; a rescued merge (lost answer, retried + // twin) has none to offer + readonly signature?: string +} + +export const mergeAmbiguitySafe = async ( + base: Bearer, + k1s: string[], + options: FundOperationOptions, +): Promise => { + try { + const merged = await mergeNotes(base.callback, k1s, options) + return {rescued: false, signature: merged.signature} + } catch (error) { + if (error instanceof AmbiguousMutationError) { + const outcome = await probeBurnedNote(base.url, options) + if (outcome === 'live') throw error + if (outcome === 'unknown') { + throw new UncertainOutcomeError( + 'The merge may have gone through but could not be confirmed - its possible combined note was already staged unverified alongside the originals.', + [], + ) + } + return {rescued: true} + } + const carried = newSecretsOf(error) + if (carried.length !== 1) throw error + const outcome = await probeMutationOutput(base.url, carried[0], options) + if (outcome === 'absent') throw error + if (outcome === 'unknown') { + throw new UncertainOutcomeError( + 'The merge was refused, but the refusal may have named a retry that already landed - its possible combined note was already staged unverified alongside the originals.', + [], + ) + } + return {rescued: true} + } +} diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index ccc6044..4869b0c 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -1,69 +1,39 @@ -// 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 it into a wallet-owned -// bearer note. -// -// Two mint generations exist on the wire, and this module speaks both: -// -// - UNNAMED (old): the minted note's k1 IS the payment preimage. The -// claim takes the preimage from the LUD-21 verify answer and rotates -// immediately - every routing node and anyone who saw the invoice knew -// that secret. -// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the -// wallet names the note at quote time with comment = sha256(secret) of -// a secret it chose, and the note lands at THAT secret. The preimage -// opens nothing, so no observer race exists and no rotate follows the -// claim; the claim poll itself is the placement proof (see -// claimFromSecret). -// -// The mode is decided per mint from the payRequest's capability -// advertisement (commentAllowed >= 64 or mintToHash), never assumed. +// Minting: receiving over Lightning. Current LUD-25 minting is always +// comment-bound: the wallet chooses the future note secret, durably stages +// an unverified bearer for it, and only then sends sha256(secret) with the +// invoice request. Paying the invoice brings that already-tracked note into +// existence; claiming confirms its authoritative value and callback. import { - AmbiguousMutationError, buildNoteUrl, claimMintedNote as fetchMintClaim, defaultRandomSecret, - fetchInvoiceVerification, fetchMintAddress, - fetchNoteInfo, fetchPayRequest, grossUpForMintFee, hashK1, - isPreimage, lightningAddressUsername, mintAddressUrl, - namesMintOutput, - newSecretsOf, - NoteSpentError, - probeBurnedNote, requestInvoice, + noteDeclaredAmount, + noteK1, resolveMintInput, - rotateNote, - sameInvoice, serverOf, - withNewK1, } from 'lnurlcash-kit' import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' -import type {NewBearer} from '../types' +import type {Bearer, NewBearer} from '../types' import {ceilMsatToSat} from '../units' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared' +import {assertFundOwner, pollUntil, withMutationSafety} from './shared' export type PreparedMint = { invoice: string verifyUrl: string | null - // how this mint keys the note the invoice pays for: 'named' mints credit - // a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints - // credit the payment preimage - mode: 'named' | 'unnamed' - // named mode only: the wallet-chosen secret the minted note lands at. - // Drawn before the invoice is requested (its hash binds the quote) and - // carried in memory only - never persisted, never logged. Losing it - // after payment loses the note, but nothing here was ever recoverable - // after a crash: the invoice and verify URL die in the same memory - noteSecret?: string + // The wallet-chosen secret the minted note lands at. Its unverified note + // is persisted before the invoice request can put this secret's hash on + // the wire. + noteSecret: string // the net note value asked for - the claim cross-checks the service's // authoritative maxWithdrawable against it expectedNoteValueMsat: number @@ -78,6 +48,14 @@ export type PreparedMint = { nodeInfo: MintAddressInfo | null } +export type PrepareMintOptions = FundOperationOptions & { + readonly persistOutput: (note: NewBearer) => void | Promise + readonly beforePersist?: (quote: { + readonly grossMsat: number + readonly server: string + }) => void | Promise +} + // 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 @@ -86,8 +64,11 @@ export type PreparedMint = { export const prepareMint = async ( mintInput: string, amountMsat: number, - options: FundOperationOptions = {}, + options: PrepareMintOptions, ): Promise => { + const {beforePersist, persistOutput, ...rest} = options + // the forced mutation policy (see shared.ts) covers the invoice request + const kitOptions = withMutationSafety(rest) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } @@ -101,14 +82,14 @@ export const prepareMint = async ( let payUrl = url if (addressUrl) { try { - nodeInfo = await fetchMintAddress(addressUrl, options) + nodeInfo = await fetchMintAddress(addressUrl, kitOptions) payUrl = nodeInfo.payLink } catch (error) { // no mint-address support here - proceed with just the guess if (!(error instanceof Error)) throw error } } - const info = await fetchPayRequest(payUrl, options) + const info = await fetchPayRequest(payUrl, kitOptions) if (!info.withdrawLink) { throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).') } @@ -118,52 +99,57 @@ export const prepareMint = async ( if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { throw new Error("Amount is outside this mint's sendable range.") } - // a mint that names its outputs takes comment = sha256(secret) with the - // quote; one that doesn't gets the byte-identical request this wallet - // always sent, and its note stays keyed by the payment preimage - const named = namesMintOutput(info) - let noteSecret: string | undefined - let h: string | undefined - if (named) { - noteSecret = defaultRandomSecret() - h = hashK1(noteSecret) + const server = serverOf(payUrl) + await beforePersist?.({grossMsat, server}) + const noteSecret = (kitOptions.randomSecret ?? defaultRandomSecret)() + const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey + const staged: NewBearer = { + url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), + callback: '', + // The URL retains the claimed amount for recovery, but the wallet's + // balance stays at zero until the mint confirms the authoritative value. + amount: 0, + verified: false, + pendingMint: mintPubkey ? {mintPubkey} : {}, } - const invoice = await requestInvoice( - info.callback, - grossMsat, - h ? {...options, h} : options, - ) + assertFundOwner(options) + await persistOutput(staged) + assertFundOwner(options) + const invoice = await requestInvoice(info.callback, grossMsat, { + ...kitOptions, + h: hashK1(noteSecret), + }) const prepared: PreparedMint = { invoice: invoice.pr, verifyUrl: invoice.verify ?? null, - mode: named ? 'named' : 'unnamed', + noteSecret, expectedNoteValueMsat: amountMsat, grossMsat, mintUrl: payUrl, withdrawLink: info.withdrawLink, - server: serverOf(payUrl), + server, username: lightningAddressUsername(payUrl), nodeInfo, } - if (noteSecret) prepared.noteSecret = noteSecret - if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey + if (mintPubkey) prepared.mintPubkey = mintPubkey return prepared } export type ClaimedNote = { note: NewBearer - // false when the note may be exposed to someone besides this wallet and - // the mint: an unnamed mint's fresh note whose rotate failed (its k1 was - // the payment preimage, transmitted and mint-known). A named mint's - // note always reports true - its secret never rode an invoice, so there - // is nothing to rotate away from 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 } +export class MintedNoteSpentError extends Error { + override readonly name = 'MintedNoteSpentError' + + constructor() { + super('The mint reports the freshly minted note as already spent.') + } +} + // Polls a named mint output's informational GET until the note is there: // 'minted' resolves the wait ('spent' resolves it too - surfaced for the // caller to reject, never retried); 'unminted' and 'pending' keep it @@ -185,67 +171,27 @@ export const pollMintClaim = async ( options, ) -// what a claim needs once the preimage is known - PreparedMint satisfies -// this, and so does the target side of an inter-mint transfer (see -// transfer.ts) export type ClaimTarget = { withdrawLink: string + noteSecret: string // the net note value asked for - a claim, cross-checked against the // service's authoritative maxWithdrawable expectedNoteValueMsat: number mintPubkey?: string } -// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's -// verify URL and invoice as rescue anchors for a mint that took the hash -// but credited the preimage anyway. PreparedMint satisfies this. -export type NamedClaimTarget = ClaimTarget & { - noteSecret: string - verifyUrl?: string | null - invoice?: string -} - -// The named-mode claim: the note was credited to the wallet's own secret, -// which rode to the mint as a HASH inside the invoice request - the -// payment preimage opens nothing, so the claim needs no preimage and no -// rotate follows it. The rescue covers the mint that claimed the -// capability but credited the preimage (the kit's mintToHashIgnoresH -// adversary: the wallet was told no rotate is needed while the preimage -// is still the money): when that mint still serves the quote's verify, one -// check recovers the preimage and the note is claimed through the old -// path, WITH the rotate that path exists for. When it serves none there -// is nothing to rescue with - the error stays loud. +// The note was credited to the wallet's own secret, which rode to the mint +// only as a hash inside the invoice request. The payment preimage opens +// nothing, so the claim needs no preimage and no rotate follows it. export const claimFromSecret = async ( - target: NamedClaimTarget, + target: ClaimTarget, poll: PollOptions = {}, options: FundOperationOptions = {}, ): Promise => { assertFundOwner(options) - let claim: MintClaim - try { - claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options) - } catch (err) { - if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) { - try { - const result = await fetchInvoiceVerification(target.verifyUrl, options) - if ( - result.settled && - sameInvoice(result.pr, target.invoice) && - result.preimage && - isPreimage(result.preimage) - ) { - return claimFromPreimage(target, result.preimage, options) - } - } catch { - // the rescue is best-effort - the original error stands - } - } - throw err - } + const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options) if (claim.state === 'spent') { - // a fresh wallet-chosen secret cannot already be spent - the service - // is lying about the note it just credited - throw new Error('The mint reports the freshly minted note as already spent.') + throw new MintedNoteSpentError() } if (claim.k1 !== target.noteSecret) { // belt and suspenders: the kit's claimMintedNote returns the queried @@ -267,141 +213,48 @@ export const claimFromSecret = async ( return {note, rotated: true} } -// Polls until the minted note is claimable, then claims it. Unnamed mints: -// watch the LUD-21 verify URL for settlement, take the preimage (which IS -// the note secret there), and claim through claimFromPreimage with its -// mandatory rotate. Named mints: watch the note itself appear at the -// wallet's own secret (see claimFromSecret). +// Polls until the minted note is claimable at the wallet's staged secret. export const claimMintedNote = async ( prepared: PreparedMint, poll: PollOptions = {}, options: FundOperationOptions = {}, -): Promise => { - if (prepared.mode === 'named') { - const noteSecret = prepared.noteSecret - if (!noteSecret) { - throw new Error('This mint was prepared in named mode without a note secret.') - } - return claimFromSecret({...prepared, noteSecret}, poll, options) - } - 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.') - } - return claimFromPreimage(prepared, preimage, options) -} +): Promise => claimFromSecret(prepared, poll, options) -// The claim itself, once the payment's preimage is known: the preimage IS -// the note secret. The claim (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 claimFromPreimage = async ( - claim: ClaimTarget, - preimage: string, +// One bounded observation for durable recovery. An unpaid invoice is normal: +// keep the staged output and try again at the next wallet/service activation. +export type StagedMintRecovery = + | {readonly state: 'unminted'} + | {readonly state: 'pending'} + | {readonly state: 'spent'} + | {readonly state: 'minted'; readonly note: NewBearer} + +export const recoverStagedMintOutput = async ( + staged: Bearer, options: FundOperationOptions = {}, -): Promise => { - // declare the invoiced amount (a claim - not yet confirmed) so the note - // is self-describing even before the verifying GET below - const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat) +): Promise => { + const noteSecret = noteK1(staged.url) + const expectedNoteValueMsat = noteDeclaredAmount(staged.url) + if (!staged.pendingMint || !noteSecret || expectedNoteValueMsat === null) { + throw new Error('The pending mint output is malformed.') + } + const withdrawLink = new URL(staged.url) + withdrawLink.searchParams.delete('k1') + withdrawLink.searchParams.delete('amount') + withdrawLink.searchParams.delete('sig') assertFundOwner(options) - // 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 ?? claim.mintPubkey - const base: NewBearer = { - url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable), - callback: noteInfo.callback, - amount: noteInfo.maxWithdrawable, + const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options)) + if (claim.state === 'unminted') return {state: 'unminted'} + if (claim.state === 'pending') return {state: 'pending'} + if (claim.state === 'spent') return {state: 'spent'} + if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) { + throw new Error('The mint did not return the pending output details.') + } + const note: NewBearer = { + url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat), + callback: claim.callback, + amount: claim.amountMsat, 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 { - // A classified refusal can still be a LANDED rotate: the callback is - // a GET and HTTP stacks retry GETs, so the service may have executed - // the first attempt and refused this one as an already-spent input. - // "Already spent" then PROVES the preimage note is burned - the - // carried fresh secret is the only money left, so probe it before - // believing the refusal. - const carried = newSecretsOf(err) - if (carried.length === 1) { - const outcome = await probeMutationOutput(declaredUrl, carried[0], options) - if (outcome === 'live') { - // the rotate landed and this answer was its retried twin - url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable) - rotated = true - } else if (outcome === 'unknown') { - possibleCopy = { - url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable), - callback: noteInfo.callback, - amount: noteInfo.maxWithdrawable, - verified: false, - } - if (mintPubkey) possibleCopy.mintPubkey = mintPubkey - // the preimage note itself may be burned - it cannot stay verified - base.verified = false - rotationError = `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.` - } else if (err instanceof NoteSpentError) { - // a genuine already-spent refusal with no live output: the note - // the service just described is burned - it cannot stay verified - base.verified = false - rotationError = err.message - } else { - rotationError = err instanceof Error ? err.message : String(err) - } - } 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 + if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey + return {state: 'minted', note} } diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index f5d397b..2b67feb 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -8,6 +8,7 @@ import { NoteSpentError, PendingNoteError, decodeBolt11AmountMsat, + defaultRandomSecret, fetchPayRequest, isBolt11Invoice, meltNote, @@ -24,7 +25,7 @@ import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {assertFundOwner, pollVerifyUntilSettled} from './shared' +import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared' export type PayOutcome = | 'settled' @@ -41,16 +42,7 @@ 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 rescuedNote?: NewBearer } @@ -67,6 +59,9 @@ export type PayOptions = { // so an abort mid-wait never strands the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve'] + // Called before the classification rotate puts its chosen output secret + // on the wire. Production callers durably link it to the carved source. + onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise } // A melt's resolved promise only means the payment is in flight; the @@ -82,13 +77,15 @@ export type PayOptions = { export const payWithBearers = async ( bearers: Bearer[], input: string, - {amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {}, + {amountMsat, poll = {}, kit = {}, assertOwner, onCarve, onReturnReady}: PayOptions = {}, ): Promise => { - const options: FundOperationOptions = { + // the forced mutation policy (see shared.ts) covers the carve, the melt, + // and the classification rotate regardless of caller kit options + const options: FundOperationOptions = withMutationSafety({ ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), - } + }) let invoice: string let amount: number const trimmed = input.trim() @@ -159,20 +156,21 @@ export const payWithBearers = async ( } 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 + // The verify budget ran out. Journal the exact output secret before the + // classification rotate reaches the mint, so a crash can recover either + // the returned source or the rotated replacement. + const recoverySecret = kit.randomSecret?.() ?? defaultRandomSecret() + await onReturnReady?.(carve, recoverySecret) 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. Reported separately - // from `carve` (see PayResult.rotatedNote): the carve may already be - // committed, and its note is what this rotate burned + const rotated = await rotateNote(carve.note.callback, k1, { + ...options, + randomSecret: () => recoverySecret, + }) return { outcome: 'failed-funds-returned', carve, rotatedNote: { - url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), + url: withNewK1(carve.note.url, recoverySecret, amount, rotated.signature), callback: carve.note.callback, amount, verified: true, @@ -184,20 +182,14 @@ export const payWithBearers = async ( } } 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), + url: withNewK1(carve.note.url, recoverySecret, amount), callback: carve.note.callback, amount, verified: false, diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index e3ccc80..298aa38 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -17,7 +17,7 @@ import type {Bearer, NewBearer} from '../types' import {receiveNote, secureReceivedNote} from '../receive' import type {ClaimedNote} from './mint' import type {FundOperationOptions} from './shared' -import {assertFundOwner, probeMutationOutput} from './shared' +import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared' // NoteSpentError / NoteUnknownError / PendingNoteError from the service are // definitive and propagate; an unreachable service still yields the note, @@ -27,13 +27,15 @@ export const receiveBearer = async ( existing: Bearer[], options: FundOperationOptions = {}, ): Promise => { + // the forced mutation policy (see shared.ts) covers the rotate below + const mutationOptions = withMutationSafety(options) const note = await receiveNote(input, existing) if (!note.verified || !note.callback) { return {note, rotated: false} } try { assertFundOwner(options) - const rotatedUrl = await secureReceivedNote(note, options) + const rotatedUrl = await secureReceivedNote(note, mutationOptions) return {note: {...note, url: rotatedUrl}, rotated: true} } catch (err) { // A classified rotate refusal can still be a LANDED rotate: the @@ -44,7 +46,7 @@ export const receiveBearer = async ( // it before believing the refusal. const carried = newSecretsOf(err) if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) { - const outcome = await probeMutationOutput(note.url, carried[0], options) + const outcome = await probeMutationOutput(note.url, carried[0], mutationOptions) if (outcome === 'live') { return { note: {...note, url: withNewK1(note.url, carried[0], note.amount)}, @@ -81,7 +83,7 @@ export const receiveBearer = async ( throw err } if (err instanceof AmbiguousMutationError) { - const outcome = await probeBurnedNote(note.url, options) + const outcome = await probeBurnedNote(note.url, mutationOptions) if (outcome === 'gone') { return { note: { diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index 68573d2..518d086 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -2,7 +2,15 @@ // every flow that waits on a payment uses, and the uncertainty type a lost // mutation answer surfaces as. -import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit' +import { + fetchInvoiceVerification, + fetchNoteInfo, + noteK1, + noteSignature, + serverOf, + verifyNoteSignature, + withNewK1, +} from 'lnurlcash-kit' import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit' import type {NewBearer} from '../types' @@ -10,8 +18,9 @@ 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 -// caller to track unverified alongside the (kept) inputs. Never dropped: -// if the mutation did land, these are the only money left. +// caller to track unverified alongside the kept inputs. A pre-wire carve +// checkpoint may already have persisted them, in which case this list is +// empty so callers do not add duplicate records. export class UncertainOutcomeError extends Error { readonly possibleOutputs: NewBearer[] constructor(message: string, possibleOutputs: NewBearer[]) { @@ -32,16 +41,54 @@ 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. + // Two durable phases for a mutating carve. Before wire use, unverified + // outputs are reported with `consumed: []`. After the mint mutation is + // known to have landed, the already-staged note is reported again with + // the inputs that can now be retired. A rejection in either phase stops + // the flow; exact-note carves call neither phase. readonly onCarve?: (carve: CarveResult) => void | Promise + // the trusted signing keys a landed mutation output's signature is + // checked against before it may report verified: the mint's pinned + // current key AND its previous one (a just-rotated mint's last notes + // stay verifiable). Production supplies the trusted-mint registry's + // keys; an absent source (or empty list) keeps every landed output + // staged unverified rather than trusting it blindly. + readonly mintSignatureKeys?: MintSignatureKeys +} + +// the trusted signing keys available for one mint server +export type MintSignatureKeys = (server: string) => readonly string[] + +// The mutation safety policy, forced where the engine talks to a mint: +// every mutation must come back signed (the signature is the only offline +// proof the mint issued the output) and a lost answer is retried exactly +// once, byte-identically (the replay the pre-wire staging exists to make +// safe). Both are lnurlcash-kit's own defaults - restating them AFTER the +// caller spread means no caller's options object can quietly strip either +// guarantee. +export const withMutationSafety = (options: Options): Options => ({ + ...options, + requireSignatures: true, + mutationRetries: 1, +}) + +// A landed mutation output earns verified:true only when its signature +// (carried as the URL's sig param) verifies against a trusted key for its +// server. Missing, malformed, or wrong-key signatures - or no trusted keys +// on file at all - leave the note staged unverified; a refresh can repair +// it later. The money is never at stake here (the note exists at the mint +// either way), only the offline-verifiable badge is. +export const landedNoteVerifies = ( + noteUrl: string, + keysFor: MintSignatureKeys | undefined, +): boolean => { + if (!keysFor) return false + const k1 = noteK1(noteUrl) + const amountMsat = noteDeclaredAmount(noteUrl) + const signature = noteSignature(noteUrl) + if (!k1 || amountMsat === null || !signature) return false + const keys = keysFor(serverOf(noteUrl)) + return keys.length > 0 && verifyNoteSignature(k1, amountMsat, signature, [...keys]) } export const assertFundOwner = (options: FundOperationOptions): void => { diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index c41a344..cc910b6 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -3,20 +3,9 @@ // that exist: this wallet requests an invoice FROM the target mint // (grossed up for its advertised mint fee, so the note that comes out // nets the requested amount), melts source notes to pay it, then claims -// the target note exactly like any other minted receive. The target -// invoice settling is the transfer's ground truth: it can only settle if -// the source melt's payment arrived. How settling is observed and claimed -// depends on the target's minting mode (see mint.ts): -// -// - UNNAMED target: the verify URL is the observation - its settled -// response reveals the preimage, which IS the note's secret, and the -// claim rotates off it immediately. -// - NAMED target: the note itself is the observation - the wallet named -// the output with its own secret at quote time, so the informational -// GET reporting 'minted' IS the settlement proof, and the note is -// already at a secret nobody else has seen. No verify URL is needed -// (but one is used to rescue the mint that took the hash and credited -// the preimage anyway). +// the target note exactly like any other minted receive. The target note +// appearing at the wallet's pre-staged secret is the transfer's ground +// truth: it can only appear if the source melt's payment arrived. // // When settlement never confirms, the source note itself is the oracle - // a successful rotate proves the melt never burned it (funds returned), @@ -25,16 +14,16 @@ import { AmbiguousMutationError, NoteSpentError, + NoteUnknownError, PendingNoteError, buildNoteUrl, - decodeBolt11AmountMsat, - fetchInvoiceVerification, - isPreimage, + defaultRandomSecret, + fetchNoteInfo, meltNote, + newSecretsOf, noteK1, requireNoteK1, rotateNote, - sameInvoice, serverOf, withNewK1, } from 'lnurlcash-kit' @@ -42,11 +31,11 @@ import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' -import type {ClaimedNote, PreparedMint} from './mint' -import {claimFromPreimage, pollMintClaim, prepareMint} from './mint' +import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint' +import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {assertFundOwner, pollVerifyUntilSettled} from './shared' +import {assertFundOwner, withMutationSafety} from './shared' export type TransferOutcome = // the melt settled and the target note was claimed (and rotated) @@ -85,13 +74,11 @@ export type TransferClaimMaterial = { invoice: string withdrawLink: string expectedNoteValueMsat: number - // the preimage note, unverified, once the preimage is known - the - // preimage IS the note secret; the caller must track it and retry - note?: NewBearer - // named target only: the wallet-chosen secret the target note lands at. + note: NewBearer + // the wallet-chosen secret the target note lands at. // The note exists at this secret from the moment the invoice settles - // the caller can rebuild the note URL from it and retry the claim - noteSecret?: string + noteSecret: string } export type TransferResult = { @@ -101,8 +88,8 @@ export type TransferResult = { quote: TransferQuote // the invoice the source note was melted to pay invoice: string - // the target invoice's verify URL - an unnamed target's ground truth - // (a named target may not serve one at all; the note itself is observed) + // the target invoice's optional verify URL (used for a payment receipt; + // the staged note itself is the settlement observation) verifyUrl: string | null sourceServer: string targetServer: string @@ -132,6 +119,10 @@ export type TransferOptions = { // settlement wait, so an abort mid-wait never strands the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve'] + // persists the target note before its hash reaches the invoice callback + persistOutput: PrepareMintOptions['persistOutput'] + // Last durable checkpoint before the source melt can become irreversible. + onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise } // the claim retry material for a named target: the note at the wallet's @@ -147,41 +138,113 @@ const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer = return note } +const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => { + const eligible = bearers.filter( + (bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url), + ) + const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer) + if (eligible.length > 0 && candidates.length === 0) { + throw new Error("That's the mint these notes are already on - pick a different target.") + } + const totals = new Map() + for (const bearer of candidates) { + const server = serverOf(bearer.url) + totals.set(server, (totals.get(server) ?? 0) + bearer.amount) + } + if (![...totals.values()].some((total) => total >= grossMsat)) { + throw new Error('No mint holds enough verified, unspent balance to cover that amount.') + } + return candidates +} + +export type PendingTransferSourceRecovery = + | {readonly state: 'pending'} + | {readonly state: 'spent'} + | {readonly state: 'returned'; readonly note: NewBearer} + +export const recoverPendingTransferSource = async ( + staged: Bearer, + source: Bearer, + options: FundOperationOptions = {}, +): Promise => { + // a recovery only ever PROBES the melt outcome and rotates - it never + // replays the melt itself; the forced policy pins signature/retry safety + const mutationOptions = withMutationSafety(options) + const recoverySecret = staged.pendingMint?.sourceRecoverySecret + if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.') + const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount) + assertFundOwner(options) + try { + const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions) + const note: NewBearer = { + url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable), + callback: recovered.callback, + amount: recovered.maxWithdrawable, + verified: true, + } + if (source.mintPubkey) note.mintPubkey = source.mintPubkey + return {state: 'returned', note} + } catch (error) { + if (error instanceof NoteSpentError) return {state: 'spent'} + if (!(error instanceof NoteUnknownError)) throw error + } + assertFundOwner(options) + try { + const rotated = await rotateNote(source.callback, requireNoteK1(source.url), { + ...mutationOptions, + randomSecret: () => recoverySecret, + }) + const note: NewBearer = { + url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature), + callback: source.callback, + amount: source.amount, + verified: true, + } + if (source.mintPubkey) note.mintPubkey = source.mintPubkey + return {state: 'returned', note} + } catch (error) { + if ( + error instanceof PendingNoteError || + error instanceof NoteSpentError || + error instanceof AmbiguousMutationError || + newSecretsOf(error).includes(recoverySecret) + ) { + return {state: 'pending'} + } + throw error + } +} + export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, - {poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {}, + {poll = {}, kit = {}, assertOwner, onCarve, persistOutput, onMeltReady}: TransferOptions, ): Promise => { - const options: FundOperationOptions = { + const options: FundOperationOptions = withMutationSafety({ ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), - } + }) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } // resolving the target and requesting its invoice touches only the // TARGET mint - a failure here (unreachable, no minting support, amount // out of range) leaves every source note untouched - const prepared = await prepareMint(targetMint, amountMsat, options) - if (prepared.mode === 'unnamed' && !prepared.verifyUrl) { - throw new Error( - 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', - ) - } + const prepared = await prepareMint(targetMint, amountMsat, { + ...options, + persistOutput, + beforePersist: ({grossMsat, server}) => { + transferSources(bearers, server, grossMsat) + }, + }) const verifyUrl = prepared.verifyUrl const targetServer = prepared.server // the source must be a DIFFERENT mint - value "moved" within one mint // goes nowhere (melt pays an invoice; the same mint's invoice just // re-mints into itself, paying fees for nothing) - const eligible = bearers.filter( - (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), - ) - const offTarget = eligible.filter((b) => serverOf(b.url) !== targetServer) - if (eligible.length > 0 && offTarget.length === 0) { - throw new Error("That's the mint these notes are already on - pick a different target.") - } + const offTarget = transferSources(bearers, targetServer, prepared.grossMsat) const quote: TransferQuote = { requestedMsat: amountMsat, grossMsat: prepared.grossMsat, @@ -197,25 +260,22 @@ export const transferBetweenMints = async ( invoice, withdrawLink: prepared.withdrawLink, expectedNoteValueMsat: prepared.expectedNoteValueMsat, - } - if (prepared.noteSecret) { - claimMaterial.noteSecret = prepared.noteSecret - // a named target's note is claimable from the wallet's secret alone, - // the moment the invoice settles - track it unverified from the start, - // so a melt that settles after the budget is never a lost note - claimMaterial.note = namedClaimNote(prepared, prepared.noteSecret) + noteSecret: prepared.noteSecret, + note: namedClaimNote(prepared, prepared.noteSecret), } // from here on the carve's fresh secrets exist only in this result - the // flow never throws again; every outcome carries them const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const k1 = requireNoteK1(carve.note.url) + const sourceRecoverySecret = (kit.randomSecret ?? defaultRandomSecret)() if (carve.consumed.length === 0) assertFundOwner(options) + await onMeltReady?.(carve, sourceRecoverySecret) + assertFundOwner(options) try { await meltNote(carve.note.callback, k1, invoice, options) - } catch (err) { - if (err instanceof NoteSpentError) { - // this melt names a single note, so this is unambiguous - it was - // already gone before the melt even started + } catch (error) { + if (!(error instanceof Error)) throw error + if (error instanceof NoteSpentError) { return {...base, outcome: 'note-already-spent'} } // anything else - a clean refusal, a dropped response, a lost answer - @@ -223,119 +283,16 @@ export const transferBetweenMints = async ( // payment arrived, and the source probe tells the rest } try { - if (prepared.mode === 'named') { - const noteSecret = prepared.noteSecret - if (!noteSecret) throw new Error('The target mint was prepared without a note secret.') - // the note appearing at the wallet's own secret IS the settlement - // proof - the target only credits it once the melt's payment landed - try { - const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options) - if ( - claim.state !== 'minted' || - claim.k1 !== noteSecret || - claim.amountMsat === null || - !claim.callback - ) { - // 'spent' on a fresh wallet-chosen secret, a claim naming a - // secret other than the one polled (impossible through the kit, - // which returns the queried secret and rejects mismatched - // echoes - asserted anyway: building from it would track the - // wrong note), or an incomplete answer: the target misbehaved - - // the way back to the note is already in the claim material - return {...base, outcome: 'settled-claim-failed', claimMaterial} - } - const note: NewBearer = { - url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat), - callback: claim.callback, - amount: claim.amountMsat, - verified: true, - } - if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey - // no rotate: the secret never rode an invoice (see mint.ts) - return {...base, outcome: 'settled', mintedAtTarget: {note, rotated: true}} - } catch (err) { - // the note never appeared at the wallet's secret within budget. - // Rescue through the quote's verify when the target serves one: - // a mint that took the hash but credited the PREIMAGE (the - // mintToHashIgnoresH adversary) is claimable through the old path - if (verifyUrl) { - try { - const proof = await fetchInvoiceVerification(verifyUrl, options) - if (proof.settled && sameInvoice(proof.pr, invoice) && proof.preimage) { - if (isPreimage(proof.preimage)) { - try { - const claimed = await claimFromPreimage(prepared, proof.preimage, options) - return {...base, outcome: 'settled', mintedAtTarget: claimed} - } catch { - const note: NewBearer = { - url: buildNoteUrl( - prepared.withdrawLink, - proof.preimage, - prepared.expectedNoteValueMsat, - ), - callback: '', - amount: prepared.expectedNoteValueMsat, - verified: false, - } - if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey - return { - ...base, - outcome: 'settled-claim-failed', - claimMaterial: {...claimMaterial, note}, - } - } - } - } - } catch { - // best-effort rescue - fall through to the source-note oracle - } - } - throw err - } - } - if (!verifyUrl) { - throw new Error( - 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', - ) - } - const proof = await pollVerifyUntilSettled(verifyUrl, poll, options) - // the proof-binding rule from pay.ts, extended for the gross-up: the - // verify URL is scoped to this invoice's payment hash, so an exact pr - // match binds it; short of that, a proof amount that is neither the - // invoiced gross nor the expected net belongs to another payment, - // while an undecodable one says nothing either way and is tolerated - const proofAmount = decodeBolt11AmountMsat(proof.pr) - if ( - !sameInvoice(proof.pr, invoice) && - proofAmount !== null && - proofAmount !== prepared.grossMsat && - proofAmount !== prepared.expectedNoteValueMsat - ) { - return {...base, outcome: 'unknown-still-pending', claimMaterial} - } - if (!proof.preimage || !isPreimage(proof.preimage)) { - // settled, but the service won't reveal the preimage - the claim - // cannot complete automatically - return {...base, outcome: 'settled-claim-failed', claimMaterial} - } try { - const claimed = await claimFromPreimage(prepared, proof.preimage, options) + const claimed = await claimFromSecret(prepared, poll, options) return {...base, outcome: 'settled', mintedAtTarget: claimed} - } catch { - // the melt settled - the money is now the preimage note at the - // target and nowhere else; surface it rather than lose it - const note: NewBearer = { - url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat), - callback: '', - amount: prepared.expectedNoteValueMsat, - verified: false, - } - if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey - return { - ...base, - outcome: 'settled-claim-failed', - claimMaterial: {...claimMaterial, note}, + } catch (error) { + if (error instanceof MintedNoteSpentError) { + return {...base, outcome: 'settled-claim-failed', claimMaterial} } + throw new Error('The target note could not be confirmed at its staged secret.', { + cause: error, + }) } } catch { // the target invoice never settled within budget - the source note is @@ -345,7 +302,10 @@ export const transferBetweenMints = async ( // left but never arrived within the budget - the claim material stays // as the way back to the money if the invoice settles later try { - const rotated = await rotateNote(carve.note.callback, k1, options) + const rotated = await rotateNote(carve.note.callback, k1, { + ...options, + randomSecret: () => sourceRecoverySecret, + }) // 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 @@ -367,10 +327,8 @@ export const transferBetweenMints = async ( return {...base, outcome: 'unknown-still-pending', claimMaterial} } if (err instanceof AmbiguousMutationError) { - // the rotate's answer was lost - pay.ts's reasoning: had the note - // still been pending the service would have said so, so the funds - // ARE back, but whether the rotation landed is unknown. Surface - // the possible fresh copy alongside the unchanged note. + // The rotate's answer was lost. Keep the transfer journal until + // recovery proves whether the original or recovery secret is live. const rescuedNote: NewBearer = { url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount), callback: carve.note.callback, @@ -378,7 +336,7 @@ export const transferBetweenMints = async ( verified: false, } if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey - return {...base, outcome: 'failed-funds-returned', rescuedNote} + return {...base, outcome: 'unknown-still-pending', claimMaterial, rescuedNote} } return {...base, outcome: 'unknown-still-pending', claimMaterial} } diff --git a/src/lnurlcash/types.ts b/src/lnurlcash/types.ts index 974c196..185977c 100644 --- a/src/lnurlcash/types.ts +++ b/src/lnurlcash/types.ts @@ -35,6 +35,15 @@ export type Bearer = { // blank mirror, kept only so this bearer displays like any other // (amount/host/label/state) deviceId?: string + // A wallet-chosen mint output persisted before invoice creation. It stays + // zero-valued until the mint confirms it; transfers also remember the + // source record that must be locked when a late settlement is recovered. + pendingMint?: { + sourceBearerId?: string + sourceRecoverySecret?: string + mintPubkey?: string + retireAfter?: number + } createdAt: number updatedAt: number } @@ -46,4 +55,10 @@ export type NewBearer = { verified: boolean mintPubkey?: string deviceId?: string + pendingMint?: { + sourceBearerId?: string + sourceRecoverySecret?: string + mintPubkey?: string + retireAfter?: number + } } diff --git a/src/stores/walletFunds.recovery.test.ts b/src/stores/walletFunds.recovery.test.ts new file mode 100644 index 0000000..97a83e8 --- /dev/null +++ b/src/stores/walletFunds.recovery.test.ts @@ -0,0 +1,350 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { createMockMint } from 'lnurlcash-conformance/mock-mint'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { useWalletStore } from './wallet'; + +type Mint = Awaited>; + +const mints: Mint[] = []; + +const mint = async (): Promise => { + const instance = await createMockMint(); + mints.push(instance); + return instance; +}; + +const stagedNote = ( + instance: Mint, + secret: string, + sourceBearerId?: string, + sourceRecoverySecret?: string, +): NewBearer => ({ + url: buildNoteUrl(`${instance.url}/w`, secret, 21_000), + callback: '', + amount: 0, + verified: false, + pendingMint: sourceBearerId ? { sourceBearerId, sourceRecoverySecret } : {}, +}); + +const liveNote = (instance: Mint, secret: string): NewBearer => ({ + url: buildNoteUrl(`${instance.url}/w`, secret, 21_000), + callback: `${instance.url}/w/cb`, + amount: 21_000, + verified: true, +}); + +// a present-but-non-serializing LockManager fake: walletFunds serializes its +// own mutations, so single-store tests only need locks to EXIST +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + stubPassthroughLocks(); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +afterEach(async () => { + await Promise.all(mints.splice(0).map((instance) => instance.close())); +}); + +describe('pending mint recovery', () => { + it('keeps an unpaid staged output at zero value', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'a'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers).toEqual([staged]); + expect(wallet.balanceMsat).toBe(0); + }); + + it('retires an unminted NWC output after its safe settlement window', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const note = stagedNote(instance, '7'.repeat(64)); + const [staged] = await wallet.addBearers( + [{ ...note, pendingMint: { retireAfter: Math.floor(Date.now() / 1000) - 1 } }], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + }); + + it('finalizes a paid stage under the same id and spends its transfer source atomically', async () => { + const storage = stubLocalStorage(); + const instance = await mint(); + const secret = 'b'.repeat(64); + const wallet = useWalletStore(); + await wallet.create(); + const [source] = await wallet.addBearers( + [liveNote(instance, 'c'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source) throw new Error('Expected a source bearer.'); + const [staged] = await wallet.addBearers( + [stagedNote(instance, secret, source.id)], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + instance.state.creditNote(secret, 21_000); + const writes = vi.spyOn(storage, 'setItem'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_funds_v2')).toHaveLength(1); + expect(wallet.bearers.find((bearer) => bearer.id === source.id)?.spent).toBe(true); + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({ + amount: 21_000, + callback: `${instance.url}/w/cb`, + verified: true, + pendingMint: undefined, + }); + expect(wallet.balanceMsat).toBe(21_000); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('rejects finalization when the staged record is missing', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + + await expect( + wallet.finalizeStagedMintOutput( + 'missing', + liveNote(instance, 'd'.repeat(64)), + wallet.captureOwnerFence(), + ), + ).rejects.toThrow('record no longer exists'); + }); + + it('reserves a transfer source in the same write that links its staged output', async () => { + const storage = stubLocalStorage(); + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const [source] = await wallet.addBearers( + [liveNote(instance, 'e'.repeat(64))], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'f'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + const writes = vi.spyOn(storage, 'setItem'); + + const recoverySecret = '2'.repeat(64); + await wallet.reserveStagedMintSource( + staged.id, + source.id, + recoverySecret, + wallet.captureOwnerFence(), + ); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_funds_v2')).toHaveLength(1); + expect(wallet.bearers.find((bearer) => bearer.id === source.id)?.spent).toBe(true); + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)?.pendingMint).toEqual({ + sourceBearerId: source.id, + sourceRecoverySecret: recoverySecret, + }); + }); + + it('rejects a second reservation of the same source', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const [source] = await wallet.addBearers( + [liveNote(instance, '3'.repeat(64))], + wallet.captureOwnerFence(), + ); + const [first, second] = await wallet.addBearers( + [stagedNote(instance, '4'.repeat(64)), stagedNote(instance, '5'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source || !first || !second) throw new Error('Expected source and staged bearers.'); + await wallet.reserveStagedMintSource( + first.id, + source.id, + '6'.repeat(64), + wallet.captureOwnerFence(), + ); + + await expect( + wallet.reserveStagedMintSource( + second.id, + source.id, + '7'.repeat(64), + wallet.captureOwnerFence(), + ), + ).rejects.toThrow(/already reserved/i); + }); + + it('re-secures a live linked source after a crash before melt', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '8'.repeat(64); + const recoverySecret = '9'.repeat(64); + instance.state.creditNote(sourceSecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'a'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + await wallet.reserveStagedMintSource( + staged.id, + source.id, + recoverySecret, + wallet.captureOwnerFence(), + ); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({ + amount: 21_000, + verified: true, + }); + expect(wallet.balanceMsat).toBe(21_000); + expect(instance.state.noteState(sourceSecret)).toBe('burned'); + expect(instance.state.noteState(recoverySecret)).toBe('outstanding'); + }); + + it('adopts the journaled source secret after a recovery rotate landed before a crash', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = 'd'.repeat(64); + const recoverySecret = 'e'.repeat(64); + instance.state.creditNote(recoverySecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'f'.repeat(64), source?.id, recoverySecret)], + wallet.captureOwnerFence(), + ); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + await wallet.markSpent(source.id, wallet.captureOwnerFence()); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({ + amount: 21_000, + verified: true, + }); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('deduplicates a returned source saved before its transfer journal was removed', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '1'.repeat(64); + const recoverySecret = '2'.repeat(64); + instance.state.creditNote(recoverySecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, '3'.repeat(64), source?.id, recoverySecret)], + wallet.captureOwnerFence(), + ); + await wallet.addBearers([liveNote(instance, recoverySecret)], wallet.captureOwnerFence()); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + await wallet.markSpent(source.id, wallet.captureOwnerFence()); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.filter((bearer) => bearer.url.includes(recoverySecret))).toHaveLength(1); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('returns a live source even when the staged target cannot be observed', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '4'.repeat(64); + const recoverySecret = '5'.repeat(64); + instance.state.creditNote(sourceSecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + if (!source) throw new Error('Expected a source bearer.'); + const [staged] = await wallet.addBearers( + [ + { + url: buildNoteUrl('http://127.0.0.1:1/w', '6'.repeat(64), 21_000), + callback: '', + amount: 0, + verified: false, + pendingMint: { sourceBearerId: source.id, sourceRecoverySecret: recoverySecret }, + }, + ], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + await wallet.markSpent(source.id, wallet.captureOwnerFence()); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({ + amount: 21_000, + verified: true, + }); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('ends recovery when the staged target is definitively spent', async () => { + const instance = await mint(); + const secret = '1'.repeat(64); + const wallet = useWalletStore(); + await wallet.create(); + const [staged] = await wallet.addBearers( + [stagedNote(instance, secret)], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + instance.state.creditNote(secret, 21_000); + instance.state.settleMelt(secret); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({ + spent: true, + pendingMint: undefined, + }); + }); +}); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index bd97251..c4608b1 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -1,6 +1,6 @@ // allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. import { computed, ref } from 'vue'; -import { deriveCashSecret, serverOf } from 'lnurlcash-kit'; +import { deriveCashSecret, noteK1, serverOf } from 'lnurlcash-kit'; import type { CashNode } from 'lnurlcash-kit'; import { @@ -15,7 +15,9 @@ import type { BearerChangeset } from '@/lnurlcash/storage'; import { lockTrustedMint } from '@/lnurlcash/trustedMints'; import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { msatToSats } from '@/lnurlcash/units'; +import { recoverPendingTransferSource } from '@/lnurlcash/ops'; import type { CarveResult } from '@/lnurlcash/ops'; +import { recoverStagedMintOutput } from '@/lnurlcash/ops/mint'; import type { WalletOwnerFence } from './walletOwnerFence'; export class TrustedMintPostCommitError extends Error { @@ -160,10 +162,30 @@ export const createWalletFunds = (options: WalletFundsOptions) => { const commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise => mutate(async () => { - const existing = bearers.value.find((bearer) => bearer.url === carve.note.url); + // a carve checkpoint is identified by its output secret, not the + // whole URL: the landed phase's note carries the mint's signature in + // the URL that the pre-wire staged phase could not have had yet + const sameOutput = (bearer: Bearer, note: NewBearer): boolean => { + const k1 = noteK1(note.url); + return ( + k1 !== null && + serverOf(bearer.url) === serverOf(note.url) && + noteK1(bearer.url) === k1 + ); + }; + const staged = bearers.value.find((bearer) => sameOutput(bearer, carve.note)); + const change = carve.change; const additions: NewBearer[] = []; - if (!existing) additions.push(carve.note); - if (carve.change) additions.push(carve.change); + if (!staged) additions.push(carve.note); + if (change && !bearers.value.some((bearer) => sameOutput(bearer, change))) { + additions.push(change); + } + // the landed checkpoint re-reports the staged note with its landed + // signature and verification state - refresh the record in place so + // a replayed checkpoint is a no-op rather than a duplicate + const upserts: Bearer[] = staged + ? [{ ...staged, ...carve.note, id: staged.id, updatedAt: Date.now() }] + : []; ownerFence(); const next = await applyBearerChangeset( options.requireKey(), @@ -171,13 +193,14 @@ export const createWalletFunds = (options: WalletFundsOptions) => { { add: additions, markSpent: carve.consumed.map((bearer) => bearer.id), + upsert: upserts, }, { beforeCommit: ownerFence }, ); const added = next.slice(0, additions.length); bearers.value = next; - await lockCommittedBearers(added); - const committed = existing ?? added[0]; + await lockCommittedBearers([...added, ...upserts]); + const committed = upserts[0] ?? added[0]; if (!committed) throw new Error('The carved note was not tracked.'); return committed; }); @@ -206,6 +229,234 @@ export const createWalletFunds = (options: WalletFundsOptions) => { } }); + const reserveStagedMintSource = ( + stagedId: string, + sourceId: string, + sourceRecoverySecret: string, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const staged = bearers.value.find((bearer) => bearer.id === stagedId); + const source = bearers.value.find((bearer) => bearer.id === sourceId); + if (!staged || !source) throw new BearerNotFoundError(); + if (!staged.pendingMint) throw new Error('The wallet record is not a pending mint output.'); + if (source.spent) throw new Error('The source note is already reserved.'); + ownerFence(); + const linked: Bearer = { + ...staged, + pendingMint: { + ...staged.pendingMint, + sourceBearerId: source.id, + sourceRecoverySecret, + }, + updatedAt: Date.now(), + }; + bearers.value = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { add: [], markSpent: [source.id], upsert: [linked] }, + { beforeCommit: ownerFence }, + ); + }); + + const restoreStagedMintSource = ( + stagedId: string, + note: NewBearer, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + const staged = bearers.value.find((bearer) => bearer.id === stagedId); + if (!staged?.pendingMint?.sourceBearerId) throw new BearerNotFoundError(); + const recoveryK1 = noteK1(note.url); + const existing = recoveryK1 + ? bearers.value.find( + (bearer) => + bearer.id !== staged.id && + serverOf(bearer.url) === serverOf(note.url) && + noteK1(bearer.url) === recoveryK1, + ) + : undefined; + ownerFence(); + const additions = existing ? [] : [note]; + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: additions, + markSpent: [staged.pendingMint.sourceBearerId], + remove: [staged.id], + upsert: existing + ? [{ ...existing, ...note, id: existing.id, updatedAt: Date.now() }] + : [], + }, + { beforeCommit: ownerFence }, + ); + bearers.value = next; + const committed = existing + ? next.find((bearer) => bearer.id === existing.id) + : next[0]; + if (!committed) throw new BearerNotFoundError(); + await lockCommittedBearers([committed]); + }); + + const finalizeStagedMintOutput = async ( + id: string, + note: NewBearer, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) throw new BearerNotFoundError(); + if (!current.pendingMint) { + if ( + current.url === note.url && + current.callback === note.callback && + current.amount === note.amount && + current.verified === note.verified && + current.mintPubkey === note.mintPubkey + ) { + return; + } + throw new Error('The wallet record is not a pending mint output.'); + } + const sourceBearerId = current.pendingMint.sourceBearerId; + if (sourceBearerId && !bearers.value.some((bearer) => bearer.id === sourceBearerId)) { + throw new BearerNotFoundError(); + } + ownerFence(); + const confirmed: Bearer = { + ...current, + ...note, + pendingMint: undefined, + updatedAt: Date.now(), + }; + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: [], + markSpent: sourceBearerId ? [sourceBearerId] : [], + upsert: [confirmed], + }, + { beforeCommit: ownerFence }, + ); + bearers.value = next; + await lockCommittedBearers([confirmed]); + }); + + const finalizeSpentStagedMintOutput = (id: string, ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) throw new BearerNotFoundError(); + if (!current.pendingMint) return; + const sourceBearerId = current.pendingMint.sourceBearerId; + ownerFence(); + const spent: Bearer = { + ...current, + pendingMint: undefined, + spent: true, + updatedAt: Date.now(), + }; + bearers.value = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: [], + markSpent: sourceBearerId ? [sourceBearerId] : [], + upsert: [spent], + }, + { beforeCommit: ownerFence }, + ); + }); + + let recoveryRun: Promise | null = null; + const recoverPendingMints = (ownerFence: WalletOwnerFence): Promise => { + if (recoveryRun) return recoveryRun; + recoveryRun = (async () => { + for (const staged of [...bearers.value]) { + if (!staged.pendingMint || staged.spent) continue; + try { + const recoverLinkedSource = async (): Promise => { + const sourceBearerId = staged.pendingMint?.sourceBearerId; + if (!sourceBearerId) return; + const source = bearers.value.find((bearer) => bearer.id === sourceBearerId); + if (!source) throw new BearerNotFoundError(); + const sourceRecovery = await recoverPendingTransferSource(staged, source, { + assertOwner: ownerFence, + }); + switch (sourceRecovery.state) { + case 'pending': + break; + case 'returned': + await restoreStagedMintSource(staged.id, sourceRecovery.note, ownerFence); + break; + case 'spent': + await finalizeSpentStagedMintOutput(staged.id, ownerFence); + break; + } + }; + const recovered = await recoverStagedMintOutput(staged, { assertOwner: ownerFence }); + switch (recovered.state) { + case 'unminted': + if ( + !staged.pendingMint.sourceBearerId && + staged.pendingMint.retireAfter !== undefined && + staged.pendingMint.retireAfter <= Math.floor(Date.now() / 1000) + ) { + await removeNote(staged.id, ownerFence); + break; + } + await recoverLinkedSource(); + break; + case 'pending': + await recoverLinkedSource(); + break; + case 'minted': + await finalizeStagedMintOutput(staged.id, recovered.note, ownerFence); + break; + case 'spent': + await finalizeSpentStagedMintOutput(staged.id, ownerFence); + break; + } + } catch (error) { + ownerFence(); + if (staged.pendingMint.sourceBearerId) { + try { + const source = bearers.value.find( + (bearer) => bearer.id === staged.pendingMint?.sourceBearerId, + ); + if (!source) throw new BearerNotFoundError(); + const sourceRecovery = await recoverPendingTransferSource(staged, source, { + assertOwner: ownerFence, + }); + if (sourceRecovery.state === 'returned') { + await restoreStagedMintSource(staged.id, sourceRecovery.note, ownerFence); + continue; + } + } catch (sourceError) { + ownerFence(); + if (sourceError instanceof TrustedMintPostCommitError) { + options.setAuxiliaryError(sourceError.message); + continue; + } + } + } + options.setAuxiliaryError( + error instanceof TrustedMintPostCommitError + ? error.message + : 'A pending mint output could not be refreshed. It will be retried later.', + ); + } + } + })().finally(() => { + recoveryRun = null; + }); + return recoveryRun; + }; + const markSpent = async ( id: string, ownerFence: WalletOwnerFence, @@ -297,6 +548,11 @@ export const createWalletFunds = (options: WalletFundsOptions) => { applyChangeset, commitCarve, updateBearer, + reserveStagedMintSource, + restoreStagedMintSource, + finalizeStagedMintOutput, + finalizeSpentStagedMintOutput, + recoverPendingMints, allocateCashSecrets, markSpent, removeNote,