From a91ef30498870dc021e49dc876752478c225f106 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:59:34 +0200 Subject: [PATCH] refactor(ops): require durable allocation and checkpoints --- src/lnurlcash/mintContract.test.ts | 20 +++++-- src/lnurlcash/ops.carve-crash.cases.ts | 20 +++---- src/lnurlcash/ops.carve.cases.ts | 53 ++++++++++++++++-- src/lnurlcash/ops.mint-named.cases.ts | 24 ++++++++- src/lnurlcash/ops.mint-receive.cases.ts | 49 +++++++++++++++-- src/lnurlcash/ops.pay.cases.ts | 43 ++++++++++++++- src/lnurlcash/ops.retry-refusal.cases.ts | 15 +++++- src/lnurlcash/ops.signature.cases.ts | 16 +++++- src/lnurlcash/ops.testHarness.ts | 4 +- src/lnurlcash/ops.transfer.cases.ts | 44 ++++++++++++++- src/lnurlcash/ops.ts | 10 ++-- src/lnurlcash/ops/carve.ts | 31 ++++++----- src/lnurlcash/ops/mint.ts | 12 ++--- src/lnurlcash/ops/pay.ts | 23 +++++--- src/lnurlcash/ops/receiveBearer.ts | 68 ++++++++++++------------ src/lnurlcash/ops/transfer.ts | 26 ++++++--- src/stores/walletFunds.ts | 2 +- 17 files changed, 356 insertions(+), 104 deletions(-) diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts index 1b73be9..5e5f614 100644 --- a/src/lnurlcash/mintContract.test.ts +++ b/src/lnurlcash/mintContract.test.ts @@ -49,6 +49,16 @@ const settleLastInvoice = async (m: Mint): Promise => { const MINT_PUBKEY = `02${'ab'.repeat(32)}` const persistOutput = async (): Promise => undefined +// deterministic stand-in for the wallet's BIP-32 allocation path +let allocatedCounter = 0 +const allocateOutputSecrets = (_server: string, count: number): Promise => { + const secrets = Array.from({length: count}, () => { + allocatedCounter += 1 + return `03${allocatedCounter.toString(16).padStart(62, '0')}` + }) + return Promise.resolve(secrets) +} + // a mint-address (LUD-25) wire response exactly as lnurl-mint serves it: // node stats under their WIRE names - `nodeCapacity` is msat like every // other amount, named without the suffix on the wire @@ -124,7 +134,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, {persistOutput}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput, allocateOutputSecrets}) // the metadata is advertised at the mint-address endpoint itself - // the payRequest never carried it expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) @@ -141,7 +151,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, {persistOutput}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput, allocateOutputSecrets}) expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) // and the link is fully usable: settle the invoice, claim the note @@ -174,7 +184,7 @@ describe('withdraw-link forms', () => { // 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, persistOutput}) + const prepared = await prepareMint('mint@mint.example', 21_000, {fetch, persistOutput, allocateOutputSecrets}) 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') @@ -218,6 +228,7 @@ describe('quote output-naming wire contract', () => { prepareMint('mint@mint.example', 21_000, { fetch: capturingFetch(routesFor(payRequestFixture({})), seen), persistOutput, + allocateOutputSecrets, }), ).rejects.toThrow(/64-character output commitment/i) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) @@ -227,6 +238,7 @@ describe('quote output-naming wire contract', () => { const seen: string[] = [] const events: string[] = [] const prepared = await prepareMint('mint@mint.example', 21_000, { + allocateOutputSecrets, fetch: (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url @@ -264,6 +276,7 @@ describe('quote output-naming wire contract', () => { prepareMint('mint@mint.example', 21_000, { fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), persistOutput: () => Promise.reject(new Error('storage full')), + allocateOutputSecrets, }), ).rejects.toThrow(/storage full/) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) @@ -275,6 +288,7 @@ describe('quote output-naming wire contract', () => { prepareMint('mint@mint.example', 21_000, { fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), persistOutput, + allocateOutputSecrets, }), ).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 index ff236ee..74a68c9 100644 --- a/src/lnurlcash/ops.carve-crash.cases.ts +++ b/src/lnurlcash/ops.carve-crash.cases.ts @@ -6,13 +6,12 @@ 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 +const allocatedSecrets = (...secrets: string[]): ((server: string, count: number) => Promise) => { + let served = false + return (_server, count) => { + if (served) throw new Error('The test expected a single allocation.') + served = true + return Promise.resolve(secrets.slice(0, count)) } } @@ -37,7 +36,7 @@ describe('ensureExactAmount crash safety', () => { const result = await ensureExactAmount([bearer], 5_000, { fetch: observingFetch, - randomSecret: secretSource(targetSecret, changeSecret), + allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret), onCarve: (prepared) => { staged = prepared }, @@ -67,7 +66,7 @@ describe('ensureExactAmount crash safety', () => { await ensureExactAmount([first, second], 7_000, { fetch: observingFetch, - randomSecret: secretSource(mergedSecret), + allocateOutputSecrets: allocatedSecrets(mergedSecret), onCarve: (prepared) => { staged = prepared }, @@ -95,7 +94,7 @@ describe('ensureExactAmount crash safety', () => { const result = await ensureExactAmount([bearer], 5_000, { fetch: dropSecondMutation, mutationRetries: 0, - randomSecret: secretSource(secret('77'), secret('78')), + allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')), onCarve: () => undefined, }) @@ -150,6 +149,7 @@ describe('ensureExactAmount crash safety', () => { await expect( ensureExactAmount([first, second], 7_000, { fetch: countingFetch, + allocateOutputSecrets: allocatedSecrets(secret('7b')), assertOwner: () => { ownerChecks += 1 if (ownerChecks === 3) throw new Error('wallet owner changed') diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts index 9f2a30d..a2396c6 100644 --- a/src/lnurlcash/ops.carve.cases.ts +++ b/src/lnurlcash/ops.carve.cases.ts @@ -3,9 +3,21 @@ import {fetchNoteInfo, noteK1, noteSignature} from 'lnurlcash-kit' import type {Bearer} from './types' import type {CarveResult} from './ops' -import {CarveCheckpointRequiredError, UncertainOutcomeError, ensureExactAmount} from './ops' +import { + CarveCheckpointRequiredError, + OutputSecretAllocationRequiredError, + UncertainOutcomeError, + ensureExactAmount, +} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness' +import { + allocateOutputSecrets, + makeBearer, + mint, + noteUrl, + secret, + signatureKeysFor, +} from './ops.testHarness' describe('ensureExactAmount', () => { it('returns an already-exact note untouched, burning nothing', async () => { @@ -25,6 +37,7 @@ describe('ensureExactAmount', () => { const bearer = await makeBearer(instance, k1, 21_000) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) expect(result.note.amount).toBe(5_000) @@ -42,7 +55,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, {onCarve: () => undefined}) + const result = await ensureExactAmount([first, second], 7_000, {onCarve: () => undefined, allocateOutputSecrets}) expect(result.note.amount).toBe(7_000) expect(result.change).toBeUndefined() expect(result.consumed).toHaveLength(2) @@ -56,7 +69,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, {onCarve: () => undefined}) + const result = await ensureExactAmount([first, second], 5_000, {onCarve: () => undefined, allocateOutputSecrets}) expect(result.note.amount).toBe(5_000) expect(result.change?.amount).toBe(2_000) expect(result.consumed).toHaveLength(2) @@ -88,7 +101,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, {onCarve: () => undefined}) + const result = await ensureExactAmount([bearer], 5_000, {onCarve: () => undefined, allocateOutputSecrets}) const partK1 = requiredValue(noteK1(result.note.url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) expect(partK1).not.toBe(k1) @@ -118,6 +131,7 @@ describe('ensureExactAmount', () => { const checkpoints: CarveResult[] = [] const failure = await ensureExactAmount([bearer], 5_000, { fetch: everythingDyingFetch, + allocateOutputSecrets, onCarve: (carve) => { checkpoints.push(carve) }, @@ -136,6 +150,7 @@ describe('ensureExactAmount', () => { const bearer = await makeBearer(instance, secret('12'), 21_000) const seen: CarveResult[] = [] const result = await ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, onCarve: (carve) => { seen.push(carve) }, @@ -166,6 +181,7 @@ describe('ensureExactAmount', () => { const retiredIds = new Set() const result = await ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, 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 @@ -194,6 +210,7 @@ describe('ensureExactAmount', () => { await expect( ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, onCarve: (carve) => { seen.push(carve) if (carve.consumed.length > 0) throw new Error('final commit failed') @@ -243,6 +260,7 @@ describe('ensureExactAmount', () => { const bearer = await makeBearer(instance, secret('14'), 21_000) await expect( ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, onCarve: () => { throw new Error('commit failed') }, @@ -303,4 +321,29 @@ describe('ensureExactAmount', () => { expect(allocations).toBe(0) }) + + it('refuses a mutating carve without an allocation path, before any staging', async () => { + const instance = await mint() + const split = await makeBearer(instance, secret('87'), 21_000) + const mergeA = await makeBearer(instance, secret('88'), 3_000) + const mergeB = await makeBearer(instance, secret('89'), 4_000) + let mutations = 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')) mutations += 1 + return fetch(input, init) + } + + await expect( + ensureExactAmount([split], 5_000, {fetch: countingFetch, onCarve: () => undefined}), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + await expect( + ensureExactAmount([mergeA, mergeB], 7_000, {fetch: countingFetch, onCarve: () => undefined}), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + + expect(mutations).toBe(0) + expect(instance.state.noteState(secret('87'))).toBe('outstanding') + expect(instance.state.noteState(secret('88'))).toBe('outstanding') + expect(instance.state.noteState(secret('89'))).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts index 16ce2b0..28fb9c1 100644 --- a/src/lnurlcash/ops.mint-named.cases.ts +++ b/src/lnurlcash/ops.mint-named.cases.ts @@ -1,10 +1,10 @@ import {describe, expect, it} from 'vitest' import {hashK1, noteK1} from 'lnurlcash-kit' -import {claimMintedNote, prepareMint} from './ops' +import {claimMintedNote, OutputSecretAllocationRequiredError, prepareMint} from './ops' import {requiredValue} from './test-utils' import type {Mint} from './ops.testHarness' -import {mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness' +import {allocateOutputSecrets, mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness' const HEX32 = /^[0-9a-f]{64}$/ @@ -16,6 +16,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) expect(prepared.noteSecret).toMatch(HEX32) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) @@ -25,6 +26,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) const preimage = await settleLastInvoice(instance) const claimed = await claimMintedNote(prepared, { @@ -44,6 +46,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) await expect( claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), @@ -54,6 +57,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({mintToHash: true, testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) await settleLastInvoice(instance) @@ -83,4 +87,20 @@ describe('mint -> claim, comment-bound output', () => { expect(prepared.noteSecret).toBe(allocated[0]) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0]))) }) + + it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => { + const instance = await mint({testHooks: true}) + let persisted = 0 + + await expect( + prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput: () => { + persisted += 1 + }, + }), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + + expect(persisted).toBe(0) + expect(instance.state.invoices.size).toBe(0) + }) }) diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts index 5c5b989..13b51be 100644 --- a/src/lnurlcash/ops.mint-receive.cases.ts +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -8,16 +8,24 @@ import { rotateNote, } from 'lnurlcash-kit' -import {claimMintedNote, prepareMint, receiveBearer} from './ops' +import { + claimMintedNote, + OutputSecretAllocationRequiredError, + prepareMint, + receiveBearer, + ReceiveRotationStagingRequiredError, +} from './ops' import {requiredValue} from './test-utils' import type {NewBearer} from './types' import { + allocateOutputSecrets, makeBearer, mint, noteUrl, persistOutput, secret, settleLastInvoice, + stageRotation, } from './ops.testHarness' describe('mint fee', () => { @@ -25,6 +33,7 @@ describe('mint fee', () => { 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, { persistOutput, + allocateOutputSecrets, }) expect(prepared.grossMsat).toBeGreaterThan(100_000) await settleLastInvoice(instance) @@ -105,8 +114,12 @@ describe('receiveBearer', () => { const instance = await mint() const senderK1 = secret('20') instance.state.creditNote(senderK1, 21_000) - const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), []) + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { + allocateOutputSecrets, + stageRotation, + }) expect(received.rotated).toBe(true) + expect(received.stage).toBe('finalize') expect(received.note.amount).toBe(21_000) expect(received.note.verified).toBe(true) const newK1 = requiredValue(noteK1(received.note.url)) @@ -140,8 +153,34 @@ describe('receiveBearer', () => { const k1 = secret('23') const bearer = await makeBearer(instance, k1, 21_000) await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') - await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( - PendingNoteError, - ) + await expect( + receiveBearer(noteUrl(instance, k1, 21_000), [], { + allocateOutputSecrets, + stageRotation, + }), + ).rejects.toBeInstanceOf(PendingNoteError) + }) + + it('refuses to rotate without a durable staging checkpoint', async () => { + const instance = await mint() + const senderK1 = secret('9a') + instance.state.creditNote(senderK1, 21_000) + + await expect( + receiveBearer(noteUrl(instance, senderK1, 21_000), [], {allocateOutputSecrets}), + ).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError) + // the sender's copy is untouched - no rotation was attempted + expect(instance.state.noteState(senderK1)).toBe('outstanding') + }) + + it('refuses to rotate without an allocation path', async () => { + const instance = await mint() + const senderK1 = secret('9b') + instance.state.creditNote(senderK1, 21_000) + + await expect( + receiveBearer(noteUrl(instance, senderK1, 21_000), [], {stageRotation}), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + expect(instance.state.noteState(senderK1)).toBe('outstanding') }) }) diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts index 04105ab..5208b37 100644 --- a/src/lnurlcash/ops.pay.cases.ts +++ b/src/lnurlcash/ops.pay.cases.ts @@ -1,9 +1,13 @@ import {describe, expect, it} from 'vitest' import {noteK1} from 'lnurlcash-kit' -import {payWithBearers} from './ops' +import { + OutputSecretAllocationRequiredError, + PayReturnCheckpointRequiredError, + payWithBearers, +} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, secret} from './ops.testHarness' +import {allocateOutputSecrets, makeBearer, mint, secret} from './ops.testHarness' describe('payWithBearers', () => { it('pays a bolt11 invoice by melting an exact note (settled)', async () => { @@ -52,6 +56,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}, + allocateOutputSecrets, onCarve: () => undefined, }) expect(result.outcome).toBe('settled') @@ -67,6 +72,8 @@ describe('payWithBearers', () => { const bearer = await makeBearer(instance, secret('33'), 21_000) const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, + onReturnReady: () => undefined, }) expect(result.outcome).toBe('failed-funds-returned') expect(instance.state.noteState(secret('33'))).toBe('burned') @@ -88,6 +95,7 @@ describe('payWithBearers', () => { }) const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, onReturnReady: async () => { stageStarted?.() await stageGate @@ -106,6 +114,8 @@ describe('payWithBearers', () => { const bearer = await makeBearer(instance, secret('34'), 21_000) const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, + onReturnReady: () => undefined, }) expect(result.outcome).toBe('unknown-still-pending') expect(instance.state.noteState(secret('34'))).toBe('pending') @@ -125,6 +135,7 @@ describe('payWithBearers', () => { // through the hook before the melt may start await expect( payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', { + allocateOutputSecrets, onCarve: () => { throw new Error('commit failed') }, @@ -175,4 +186,32 @@ describe('payWithBearers', () => { expect(result.outcome).toBe('settled') expect(allocations).toBe(0) }) + + it('refuses the return classification without its durable checkpoint', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('8d'), 21_000) + + await expect( + payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, + }), + ).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError) + // the payment failed and the mint restored the note; the refusal came + // before the classification rotate, so nothing else moved + expect(instance.state.noteState(secret('8d'))).toBe('outstanding') + }) + + it('refuses the return classification without an allocation path', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('8e'), 21_000) + + await expect( + payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + onReturnReady: () => undefined, + }), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + expect(instance.state.noteState(secret('8e'))).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops.retry-refusal.cases.ts b/src/lnurlcash/ops.retry-refusal.cases.ts index 4d3263e..dce23a9 100644 --- a/src/lnurlcash/ops.retry-refusal.cases.ts +++ b/src/lnurlcash/ops.retry-refusal.cases.ts @@ -4,7 +4,15 @@ import {noteK1} from 'lnurlcash-kit' import type {CarveResult} from './ops' import {ensureExactAmount, receiveBearer} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, retryingCbFetch, secret} from './ops.testHarness' +import { + allocateOutputSecrets, + makeBearer, + mint, + noteUrl, + retryingCbFetch, + secret, + stageRotation, +} 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 @@ -19,6 +27,7 @@ describe('mutation retry refusals', () => { const bearer = await makeBearer(instance, k1, 50_000) const result = await ensureExactAmount([bearer], 25_000, { fetch: retryingCbFetch(), + allocateOutputSecrets, onCarve: () => undefined, }) // the split landed on the first (unseen) attempt: the input is burned @@ -39,6 +48,7 @@ describe('mutation retry refusals', () => { const b = await makeBearer(instance, secret('62'), 5_000) const result = await ensureExactAmount([a, b], 25_000, { fetch: retryingCbFetch(), + allocateOutputSecrets, onCarve: () => undefined, }) expect(result.consumed).toHaveLength(2) @@ -55,6 +65,8 @@ describe('mutation retry refusals', () => { instance.state.creditNote(senderK1, 21_000) const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { fetch: retryingCbFetch(), + allocateOutputSecrets, + stageRotation, }) expect(received.rotated).toBe(true) expect(received.note.amount).toBe(21_000) @@ -73,6 +85,7 @@ describe('mutation retry refusals', () => { await expect( ensureExactAmount([bearer], 25_000, { fetch: retryingCbFetch(), + allocateOutputSecrets, onCarve: (carve) => { checkpoints.push(carve) }, diff --git a/src/lnurlcash/ops.signature.cases.ts b/src/lnurlcash/ops.signature.cases.ts index 4079533..63e3938 100644 --- a/src/lnurlcash/ops.signature.cases.ts +++ b/src/lnurlcash/ops.signature.cases.ts @@ -3,7 +3,14 @@ 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 { + allocateOutputSecrets, + 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 @@ -19,6 +26,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -38,6 +46,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([first, second], 7_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -56,6 +65,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -73,6 +83,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, // only the current key is trusted - the signature under the retired // key must not verify mintSignatureKeys: () => [instance.state.pubkey], @@ -93,6 +104,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, // the only trusted key on file belongs to a DIFFERENT mint mintSignatureKeys: () => [stranger.state.pubkey], }) @@ -112,6 +124,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -133,6 +146,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, // no key source at all: nothing may be trusted blindly }) diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index 4796a79..9b0daa8 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -1,6 +1,6 @@ import {afterEach, expect} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {sha256} from '@noble/hashes/sha2.js' import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit' @@ -35,7 +35,7 @@ export const allocateOutputSecrets = ( ): Promise => { const secrets = Array.from({length: count}, () => { allocatedSecretCounter += 1 - return secret(`allocated-${allocatedSecretCounter.toString(16)}`) + return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`))) }) return Promise.resolve(secrets) } diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 2c944c6..13f9404 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -3,10 +3,15 @@ import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {hashK1, noteK1} from 'lnurlcash-kit' import {transferBetweenMints as transferBetweenMintsEngine} from './ops' +import { + OutputSecretAllocationRequiredError, + TransferMeltCheckpointRequiredError, +} from './ops' import type {CarveResult, TransferOptions} from './ops' import type {Bearer} from './types' import {requiredValue} from './test-utils' import { + allocateOutputSecrets, expectBurned, makeBearer, mint, @@ -20,7 +25,13 @@ const transferBetweenMints = ( amountMsat: number, targetMint: string, options: Omit = {}, -) => transferBetweenMintsEngine(bearers, amountMsat, targetMint, {...options, persistOutput}) +) => + transferBetweenMintsEngine(bearers, amountMsat, targetMint, { + allocateOutputSecrets, + onMeltReady: () => undefined, + ...options, + persistOutput, + }) describe('transferBetweenMints', () => { const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} @@ -59,6 +70,37 @@ describe('transferBetweenMints', () => { expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret) }) + it('refuses the source melt without its durable checkpoint', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const bearer = await makeBearer(source, secret('98'), 21_000) + + await expect( + transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + allocateOutputSecrets, + persistOutput, + }), + ).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError) + + // the target stage happened (invoice exists) but the source note is + // untouched and nothing melted + expect(source.state.noteState(secret('98'))).toBe('outstanding') + }) + + it('refuses at target staging without an allocation path', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const bearer = await makeBearer(source, secret('99'), 21_000) + + await expect( + transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + onMeltReady: () => undefined, + persistOutput, + }), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + expect(source.state.noteState(secret('99'))).toBe('outstanding') + }) + it('moves value to another mint at a wallet-chosen target secret', async () => { const source = await mint() const target = await mint({testHooks: true}) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index eaa2d83..2a9ce18 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -47,11 +47,15 @@ export type { ClaimedNote, StagedMintRecovery, } from './ops/mint' -export {receiveBearer} from './ops/receiveBearer' +export {receiveBearer, ReceiveRotationStagingRequiredError} from './ops/receiveBearer' export type {ReceiveBearerOptions, ReceivedNote} from './ops/receiveBearer' -export {payWithBearers} from './ops/pay' +export {payWithBearers, PayReturnCheckpointRequiredError} from './ops/pay' export type {PayOutcome, PayResult, PayOptions} from './ops/pay' -export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer' +export { + recoverPendingTransferSource, + transferBetweenMints, + TransferMeltCheckpointRequiredError, +} from './ops/transfer' export type { TransferClaimMaterial, TransferOptions, diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index 9cfee6f..c3dbcb5 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -9,7 +9,6 @@ import { AmbiguousMutationError, - defaultRandomSecret, mergeBatches, newSecretsOf, noteK1, @@ -63,10 +62,9 @@ export class CarveCheckpointRequiredError extends Error { export type CarveOptions = FundOperationOptions & { // the caller's durable allocation path for the carve's output secrets - // (split: target + change; merge: the combined note). When present the - // secrets come from the wallet's reserved BIP-32 indices; when absent the - // carve falls back to random secrets (legacy callers only - production - // always allocates). + // (split: target + change; merge: the combined note). Required for any + // mutating carve: the secrets come from the wallet's reserved BIP-32 + // indices so a restart can never reuse or lose them. readonly allocateOutputSecrets?: OutputSecretAllocator } @@ -152,9 +150,14 @@ export const ensureExactAmount = async ( if (mergeBatches(base.callback, k1s).length !== 1) { throw new UnsupportedMultiBatchMergeError() } - const [mergeSecret] = options.allocateOutputSecrets - ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 1) - : [(options.randomSecret ?? defaultRandomSecret)()] + if (!options.onCarve) { + throw new CarveCheckpointRequiredError() + } + const [mergeSecret] = await requireOutputSecrets( + options.allocateOutputSecrets, + serverOf(base.url), + 1, + ) const staged: NewBearer = { url: withNewK1(base.url, mergeSecret, total), callback: base.callback, @@ -189,10 +192,14 @@ export const ensureExactAmount = async ( // split path: total above target - one split request across all picked // k1s, carving the target off and leaving the change as a fresh note - const randomSecret = options.randomSecret ?? defaultRandomSecret - const [partK1, changeK1] = options.allocateOutputSecrets - ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 2) - : [randomSecret(), randomSecret()] + if (!options.onCarve) { + throw new CarveCheckpointRequiredError() + } + const [partK1, changeK1] = await requireOutputSecrets( + options.allocateOutputSecrets, + serverOf(base.url), + 2, + ) let partSignature: string | undefined let changeSignature: string | undefined const stagedNote: NewBearer = { diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 6d9c245..ea61d37 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -7,7 +7,6 @@ import { buildNoteUrl, claimMintedNote as fetchMintClaim, - defaultRandomSecret, fetchMintAddress, fetchPayRequest, grossUpForMintFee, @@ -56,10 +55,9 @@ export type PrepareMintOptions = FundOperationOptions & { readonly grossMsat: number readonly server: string }) => void | Promise - // the caller's durable allocation path for the staged note's secret. - // When present the secret comes from the wallet's reserved BIP-32 - // indices; when absent a random secret is generated (legacy callers - // only - production always allocates). + // the caller's durable allocation path for the staged note's secret - + // required: the wallet's reserved BIP-32 indices are the only source a + // restart can never reuse or lose readonly allocateOutputSecrets?: OutputSecretAllocator } @@ -108,9 +106,7 @@ export const prepareMint = async ( } const server = serverOf(payUrl) await beforePersist?.({grossMsat, server}) - const [noteSecret] = options.allocateOutputSecrets - ? await requireOutputSecrets(options.allocateOutputSecrets, server, 1) - : [(kitOptions.randomSecret ?? defaultRandomSecret)()] + const [noteSecret] = await requireOutputSecrets(options.allocateOutputSecrets, server, 1) const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey const staged: NewBearer = { url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index 64af71c..69d6687 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -8,7 +8,6 @@ import { NoteSpentError, PendingNoteError, decodeBolt11AmountMsat, - defaultRandomSecret, fetchPayRequest, isBolt11Invoice, meltNote, @@ -49,6 +48,14 @@ export type PayResult = { rescuedNote?: NewBearer } +export class PayReturnCheckpointRequiredError extends Error { + override readonly name = 'PayReturnCheckpointRequiredError' + + constructor() { + super('A pay-return classification requires a durable onReturnReady checkpoint.') + } +} + export type PayOptions = { // required when `input` is a Lightning Address / LNURL-pay (a bolt11 // carries its own amount) @@ -64,8 +71,7 @@ export type PayOptions = { onCarve?: FundOperationOptions['onCarve'] // the caller's durable allocation path: the carve's output secrets and - // only when the return classification actually needs one - the recovery - // secret. When absent, secrets fall back to random generation (legacy - // callers only - production always allocates). + // secret. Required for any mutating carve or return classification. allocateOutputSecrets?: OutputSecretAllocator // trusted signing keys the carve's landed outputs verify against (see // FundOperationOptions.mintSignatureKeys) @@ -174,10 +180,13 @@ export const payWithBearers = async ( // 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] = allocateOutputSecrets - ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) - : [(kit.randomSecret ?? defaultRandomSecret)()] - await onReturnReady?.(carve, recoverySecret) + if (!onReturnReady) throw new PayReturnCheckpointRequiredError() + const [recoverySecret] = await requireOutputSecrets( + allocateOutputSecrets, + serverOf(carve.note.url), + 1, + ) + await onReturnReady(carve, recoverySecret) try { const rotated = await rotateNote(carve.note.callback, k1, { ...options, diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index 3d9a9d9..9e4a110 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -4,15 +4,13 @@ // immediately, since the previous holder (and anything that logged the URL // in transit) still knows the old secret. // -// Durability contract: when the caller stages (options.stageRotation), the -// rotate's fresh secret comes from the caller's reservation path and the -// future rotated note is persisted BEFORE the rotate can land - a crash -// after the mint burned the sender's copy then never loses the only secret -// the money answers to. The staged record carries the original k1 as its -// pendingMint.sourceRecoverySecret so wallet recovery can restore the -// received note when the rotate provably never landed. A caller that -// stages MUST allocate; the legacy no-staging path (random secret, no -// checkpoint) remains only for engine tests. +// Durability contract: the rotate's fresh secret comes from the caller's +// reservation path (options.allocateOutputSecrets) and the future rotated +// note is persisted through options.stageRotation BEFORE the rotate can +// land - a crash after the mint burned the sender's copy never loses the +// only secret the money answers to. The staged record carries the original +// k1 as its pendingMint.sourceRecoverySecret so wallet recovery can restore +// the received note when the rotate provably never landed. import { AmbiguousMutationError, @@ -32,6 +30,14 @@ import {requireOutputSecrets} from './allocation' import type {FundOperationOptions} from './shared' import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared' +export class ReceiveRotationStagingRequiredError extends Error { + override readonly name = 'ReceiveRotationStagingRequiredError' + + constructor() { + super('A receive rotation requires a durable stageRotation checkpoint.') + } +} + export type ReceiveBearerOptions = FundOperationOptions & { // the caller's durable allocation path for the rotation's fresh secret - // required when stageRotation is present @@ -54,8 +60,8 @@ export type ReceivedNote = { // original, demoted to unverified since its copy may be burned) // - 'discard': the rotate provably never landed - drop the staged // record; `note` is the original, still verified as received - // - 'none': no rotate was attempted or nothing was staged (unverifiable - // note, or the legacy no-staging path) + // - 'none': no rotate was attempted (the note could not be verified) - + // nothing was staged readonly stage: 'finalize' | 'keep' | 'discard' | 'none' } @@ -73,33 +79,29 @@ export const receiveBearer = async ( if (!note.verified || !note.callback) { return {note, rotated: false, stage: 'none'} } - // staging without allocation is a caller bug: the staged record would - // hold a secret no reservation can account for - const staging = options.stageRotation - const [rotationSecret] = staging - ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(note.url), 1) - : [] // the rotate burns the sender's secret at a fresh one: stage that future // note durably before the mutation can land, or a crash strands the money - if (staging && rotationSecret) { - const staged: NewBearer = { - url: withNewK1(note.url, rotationSecret, note.amount), - callback: note.callback, - amount: note.amount, - verified: false, - pendingMint: { - sourceRecoverySecret: requireNoteK1(note.url), - ...(note.mintPubkey ? {mintPubkey: note.mintPubkey} : {}), - }, - } - await staging(staged) + const staging = options.stageRotation + if (!staging) throw new ReceiveRotationStagingRequiredError() + const [rotationSecret] = await requireOutputSecrets( + options.allocateOutputSecrets, + serverOf(note.url), + 1, + ) + const staged: NewBearer = { + url: withNewK1(note.url, rotationSecret, note.amount), + callback: note.callback, + amount: note.amount, + verified: false, + pendingMint: { + sourceRecoverySecret: requireNoteK1(note.url), + ...(note.mintPubkey ? {mintPubkey: note.mintPubkey} : {}), + }, } - const rotateOptions = rotationSecret - ? {...mutationOptions, randomSecret: () => rotationSecret} - : mutationOptions + await staging(staged) + const rotateOptions = {...mutationOptions, randomSecret: () => rotationSecret} // the disposition the caller applies to its staged record const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => { - if (!staging) return 'none' if (rotated) return 'finalize' return ambiguous ? 'keep' : 'discard' } diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index 1628ae0..80936a5 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -17,7 +17,6 @@ import { NoteUnknownError, PendingNoteError, buildNoteUrl, - defaultRandomSecret, fetchNoteInfo, meltNote, newSecretsOf, @@ -111,6 +110,14 @@ export type TransferResult = { rescuedNote?: NewBearer } +export class TransferMeltCheckpointRequiredError extends Error { + override readonly name = 'TransferMeltCheckpointRequiredError' + + constructor() { + super('A source melt requires a durable onMeltReady checkpoint.') + } +} + export type TransferOptions = { // verify-poll budget - tests shrink this poll?: PollOptions @@ -124,9 +131,9 @@ export type TransferOptions = { // persists the target note before its hash reaches the invoice callback persistOutput: PrepareMintOptions['persistOutput'] // the caller's durable allocation path: the staged target note's secret, - // the source carve's outputs, and the source recovery secret. When - // absent, secrets fall back to random generation (legacy callers only - - // production always allocates). + // the source carve's outputs, and the source recovery secret. Required: + // the wallet's reserved BIP-32 indices are the only source a restart can + // never reuse or lose. allocateOutputSecrets?: OutputSecretAllocator // trusted signing keys the source carve's landed outputs verify against mintSignatureKeys?: MintSignatureKeys @@ -281,11 +288,14 @@ export const transferBetweenMints = async ( // flow never throws again; every outcome carries them const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const k1 = requireNoteK1(carve.note.url) - const [sourceRecoverySecret] = allocateOutputSecrets - ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) - : [(kit.randomSecret ?? defaultRandomSecret)()] + if (!onMeltReady) throw new TransferMeltCheckpointRequiredError() + const [sourceRecoverySecret] = await requireOutputSecrets( + allocateOutputSecrets, + serverOf(carve.note.url), + 1, + ) if (carve.consumed.length === 0) assertFundOwner(options) - await onMeltReady?.(carve, sourceRecoverySecret) + await onMeltReady(carve, sourceRecoverySecret) assertFundOwner(options) try { await meltNote(carve.note.callback, k1, invoice, options) diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index 15eb9a0..9bf22e2 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -496,7 +496,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => { ownerFence, ); } - } catch (error) { + } catch { ownerFence(); options.setAuxiliaryError( 'Pending allocation records could not be cleared. They will be retired later.',