refactor(ops): require durable allocation and checkpoints

This commit is contained in:
2026-09-05 11:59:34 +02:00
parent 8e9c9dcbbc
commit a91ef30498
17 changed files with 356 additions and 104 deletions
+17 -3
View File
@@ -49,6 +49,16 @@ const settleLastInvoice = async (m: Mint): Promise<string> => {
const MINT_PUBKEY = `02${'ab'.repeat(32)}` const MINT_PUBKEY = `02${'ab'.repeat(32)}`
const persistOutput = async (): Promise<void> => undefined const persistOutput = async (): Promise<void> => undefined
// deterministic stand-in for the wallet's BIP-32 allocation path
let allocatedCounter = 0
const allocateOutputSecrets = (_server: string, count: number): Promise<readonly string[]> => {
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: // a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
// node stats under their WIRE names - `nodeCapacity` is msat like every // node stats under their WIRE names - `nodeCapacity` is msat like every
// other amount, named without the suffix on the wire // 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 () => { it("surfaces the mock mint's mint-address node stats through prepareMint", async () => {
const m = await mint() 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 metadata is advertised at the mint-address endpoint itself -
// the payRequest never carried it // the payRequest never carried it
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) 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 // conformance 0.7.0 defaults to plain https links; lnurlw:// is an
// explicit LUD-17 transport form // explicit LUD-17 transport form
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) 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:\/\//) expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)
// and the link is fully usable: settle the invoice, claim the note // 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 // amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}], ['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') expect(prepared.withdrawLink).toBe('https://mint.example/note')
// the mint-address payLink is authoritative - the payRequest came from it // the mint-address payLink is authoritative - the payRequest came from it
expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint') 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, { prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen), fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
persistOutput, persistOutput,
allocateOutputSecrets,
}), }),
).rejects.toThrow(/64-character output commitment/i) ).rejects.toThrow(/64-character output commitment/i)
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) 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 seen: string[] = []
const events: string[] = [] const events: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, { const prepared = await prepareMint('mint@mint.example', 21_000, {
allocateOutputSecrets,
fetch: (input, init) => { fetch: (input, init) => {
const url = const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.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, { prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
persistOutput: () => Promise.reject(new Error('storage full')), persistOutput: () => Promise.reject(new Error('storage full')),
allocateOutputSecrets,
}), }),
).rejects.toThrow(/storage full/) ).rejects.toThrow(/storage full/)
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) 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, { prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
persistOutput, persistOutput,
allocateOutputSecrets,
}), }),
).rejects.toThrow(/64-character output commitment/i) ).rejects.toThrow(/64-character output commitment/i)
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
+10 -10
View File
@@ -6,13 +6,12 @@ import {ensureExactAmount, UnsupportedMultiBatchMergeError} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import {makeBearer, mint, secret} from './ops.testHarness' import {makeBearer, mint, secret} from './ops.testHarness'
const secretSource = (...secrets: string[]): (() => string) => { const allocatedSecrets = (...secrets: string[]): ((server: string, count: number) => Promise<readonly string[]>) => {
let index = 0 let served = false
return () => { return (_server, count) => {
const next = secrets[index] if (served) throw new Error('The test expected a single allocation.')
if (!next) throw new Error('The test exhausted its prepared secrets.') served = true
index += 1 return Promise.resolve(secrets.slice(0, count))
return next
} }
} }
@@ -37,7 +36,7 @@ describe('ensureExactAmount crash safety', () => {
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
fetch: observingFetch, fetch: observingFetch,
randomSecret: secretSource(targetSecret, changeSecret), allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret),
onCarve: (prepared) => { onCarve: (prepared) => {
staged = prepared staged = prepared
}, },
@@ -67,7 +66,7 @@ describe('ensureExactAmount crash safety', () => {
await ensureExactAmount([first, second], 7_000, { await ensureExactAmount([first, second], 7_000, {
fetch: observingFetch, fetch: observingFetch,
randomSecret: secretSource(mergedSecret), allocateOutputSecrets: allocatedSecrets(mergedSecret),
onCarve: (prepared) => { onCarve: (prepared) => {
staged = prepared staged = prepared
}, },
@@ -95,7 +94,7 @@ describe('ensureExactAmount crash safety', () => {
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
fetch: dropSecondMutation, fetch: dropSecondMutation,
mutationRetries: 0, mutationRetries: 0,
randomSecret: secretSource(secret('77'), secret('78')), allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')),
onCarve: () => undefined, onCarve: () => undefined,
}) })
@@ -150,6 +149,7 @@ describe('ensureExactAmount crash safety', () => {
await expect( await expect(
ensureExactAmount([first, second], 7_000, { ensureExactAmount([first, second], 7_000, {
fetch: countingFetch, fetch: countingFetch,
allocateOutputSecrets: allocatedSecrets(secret('7b')),
assertOwner: () => { assertOwner: () => {
ownerChecks += 1 ownerChecks += 1
if (ownerChecks === 3) throw new Error('wallet owner changed') if (ownerChecks === 3) throw new Error('wallet owner changed')
+48 -5
View File
@@ -3,9 +3,21 @@ import {fetchNoteInfo, noteK1, noteSignature} from 'lnurlcash-kit'
import type {Bearer} from './types' import type {Bearer} from './types'
import type {CarveResult} from './ops' import type {CarveResult} from './ops'
import {CarveCheckpointRequiredError, UncertainOutcomeError, ensureExactAmount} from './ops' import {
CarveCheckpointRequiredError,
OutputSecretAllocationRequiredError,
UncertainOutcomeError,
ensureExactAmount,
} from './ops'
import {requiredValue} from './test-utils' 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', () => { describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => { 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 bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) })
expect(result.note.amount).toBe(5_000) expect(result.note.amount).toBe(5_000)
@@ -42,7 +55,7 @@ describe('ensureExactAmount', () => {
const instance = await mint() const instance = await mint()
const first = await makeBearer(instance, secret('03'), 3_000) const first = await makeBearer(instance, secret('03'), 3_000)
const second = await makeBearer(instance, secret('04'), 4_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.note.amount).toBe(7_000)
expect(result.change).toBeUndefined() expect(result.change).toBeUndefined()
expect(result.consumed).toHaveLength(2) expect(result.consumed).toHaveLength(2)
@@ -56,7 +69,7 @@ describe('ensureExactAmount', () => {
const instance = await mint() const instance = await mint()
const first = await makeBearer(instance, secret('05'), 3_000) const first = await makeBearer(instance, secret('05'), 3_000)
const second = await makeBearer(instance, secret('06'), 4_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.note.amount).toBe(5_000)
expect(result.change?.amount).toBe(2_000) expect(result.change?.amount).toBe(2_000)
expect(result.consumed).toHaveLength(2) expect(result.consumed).toHaveLength(2)
@@ -88,7 +101,7 @@ describe('ensureExactAmount', () => {
const instance = await mint({dropAfterMutation: true}) const instance = await mint({dropAfterMutation: true})
const k1 = secret('10') const k1 = secret('10')
const bearer = await makeBearer(instance, k1, 21_000) 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 partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(partK1).not.toBe(k1) expect(partK1).not.toBe(k1)
@@ -118,6 +131,7 @@ describe('ensureExactAmount', () => {
const checkpoints: CarveResult[] = [] const checkpoints: CarveResult[] = []
const failure = await ensureExactAmount([bearer], 5_000, { const failure = await ensureExactAmount([bearer], 5_000, {
fetch: everythingDyingFetch, fetch: everythingDyingFetch,
allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
checkpoints.push(carve) checkpoints.push(carve)
}, },
@@ -136,6 +150,7 @@ describe('ensureExactAmount', () => {
const bearer = await makeBearer(instance, secret('12'), 21_000) const bearer = await makeBearer(instance, secret('12'), 21_000)
const seen: CarveResult[] = [] const seen: CarveResult[] = []
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve)
}, },
@@ -166,6 +181,7 @@ describe('ensureExactAmount', () => {
const retiredIds = new Set<string>() const retiredIds = new Set<string>()
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
// checkpoints identify an output by its secret: the landed phase's // checkpoints identify an output by its secret: the landed phase's
// URL has gained a signature the staged phase could not have had // URL has gained a signature the staged phase could not have had
@@ -194,6 +210,7 @@ describe('ensureExactAmount', () => {
await expect( await expect(
ensureExactAmount([bearer], 5_000, { ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve)
if (carve.consumed.length > 0) throw new Error('final commit failed') 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) const bearer = await makeBearer(instance, secret('14'), 21_000)
await expect( await expect(
ensureExactAmount([bearer], 5_000, { ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets,
onCarve: () => { onCarve: () => {
throw new Error('commit failed') throw new Error('commit failed')
}, },
@@ -303,4 +321,29 @@ describe('ensureExactAmount', () => {
expect(allocations).toBe(0) 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')
})
}) })
+22 -2
View File
@@ -1,10 +1,10 @@
import {describe, expect, it} from 'vitest' import {describe, expect, it} from 'vitest'
import {hashK1, noteK1} from 'lnurlcash-kit' import {hashK1, noteK1} from 'lnurlcash-kit'
import {claimMintedNote, prepareMint} from './ops' import {claimMintedNote, OutputSecretAllocationRequiredError, prepareMint} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import type {Mint} from './ops.testHarness' 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}$/ const HEX32 = /^[0-9a-f]{64}$/
@@ -16,6 +16,7 @@ describe('mint -> claim, comment-bound output', () => {
const instance = await mint({testHooks: true}) const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets,
}) })
expect(prepared.noteSecret).toMatch(HEX32) expect(prepared.noteSecret).toMatch(HEX32)
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) 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 instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets,
}) })
const preimage = await settleLastInvoice(instance) const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
@@ -44,6 +46,7 @@ describe('mint -> claim, comment-bound output', () => {
const instance = await mint({testHooks: true}) const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets,
}) })
await expect( await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), 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 instance = await mint({mintToHash: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets,
}) })
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret))
await settleLastInvoice(instance) await settleLastInvoice(instance)
@@ -83,4 +87,20 @@ describe('mint -> claim, comment-bound output', () => {
expect(prepared.noteSecret).toBe(allocated[0]) expect(prepared.noteSecret).toBe(allocated[0])
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(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)
})
}) })
+44 -5
View File
@@ -8,16 +8,24 @@ import {
rotateNote, rotateNote,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import {claimMintedNote, prepareMint, receiveBearer} from './ops' import {
claimMintedNote,
OutputSecretAllocationRequiredError,
prepareMint,
receiveBearer,
ReceiveRotationStagingRequiredError,
} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import type {NewBearer} from './types' import type {NewBearer} from './types'
import { import {
allocateOutputSecrets,
makeBearer, makeBearer,
mint, mint,
noteUrl, noteUrl,
persistOutput, persistOutput,
secret, secret,
settleLastInvoice, settleLastInvoice,
stageRotation,
} from './ops.testHarness' } from './ops.testHarness'
describe('mint fee', () => { describe('mint fee', () => {
@@ -25,6 +33,7 @@ describe('mint fee', () => {
const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) 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, persistOutput,
allocateOutputSecrets,
}) })
expect(prepared.grossMsat).toBeGreaterThan(100_000) expect(prepared.grossMsat).toBeGreaterThan(100_000)
await settleLastInvoice(instance) await settleLastInvoice(instance)
@@ -105,8 +114,12 @@ describe('receiveBearer', () => {
const instance = await mint() const instance = await mint()
const senderK1 = secret('20') const senderK1 = secret('20')
instance.state.creditNote(senderK1, 21_000) 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.rotated).toBe(true)
expect(received.stage).toBe('finalize')
expect(received.note.amount).toBe(21_000) expect(received.note.amount).toBe(21_000)
expect(received.note.verified).toBe(true) expect(received.note.verified).toBe(true)
const newK1 = requiredValue(noteK1(received.note.url)) const newK1 = requiredValue(noteK1(received.note.url))
@@ -140,8 +153,34 @@ describe('receiveBearer', () => {
const k1 = secret('23') const k1 = secret('23')
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000)
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz')
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( await expect(
PendingNoteError, 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')
}) })
}) })
+41 -2
View File
@@ -1,9 +1,13 @@
import {describe, expect, it} from 'vitest' import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit' import {noteK1} from 'lnurlcash-kit'
import {payWithBearers} from './ops' import {
OutputSecretAllocationRequiredError,
PayReturnCheckpointRequiredError,
payWithBearers,
} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import {makeBearer, mint, secret} from './ops.testHarness' import {allocateOutputSecrets, makeBearer, mint, secret} from './ops.testHarness'
describe('payWithBearers', () => { describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => { 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 bearer = await makeBearer(instance, secret('32'), 50_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
allocateOutputSecrets,
onCarve: () => undefined, onCarve: () => undefined,
}) })
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled')
@@ -67,6 +72,8 @@ describe('payWithBearers', () => {
const bearer = await makeBearer(instance, secret('33'), 21_000) const bearer = await makeBearer(instance, secret('33'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
allocateOutputSecrets,
onReturnReady: () => undefined,
}) })
expect(result.outcome).toBe('failed-funds-returned') expect(result.outcome).toBe('failed-funds-returned')
expect(instance.state.noteState(secret('33'))).toBe('burned') expect(instance.state.noteState(secret('33'))).toBe('burned')
@@ -88,6 +95,7 @@ describe('payWithBearers', () => {
}) })
const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
allocateOutputSecrets,
onReturnReady: async () => { onReturnReady: async () => {
stageStarted?.() stageStarted?.()
await stageGate await stageGate
@@ -106,6 +114,8 @@ describe('payWithBearers', () => {
const bearer = await makeBearer(instance, secret('34'), 21_000) const bearer = await makeBearer(instance, secret('34'), 21_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
allocateOutputSecrets,
onReturnReady: () => undefined,
}) })
expect(result.outcome).toBe('unknown-still-pending') expect(result.outcome).toBe('unknown-still-pending')
expect(instance.state.noteState(secret('34'))).toBe('pending') expect(instance.state.noteState(secret('34'))).toBe('pending')
@@ -125,6 +135,7 @@ describe('payWithBearers', () => {
// through the hook before the melt may start // through the hook before the melt may start
await expect( await expect(
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', { payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
allocateOutputSecrets,
onCarve: () => { onCarve: () => {
throw new Error('commit failed') throw new Error('commit failed')
}, },
@@ -175,4 +186,32 @@ describe('payWithBearers', () => {
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled')
expect(allocations).toBe(0) 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')
})
}) })
+14 -1
View File
@@ -4,7 +4,15 @@ import {noteK1} from 'lnurlcash-kit'
import type {CarveResult} from './ops' import type {CarveResult} from './ops'
import {ensureExactAmount, receiveBearer} from './ops' import {ensureExactAmount, receiveBearer} from './ops'
import {requiredValue} from './test-utils' 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 // 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 // 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 bearer = await makeBearer(instance, k1, 50_000)
const result = await ensureExactAmount([bearer], 25_000, { const result = await ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: () => undefined, onCarve: () => undefined,
}) })
// the split landed on the first (unseen) attempt: the input is burned // 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 b = await makeBearer(instance, secret('62'), 5_000)
const result = await ensureExactAmount([a, b], 25_000, { const result = await ensureExactAmount([a, b], 25_000, {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: () => undefined, onCarve: () => undefined,
}) })
expect(result.consumed).toHaveLength(2) expect(result.consumed).toHaveLength(2)
@@ -55,6 +65,8 @@ describe('mutation retry refusals', () => {
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets,
stageRotation,
}) })
expect(received.rotated).toBe(true) expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000) expect(received.note.amount).toBe(21_000)
@@ -73,6 +85,7 @@ describe('mutation retry refusals', () => {
await expect( await expect(
ensureExactAmount([bearer], 25_000, { ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
checkpoints.push(carve) checkpoints.push(carve)
}, },
+15 -1
View File
@@ -3,7 +3,14 @@ import {noteK1, noteSignature} from 'lnurlcash-kit'
import {ensureExactAmount} from './ops' import {ensureExactAmount} from './ops'
import {requiredValue} from './test-utils' 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' import {fetchNoteInfo} from 'lnurlcash-kit'
// Landed split/merge outputs carry the mint's signature in their URL (sig // 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, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) })
@@ -38,6 +46,7 @@ describe('landed mutation signature verification', () => {
const result = await ensureExactAmount([first, second], 7_000, { const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) })
@@ -56,6 +65,7 @@ describe('landed mutation signature verification', () => {
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) })
@@ -73,6 +83,7 @@ describe('landed mutation signature verification', () => {
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
// only the current key is trusted - the signature under the retired // only the current key is trusted - the signature under the retired
// key must not verify // key must not verify
mintSignatureKeys: () => [instance.state.pubkey], mintSignatureKeys: () => [instance.state.pubkey],
@@ -93,6 +104,7 @@ describe('landed mutation signature verification', () => {
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
// the only trusted key on file belongs to a DIFFERENT mint // the only trusted key on file belongs to a DIFFERENT mint
mintSignatureKeys: () => [stranger.state.pubkey], mintSignatureKeys: () => [stranger.state.pubkey],
}) })
@@ -112,6 +124,7 @@ describe('landed mutation signature verification', () => {
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) })
@@ -133,6 +146,7 @@ describe('landed mutation signature verification', () => {
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets,
// no key source at all: nothing may be trusted blindly // no key source at all: nothing may be trusted blindly
}) })
+2 -2
View File
@@ -1,6 +1,6 @@
import {afterEach, expect} from 'vitest' import {afterEach, expect} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' 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 {sha256} from '@noble/hashes/sha2.js'
import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit' import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit'
@@ -35,7 +35,7 @@ export const allocateOutputSecrets = (
): Promise<readonly string[]> => { ): Promise<readonly string[]> => {
const secrets = Array.from({length: count}, () => { const secrets = Array.from({length: count}, () => {
allocatedSecretCounter += 1 allocatedSecretCounter += 1
return secret(`allocated-${allocatedSecretCounter.toString(16)}`) return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`)))
}) })
return Promise.resolve(secrets) return Promise.resolve(secrets)
} }
+43 -1
View File
@@ -3,10 +3,15 @@ import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {hashK1, noteK1} from 'lnurlcash-kit' import {hashK1, noteK1} from 'lnurlcash-kit'
import {transferBetweenMints as transferBetweenMintsEngine} from './ops' import {transferBetweenMints as transferBetweenMintsEngine} from './ops'
import {
OutputSecretAllocationRequiredError,
TransferMeltCheckpointRequiredError,
} from './ops'
import type {CarveResult, TransferOptions} from './ops' import type {CarveResult, TransferOptions} from './ops'
import type {Bearer} from './types' import type {Bearer} from './types'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import { import {
allocateOutputSecrets,
expectBurned, expectBurned,
makeBearer, makeBearer,
mint, mint,
@@ -20,7 +25,13 @@ const transferBetweenMints = (
amountMsat: number, amountMsat: number,
targetMint: string, targetMint: string,
options: Omit<TransferOptions, 'persistOutput'> = {}, options: Omit<TransferOptions, 'persistOutput'> = {},
) => transferBetweenMintsEngine(bearers, amountMsat, targetMint, {...options, persistOutput}) ) =>
transferBetweenMintsEngine(bearers, amountMsat, targetMint, {
allocateOutputSecrets,
onMeltReady: () => undefined,
...options,
persistOutput,
})
describe('transferBetweenMints', () => { describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
@@ -59,6 +70,37 @@ describe('transferBetweenMints', () => {
expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret) 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 () => { it('moves value to another mint at a wallet-chosen target secret', async () => {
const source = await mint() const source = await mint()
const target = await mint({testHooks: true}) const target = await mint({testHooks: true})
+7 -3
View File
@@ -47,11 +47,15 @@ export type {
ClaimedNote, ClaimedNote,
StagedMintRecovery, StagedMintRecovery,
} from './ops/mint' } from './ops/mint'
export {receiveBearer} from './ops/receiveBearer' export {receiveBearer, ReceiveRotationStagingRequiredError} from './ops/receiveBearer'
export type {ReceiveBearerOptions, ReceivedNote} 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 type {PayOutcome, PayResult, PayOptions} from './ops/pay'
export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer' export {
recoverPendingTransferSource,
transferBetweenMints,
TransferMeltCheckpointRequiredError,
} from './ops/transfer'
export type { export type {
TransferClaimMaterial, TransferClaimMaterial,
TransferOptions, TransferOptions,
+19 -12
View File
@@ -9,7 +9,6 @@
import { import {
AmbiguousMutationError, AmbiguousMutationError,
defaultRandomSecret,
mergeBatches, mergeBatches,
newSecretsOf, newSecretsOf,
noteK1, noteK1,
@@ -63,10 +62,9 @@ export class CarveCheckpointRequiredError extends Error {
export type CarveOptions = FundOperationOptions & { export type CarveOptions = FundOperationOptions & {
// the caller's durable allocation path for the carve's output secrets // the caller's durable allocation path for the carve's output secrets
// (split: target + change; merge: the combined note). When present the // (split: target + change; merge: the combined note). Required for any
// secrets come from the wallet's reserved BIP-32 indices; when absent the // mutating carve: the secrets come from the wallet's reserved BIP-32
// carve falls back to random secrets (legacy callers only - production // indices so a restart can never reuse or lose them.
// always allocates).
readonly allocateOutputSecrets?: OutputSecretAllocator readonly allocateOutputSecrets?: OutputSecretAllocator
} }
@@ -152,9 +150,14 @@ export const ensureExactAmount = async (
if (mergeBatches(base.callback, k1s).length !== 1) { if (mergeBatches(base.callback, k1s).length !== 1) {
throw new UnsupportedMultiBatchMergeError() throw new UnsupportedMultiBatchMergeError()
} }
const [mergeSecret] = options.allocateOutputSecrets if (!options.onCarve) {
? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 1) throw new CarveCheckpointRequiredError()
: [(options.randomSecret ?? defaultRandomSecret)()] }
const [mergeSecret] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(base.url),
1,
)
const staged: NewBearer = { const staged: NewBearer = {
url: withNewK1(base.url, mergeSecret, total), url: withNewK1(base.url, mergeSecret, total),
callback: base.callback, callback: base.callback,
@@ -189,10 +192,14 @@ export const ensureExactAmount = async (
// split path: total above target - one split request across all picked // split path: total above target - one split request across all picked
// k1s, carving the target off and leaving the change as a fresh note // k1s, carving the target off and leaving the change as a fresh note
const randomSecret = options.randomSecret ?? defaultRandomSecret if (!options.onCarve) {
const [partK1, changeK1] = options.allocateOutputSecrets throw new CarveCheckpointRequiredError()
? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 2) }
: [randomSecret(), randomSecret()] const [partK1, changeK1] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(base.url),
2,
)
let partSignature: string | undefined let partSignature: string | undefined
let changeSignature: string | undefined let changeSignature: string | undefined
const stagedNote: NewBearer = { const stagedNote: NewBearer = {
+4 -8
View File
@@ -7,7 +7,6 @@
import { import {
buildNoteUrl, buildNoteUrl,
claimMintedNote as fetchMintClaim, claimMintedNote as fetchMintClaim,
defaultRandomSecret,
fetchMintAddress, fetchMintAddress,
fetchPayRequest, fetchPayRequest,
grossUpForMintFee, grossUpForMintFee,
@@ -56,10 +55,9 @@ export type PrepareMintOptions = FundOperationOptions & {
readonly grossMsat: number readonly grossMsat: number
readonly server: string readonly server: string
}) => void | Promise<void> }) => void | Promise<void>
// the caller's durable allocation path for the staged note's secret. // the caller's durable allocation path for the staged note's secret -
// When present the secret comes from the wallet's reserved BIP-32 // required: the wallet's reserved BIP-32 indices are the only source a
// indices; when absent a random secret is generated (legacy callers // restart can never reuse or lose
// only - production always allocates).
readonly allocateOutputSecrets?: OutputSecretAllocator readonly allocateOutputSecrets?: OutputSecretAllocator
} }
@@ -108,9 +106,7 @@ export const prepareMint = async (
} }
const server = serverOf(payUrl) const server = serverOf(payUrl)
await beforePersist?.({grossMsat, server}) await beforePersist?.({grossMsat, server})
const [noteSecret] = options.allocateOutputSecrets const [noteSecret] = await requireOutputSecrets(options.allocateOutputSecrets, server, 1)
? await requireOutputSecrets(options.allocateOutputSecrets, server, 1)
: [(kitOptions.randomSecret ?? defaultRandomSecret)()]
const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey
const staged: NewBearer = { const staged: NewBearer = {
url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
+16 -7
View File
@@ -8,7 +8,6 @@ import {
NoteSpentError, NoteSpentError,
PendingNoteError, PendingNoteError,
decodeBolt11AmountMsat, decodeBolt11AmountMsat,
defaultRandomSecret,
fetchPayRequest, fetchPayRequest,
isBolt11Invoice, isBolt11Invoice,
meltNote, meltNote,
@@ -49,6 +48,14 @@ export type PayResult = {
rescuedNote?: NewBearer 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 = { export type PayOptions = {
// required when `input` is a Lightning Address / LNURL-pay (a bolt11 // required when `input` is a Lightning Address / LNURL-pay (a bolt11
// carries its own amount) // carries its own amount)
@@ -64,8 +71,7 @@ export type PayOptions = {
onCarve?: FundOperationOptions['onCarve'] onCarve?: FundOperationOptions['onCarve']
// the caller's durable allocation path: the carve's output secrets and - // the caller's durable allocation path: the carve's output secrets and -
// only when the return classification actually needs one - the recovery // only when the return classification actually needs one - the recovery
// secret. When absent, secrets fall back to random generation (legacy // secret. Required for any mutating carve or return classification.
// callers only - production always allocates).
allocateOutputSecrets?: OutputSecretAllocator allocateOutputSecrets?: OutputSecretAllocator
// trusted signing keys the carve's landed outputs verify against (see // trusted signing keys the carve's landed outputs verify against (see
// FundOperationOptions.mintSignatureKeys) // FundOperationOptions.mintSignatureKeys)
@@ -174,10 +180,13 @@ export const payWithBearers = async (
// The verify budget ran out. Journal the exact output secret before the // The verify budget ran out. Journal the exact output secret before the
// classification rotate reaches the mint, so a crash can recover either // classification rotate reaches the mint, so a crash can recover either
// the returned source or the rotated replacement. // the returned source or the rotated replacement.
const [recoverySecret] = allocateOutputSecrets if (!onReturnReady) throw new PayReturnCheckpointRequiredError()
? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) const [recoverySecret] = await requireOutputSecrets(
: [(kit.randomSecret ?? defaultRandomSecret)()] allocateOutputSecrets,
await onReturnReady?.(carve, recoverySecret) serverOf(carve.note.url),
1,
)
await onReturnReady(carve, recoverySecret)
try { try {
const rotated = await rotateNote(carve.note.callback, k1, { const rotated = await rotateNote(carve.note.callback, k1, {
...options, ...options,
+25 -23
View File
@@ -4,15 +4,13 @@
// immediately, since the previous holder (and anything that logged the URL // immediately, since the previous holder (and anything that logged the URL
// in transit) still knows the old secret. // in transit) still knows the old secret.
// //
// Durability contract: when the caller stages (options.stageRotation), the // Durability contract: the rotate's fresh secret comes from the caller's
// rotate's fresh secret comes from the caller's reservation path and the // reservation path (options.allocateOutputSecrets) and the future rotated
// future rotated note is persisted BEFORE the rotate can land - a crash // note is persisted through options.stageRotation BEFORE the rotate can
// after the mint burned the sender's copy then never loses the only secret // land - a crash after the mint burned the sender's copy never loses the
// the money answers to. The staged record carries the original k1 as its // only secret the money answers to. The staged record carries the original
// pendingMint.sourceRecoverySecret so wallet recovery can restore the // k1 as its pendingMint.sourceRecoverySecret so wallet recovery can restore
// received note when the rotate provably never landed. A caller that // the received note when the rotate provably never landed.
// stages MUST allocate; the legacy no-staging path (random secret, no
// checkpoint) remains only for engine tests.
import { import {
AmbiguousMutationError, AmbiguousMutationError,
@@ -32,6 +30,14 @@ import {requireOutputSecrets} from './allocation'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, probeMutationOutput, withMutationSafety} 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 & { export type ReceiveBearerOptions = FundOperationOptions & {
// the caller's durable allocation path for the rotation's fresh secret - // the caller's durable allocation path for the rotation's fresh secret -
// required when stageRotation is present // required when stageRotation is present
@@ -54,8 +60,8 @@ export type ReceivedNote = {
// original, demoted to unverified since its copy may be burned) // original, demoted to unverified since its copy may be burned)
// - 'discard': the rotate provably never landed - drop the staged // - 'discard': the rotate provably never landed - drop the staged
// record; `note` is the original, still verified as received // record; `note` is the original, still verified as received
// - 'none': no rotate was attempted or nothing was staged (unverifiable // - 'none': no rotate was attempted (the note could not be verified) -
// note, or the legacy no-staging path) // nothing was staged
readonly stage: 'finalize' | 'keep' | 'discard' | 'none' readonly stage: 'finalize' | 'keep' | 'discard' | 'none'
} }
@@ -73,15 +79,15 @@ export const receiveBearer = async (
if (!note.verified || !note.callback) { if (!note.verified || !note.callback) {
return {note, rotated: false, stage: 'none'} 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 // 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 // note durably before the mutation can land, or a crash strands the money
if (staging && rotationSecret) { const staging = options.stageRotation
if (!staging) throw new ReceiveRotationStagingRequiredError()
const [rotationSecret] = await requireOutputSecrets(
options.allocateOutputSecrets,
serverOf(note.url),
1,
)
const staged: NewBearer = { const staged: NewBearer = {
url: withNewK1(note.url, rotationSecret, note.amount), url: withNewK1(note.url, rotationSecret, note.amount),
callback: note.callback, callback: note.callback,
@@ -93,13 +99,9 @@ export const receiveBearer = async (
}, },
} }
await staging(staged) await staging(staged)
} const rotateOptions = {...mutationOptions, randomSecret: () => rotationSecret}
const rotateOptions = rotationSecret
? {...mutationOptions, randomSecret: () => rotationSecret}
: mutationOptions
// the disposition the caller applies to its staged record // the disposition the caller applies to its staged record
const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => { const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => {
if (!staging) return 'none'
if (rotated) return 'finalize' if (rotated) return 'finalize'
return ambiguous ? 'keep' : 'discard' return ambiguous ? 'keep' : 'discard'
} }
+18 -8
View File
@@ -17,7 +17,6 @@ import {
NoteUnknownError, NoteUnknownError,
PendingNoteError, PendingNoteError,
buildNoteUrl, buildNoteUrl,
defaultRandomSecret,
fetchNoteInfo, fetchNoteInfo,
meltNote, meltNote,
newSecretsOf, newSecretsOf,
@@ -111,6 +110,14 @@ export type TransferResult = {
rescuedNote?: NewBearer rescuedNote?: NewBearer
} }
export class TransferMeltCheckpointRequiredError extends Error {
override readonly name = 'TransferMeltCheckpointRequiredError'
constructor() {
super('A source melt requires a durable onMeltReady checkpoint.')
}
}
export type TransferOptions = { export type TransferOptions = {
// verify-poll budget - tests shrink this // verify-poll budget - tests shrink this
poll?: PollOptions poll?: PollOptions
@@ -124,9 +131,9 @@ export type TransferOptions = {
// persists the target note before its hash reaches the invoice callback // persists the target note before its hash reaches the invoice callback
persistOutput: PrepareMintOptions['persistOutput'] persistOutput: PrepareMintOptions['persistOutput']
// the caller's durable allocation path: the staged target note's secret, // the caller's durable allocation path: the staged target note's secret,
// the source carve's outputs, and the source recovery secret. When // the source carve's outputs, and the source recovery secret. Required:
// absent, secrets fall back to random generation (legacy callers only - // the wallet's reserved BIP-32 indices are the only source a restart can
// production always allocates). // never reuse or lose.
allocateOutputSecrets?: OutputSecretAllocator allocateOutputSecrets?: OutputSecretAllocator
// trusted signing keys the source carve's landed outputs verify against // trusted signing keys the source carve's landed outputs verify against
mintSignatureKeys?: MintSignatureKeys mintSignatureKeys?: MintSignatureKeys
@@ -281,11 +288,14 @@ export const transferBetweenMints = async (
// flow never throws again; every outcome carries them // flow never throws again; every outcome carries them
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
const k1 = requireNoteK1(carve.note.url) const k1 = requireNoteK1(carve.note.url)
const [sourceRecoverySecret] = allocateOutputSecrets if (!onMeltReady) throw new TransferMeltCheckpointRequiredError()
? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) const [sourceRecoverySecret] = await requireOutputSecrets(
: [(kit.randomSecret ?? defaultRandomSecret)()] allocateOutputSecrets,
serverOf(carve.note.url),
1,
)
if (carve.consumed.length === 0) assertFundOwner(options) if (carve.consumed.length === 0) assertFundOwner(options)
await onMeltReady?.(carve, sourceRecoverySecret) await onMeltReady(carve, sourceRecoverySecret)
assertFundOwner(options) assertFundOwner(options)
try { try {
await meltNote(carve.note.callback, k1, invoice, options) await meltNote(carve.note.callback, k1, invoice, options)
+1 -1
View File
@@ -496,7 +496,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
ownerFence, ownerFence,
); );
} }
} catch (error) { } catch {
ownerFence(); ownerFence();
options.setAuxiliaryError( options.setAuxiliaryError(
'Pending allocation records could not be cleared. They will be retired later.', 'Pending allocation records could not be cleared. They will be retired later.',