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 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:
// 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)
+10 -10
View File
@@ -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<readonly string[]>) => {
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')
+48 -5
View File
@@ -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<string>()
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')
})
})
+22 -2
View File
@@ -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)
})
})
+44 -5
View File
@@ -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')
})
})
+41 -2
View File
@@ -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')
})
})
+14 -1
View File
@@ -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)
},
+15 -1
View File
@@ -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
})
+2 -2
View File
@@ -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<readonly string[]> => {
const secrets = Array.from({length: count}, () => {
allocatedSecretCounter += 1
return secret(`allocated-${allocatedSecretCounter.toString(16)}`)
return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`)))
})
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 {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<TransferOptions, 'persistOutput'> = {},
) => 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})
+7 -3
View File
@@ -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,
+19 -12
View File
@@ -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 = {
+4 -8
View File
@@ -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<void>
// 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),
+16 -7
View File
@@ -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,
+35 -33
View File
@@ -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'
}
+18 -8
View File
@@ -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)
+1 -1
View File
@@ -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.',