diff --git a/src/composables/useReceiveTokenDialog.ts b/src/composables/useReceiveTokenDialog.ts index 3f53601..8b5ffb2 100644 --- a/src/composables/useReceiveTokenDialog.ts +++ b/src/composables/useReceiveTokenDialog.ts @@ -8,7 +8,7 @@ import { } from 'lnurlcash-kit'; import { receiveBearer } from '@/lnurlcash/ops'; -import type { NewBearer } from '@/lnurlcash/types'; +import type { Bearer } from '@/lnurlcash/types'; import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units'; import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; import { useMintsStore } from '@/stores/mints'; @@ -95,18 +95,42 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok if (busy.value || value === '') return; busy.value = true; clearError(); + let fundOperation: ReturnType | undefined; + let staged: Bearer | undefined; try { - const ownerFence = wallet.captureOwnerFence(); + fundOperation = wallet.beginFundOperation(); + const ownerFence = fundOperation.ownerFence; + // the rotate's fresh secret is reserved and its future note staged + // before the mutation can land - a crash mid-rotate never loses the + // money (wallet recovery reconciles the staged record) const claimed = await receiveBearer(value, wallet.bearers, { assertOwner: ownerFence, + allocateOutputSecrets: (server, count) => + wallet.allocateOutputSecrets(server, count, ownerFence), + stageRotation: async (note) => { + [staged] = await wallet.addBearers([note], ownerFence); + }, }); const note = claimed.note; const server = new URL(note.url).host; const wasTrusted = mints.isTrusted(server); - const notes: NewBearer[] = claimed.possibleCopy ? [note, claimed.possibleCopy] : [note]; let trustWarning = ''; try { - await wallet.addBearers(notes, ownerFence); + if (claimed.stage === 'finalize') { + // the rotate landed: the staged record becomes the confirmed note + if (!staged) throw new Error('The rotated note was not staged.'); + await wallet.finalizeStagedMintOutput(staged.id, note, ownerFence); + } else if (claimed.stage === 'discard') { + // the rotate provably never landed: drop the worthless stage and + // keep the original (exposed - see rotationWarning) + if (staged) await wallet.removeNote(staged.id, ownerFence); + await wallet.addBearers([note], ownerFence); + } else { + // 'keep': the staged record is the possible rotated copy; the + // original is tracked unverified alongside it. 'none': no rotate + // was attempted - the note goes in as received. + await wallet.addBearers([note], ownerFence); + } } catch (error) { if (!(error instanceof TrustedMintPostCommitError)) throw error; trustWarning = error.message; @@ -132,8 +156,15 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok trustPrompt.openTrust(server, note.mintPubkey); } } catch (error) { + // a definitive failure thrown after staging leaves the staged record + // behind - drop it best-effort (wallet recovery restores the original + // note from it if this discard itself is impossible right now) + if (staged && fundOperation) { + await wallet.removeNote(staged.id, fundOperation.ownerFence).catch(() => undefined); + } classifyError(error instanceof Error ? error : new Error(String(error))); } finally { + fundOperation?.complete(); busy.value = false; } }; diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts index 897fa9f..9f2a30d 100644 --- a/src/lnurlcash/ops.carve.cases.ts +++ b/src/lnurlcash/ops.carve.cases.ts @@ -250,4 +250,57 @@ describe('ensureExactAmount', () => { ).rejects.toThrow(/commit failed/) expect(instance.state.noteState(secret('14'))).toBe('outstanding') }) + + it('draws the split outputs from the caller allocation path, exactly two', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('80'), 21_000) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('81'), secret('82')] + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + }) + + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 2}]) + expect(noteK1(result.note.url)).toBe(allocated[0]) + expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]) + }) + + it('draws the merge output from the caller allocation path, exactly one', async () => { + const instance = await mint() + const first = await makeBearer(instance, secret('83'), 3_000) + const second = await makeBearer(instance, secret('84'), 4_000) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('85')] + + const result = await ensureExactAmount([first, second], 7_000, { + onCarve: () => undefined, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + }) + + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) + expect(noteK1(result.note.url)).toBe(allocated[0]) + }) + + it('allocates nothing when one note already holds the exact amount', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('86'), 21_000) + let allocations = 0 + + await ensureExactAmount([bearer], 21_000, { + allocateOutputSecrets: () => { + allocations += 1 + return Promise.resolve([]) + }, + }) + + expect(allocations).toBe(0) + }) }) diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts index 886f165..16ce2b0 100644 --- a/src/lnurlcash/ops.mint-named.cases.ts +++ b/src/lnurlcash/ops.mint-named.cases.ts @@ -4,7 +4,7 @@ import {hashK1, noteK1} from 'lnurlcash-kit' import {claimMintedNote, prepareMint} from './ops' import {requiredValue} from './test-utils' import type {Mint} from './ops.testHarness' -import {mint, persistOutput, settleLastInvoice} from './ops.testHarness' +import {mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness' const HEX32 = /^[0-9a-f]{64}$/ @@ -65,4 +65,22 @@ describe('mint -> claim, comment-bound output', () => { expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32) }) + + it('draws the staged note secret from the caller allocation path', async () => { + const instance = await mint({testHooks: true}) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('90')] + + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + }) + + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) + expect(prepared.noteSecret).toBe(allocated[0]) + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0]))) + }) }) diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts index 6c0635d..5c5b989 100644 --- a/src/lnurlcash/ops.mint-receive.cases.ts +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -10,6 +10,7 @@ import { import {claimMintedNote, prepareMint, receiveBearer} from './ops' import {requiredValue} from './test-utils' +import type {NewBearer} from './types' import { makeBearer, mint, @@ -40,6 +41,66 @@ describe('mint fee', () => { }) describe('receiveBearer', () => { + it('stages the rotation output before the rotate can reach the mint', async () => { + const instance = await mint() + const senderK1 = secret('94') + instance.state.creditNote(senderK1, 21_000) + const rotationSecret = secret('95') + let staged: NewBearer | undefined + const observingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) expect(staged).toBeDefined() + return fetch(input, init) + } + + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { + fetch: observingFetch, + allocateOutputSecrets: () => Promise.resolve([rotationSecret]), + stageRotation: (note) => { + staged = note + }, + }) + + expect(received.stage).toBe('finalize') + expect(received.rotated).toBe(true) + expect(noteK1(received.note.url)).toBe(rotationSecret) + expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret) + expect(requiredValue(staged).verified).toBe(false) + expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1) + expect(instance.state.noteState(senderK1)).toBe('burned') + expect(instance.state.noteState(rotationSecret)).toBe('outstanding') + }) + + it('reports discard when the rotate provably never landed', async () => { + const instance = await mint() + const senderK1 = secret('96') + instance.state.creditNote(senderK1, 21_000) + const rotationSecret = secret('97') + let staged: NewBearer | undefined + const failingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset')) + return fetch(input, init) + } + + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { + fetch: failingFetch, + allocateOutputSecrets: () => Promise.resolve([rotationSecret]), + stageRotation: (note) => { + staged = note + }, + }) + + expect(received.stage).toBe('discard') + expect(received.rotated).toBe(false) + expect(received.note.verified).toBe(true) + expect(received.rotationError).toBeDefined() + // nothing landed: both the sender's copy and the would-be output live on + expect(instance.state.noteState(senderK1)).toBe('outstanding') + expect(instance.state.noteState(rotationSecret)).toBeNull() + expect(staged).toBeDefined() + }) + it("verifies an incoming note and rotates it, burning the sender's copy", async () => { const instance = await mint() const senderK1 = secret('20') diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts index bb770e6..04105ab 100644 --- a/src/lnurlcash/ops.pay.cases.ts +++ b/src/lnurlcash/ops.pay.cases.ts @@ -134,4 +134,45 @@ describe('payWithBearers', () => { expect(instance.state.noteState(secret('36'))).toBe('outstanding') expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) }) + + it('draws the return recovery secret from the caller allocation path', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('8a'), 21_000) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('8b')] + const stagedSecrets: string[] = [] + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + onReturnReady: (_carve, recoverySecret) => { + stagedSecrets.push(recoverySecret) + }, + }) + + expect(result.outcome).toBe('failed-funds-returned') + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) + expect(stagedSecrets).toEqual(allocated) + expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]) + }) + + it('allocates nothing while the melt settles cleanly', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('8c'), 21_000) + let allocations = 0 + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + allocateOutputSecrets: () => { + allocations += 1 + return Promise.resolve([]) + }, + }) + + expect(result.outcome).toBe('settled') + expect(allocations).toBe(0) + }) }) diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index 2b4c281..4796a79 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -26,6 +26,24 @@ export const secret = (seed: string): string => export const persistOutput = async (): Promise => undefined +// deterministic stand-in for the wallet's BIP-32 allocation path: answers +// every reservation with fresh unique secrets, never reusing one +let allocatedSecretCounter = 0 +export const allocateOutputSecrets = ( + _server: string, + count: number, +): Promise => { + const secrets = Array.from({length: count}, () => { + allocatedSecretCounter += 1 + return secret(`allocated-${allocatedSecretCounter.toString(16)}`) + }) + return Promise.resolve(secrets) +} + +// the receive-rotation staging checkpoint - engine tests keep no wallet, +// so staging is a recorded no-op for them +export const stageRotation = async (): Promise => undefined + // the trusted signing keys a production caller would serve from the // trusted-mint registry: the mock mint's current key plus any it retired export const signatureKeysFor = diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 7f70578..2c944c6 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -25,6 +25,40 @@ const transferBetweenMints = ( describe('transferBetweenMints', () => { const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + it('draws the target and source secrets from the caller allocation path', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const bearer = await makeBearer(source, secret('91'), 21_000) + const targetSecret = secret('92') + const sourceRecoverySecret = secret('93') + const allocations: Array<{server: string; count: number}> = [] + const meltReadiness: string[] = [] + + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret] + return Promise.resolve(secrets.slice(0, count)) + }, + onMeltReady: (_carve, recovery) => { + meltReadiness.push(recovery) + }, + }) + await settleWhenRequested(target) + const result = await pending + + expect(result.outcome).toBe('settled') + // the staged target note first, then the source recovery secret - an + // exact source carve allocates nothing + expect(allocations).toEqual([ + {server: `127.0.0.1:${target.port}`, count: 1}, + {server: `127.0.0.1:${source.port}`, count: 1}, + ]) + expect(meltReadiness).toEqual([sourceRecoverySecret]) + expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret) + }) + it('moves value to another mint at a wallet-chosen target secret', async () => { const source = await mint() const target = await mint({testHooks: true}) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index bc2efe9..eaa2d83 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -27,12 +27,14 @@ export {UncertainOutcomeError} from './ops/shared' export type {FundOperationOptions, PollOptions} from './ops/shared' +export {OutputSecretAllocationRequiredError} from './ops/allocation' +export type {OutputSecretAllocator} from './ops/allocation' export { CarveCheckpointRequiredError, ensureExactAmount, UnsupportedMultiBatchMergeError, } from './ops/carve' -export type {CarveResult} from './ops/carve' +export type {CarveOptions, CarveResult} from './ops/carve' export { MintedNoteSpentError, prepareMint, @@ -46,6 +48,7 @@ export type { StagedMintRecovery, } from './ops/mint' export {receiveBearer} from './ops/receiveBearer' +export type {ReceiveBearerOptions, ReceivedNote} from './ops/receiveBearer' export {payWithBearers} from './ops/pay' export type {PayOutcome, PayResult, PayOptions} from './ops/pay' export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer' diff --git a/src/lnurlcash/ops/allocation.ts b/src/lnurlcash/ops/allocation.ts new file mode 100644 index 0000000..6ca95bb --- /dev/null +++ b/src/lnurlcash/ops/allocation.ts @@ -0,0 +1,43 @@ +// Output-secret allocation: a mint-touching operation never invents the +// secrets its outputs will live at. It asks the caller's durable allocation +// path (the wallet's BIP-32 counter reservation, see +// stores/walletFunds.ts) for exactly the secrets it is about to stage, at +// the moment the target server is known - before any hash of them can +// reach a mint. The reservation commit (counter bump plus encrypted +// journal record) is what makes a crash between staging and wire safe: +// indices may be burned, never reused, and the staged secrets are +// recoverable from the funds document. +// +// The ops layer stays framework-free: this is just the option type and the +// point-of-use requirement. Tests inject deterministic allocators; +// production callers serve reserveCashIndices-backed secrets. + +export type OutputSecretAllocator = (server: string, count: number) => Promise + +export class OutputSecretAllocationRequiredError extends Error { + override readonly name = 'OutputSecretAllocationRequiredError' + + constructor() { + super('This operation requires durably allocated output secrets.') + } +} + +// The one place secrets enter an operation: exactly `count` fresh secrets +// for `server`, or a rejection before anything is staged or sent. A +// short/long/wrong-shaped answer is a caller bug - fail the operation +// rather than stage a note at an unmappable secret. +export const requireOutputSecrets = async ( + allocate: OutputSecretAllocator | undefined, + server: string, + count: number, +): Promise => { + if (!allocate) throw new OutputSecretAllocationRequiredError() + const secrets = await allocate(server, count) + if ( + secrets.length !== count || + secrets.some((secret) => typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) + ) { + throw new Error('The output secret allocation did not match the request.') + } + return secrets +} diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index 3c107c9..9cfee6f 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -20,6 +20,8 @@ import { withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import {mergeAmbiguitySafe} from './carveRecovery' import type {FundOperationOptions} from './shared' import { @@ -59,6 +61,15 @@ 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). + readonly allocateOutputSecrets?: OutputSecretAllocator +} + // Selection: only notes that can actually take part - verified (callback // known), not locally spent, holding a real k1 (device-backed mirrors are // excluded; the ops engine cannot mutate a secret it doesn't hold). Notes @@ -77,7 +88,7 @@ export class CarveCheckpointRequiredError extends Error { export const ensureExactAmount = async ( bearers: Bearer[], amountMsat: number, - options: FundOperationOptions = {}, + options: CarveOptions = {}, ): Promise => { if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') @@ -141,7 +152,9 @@ export const ensureExactAmount = async ( if (mergeBatches(base.callback, k1s).length !== 1) { throw new UnsupportedMultiBatchMergeError() } - const mergeSecret = (options.randomSecret ?? defaultRandomSecret)() + const [mergeSecret] = options.allocateOutputSecrets + ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 1) + : [(options.randomSecret ?? defaultRandomSecret)()] const staged: NewBearer = { url: withNewK1(base.url, mergeSecret, total), callback: base.callback, @@ -177,8 +190,9 @@ 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 = randomSecret() - const changeK1 = randomSecret() + const [partK1, changeK1] = options.allocateOutputSecrets + ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 2) + : [randomSecret(), randomSecret()] let partSignature: string | undefined let changeSignature: string | undefined const stagedNote: NewBearer = { diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 4869b0c..6d9c245 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -23,6 +23,8 @@ import { import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {ceilMsatToSat} from '../units' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' import {assertFundOwner, pollUntil, withMutationSafety} from './shared' @@ -54,6 +56,11 @@ export type PrepareMintOptions = FundOperationOptions & { readonly grossMsat: number readonly server: string }) => void | Promise + // the caller's durable allocation path for the staged note's secret. + // When present the secret comes from the wallet's reserved BIP-32 + // indices; when absent a random secret is generated (legacy callers + // only - production always allocates). + readonly allocateOutputSecrets?: OutputSecretAllocator } // Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover @@ -101,7 +108,9 @@ export const prepareMint = async ( } const server = serverOf(payUrl) await beforePersist?.({grossMsat, server}) - const noteSecret = (kitOptions.randomSecret ?? defaultRandomSecret)() + const [noteSecret] = options.allocateOutputSecrets + ? await requireOutputSecrets(options.allocateOutputSecrets, server, 1) + : [(kitOptions.randomSecret ?? defaultRandomSecret)()] const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey const staged: NewBearer = { url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index 2b67feb..64af71c 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -17,14 +17,17 @@ import { resolveLnurlInput, rotateNote, sameInvoice, + serverOf, withNewK1, } from 'lnurlcash-kit' import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {PollOptions} from './shared' -import type {FundOperationOptions} from './shared' +import type {FundOperationOptions, MintSignatureKeys} from './shared' import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared' export type PayOutcome = @@ -59,6 +62,14 @@ export type PayOptions = { // so an abort mid-wait never strands the carve (see // FundOperationOptions.onCarve) 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). + allocateOutputSecrets?: OutputSecretAllocator + // trusted signing keys the carve's landed outputs verify against (see + // FundOperationOptions.mintSignatureKeys) + mintSignatureKeys?: MintSignatureKeys // Called before the classification rotate puts its chosen output secret // on the wire. Production callers durably link it to the carved source. onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise @@ -77,7 +88,7 @@ export type PayOptions = { export const payWithBearers = async ( bearers: Bearer[], input: string, - {amountMsat, poll = {}, kit = {}, assertOwner, onCarve, onReturnReady}: PayOptions = {}, + {amountMsat, poll = {}, kit = {}, assertOwner, onCarve, allocateOutputSecrets, mintSignatureKeys, onReturnReady}: PayOptions = {}, ): Promise => { // the forced mutation policy (see shared.ts) covers the carve, the melt, // and the classification rotate regardless of caller kit options @@ -85,6 +96,7 @@ export const payWithBearers = async ( ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), + ...(mintSignatureKeys ? {mintSignatureKeys} : {}), }) let invoice: string let amount: number @@ -118,7 +130,10 @@ export const payWithBearers = async ( amount = amountMsat } - const carve = await ensureExactAmount(bearers, amount, options) + const carve = await ensureExactAmount(bearers, amount, { + ...options, + ...(allocateOutputSecrets ? {allocateOutputSecrets} : {}), + }) const k1 = requireNoteK1(carve.note.url) if (carve.consumed.length === 0) assertFundOwner(options) let melt: MeltResult @@ -159,7 +174,9 @@ 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 = kit.randomSecret?.() ?? defaultRandomSecret() + const [recoverySecret] = allocateOutputSecrets + ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) + : [(kit.randomSecret ?? defaultRandomSecret)()] await onReturnReady?.(carve, recoverySecret) try { const rotated = await rotateNote(carve.note.callback, k1, { diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index 298aa38..3d9a9d9 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -3,6 +3,16 @@ // bech32, lnurlw://), verify it with the issuing service, then rotate // 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. import { AmbiguousMutationError, @@ -11,32 +21,92 @@ import { PendingNoteError, newSecretsOf, probeBurnedNote, + requireNoteK1, + serverOf, withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {receiveNote, secureReceivedNote} from '../receive' -import type {ClaimedNote} from './mint' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {FundOperationOptions} from './shared' import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared' +export type ReceiveBearerOptions = FundOperationOptions & { + // the caller's durable allocation path for the rotation's fresh secret - + // required when stageRotation is present + readonly allocateOutputSecrets?: OutputSecretAllocator + // durably persist the future rotated note (unverified, pendingMint + // carrying the original k1 as sourceRecoverySecret) before the rotate + // reaches the mint. The record's fate is decided by the result's + // `stage` disposition. + readonly stageRotation?: (staged: NewBearer) => void | Promise +} + +export type ReceivedNote = { + readonly note: NewBearer + readonly rotated: boolean + readonly rotationError?: string + // how the caller disposes the staged rotation output: + // - 'finalize': the rotate landed - replace the staged record with `note` + // - 'keep': the outcome is genuinely ambiguous - the staged record IS + // the possible rotated copy, kept unverified alongside `note` (the + // 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) + readonly stage: 'finalize' | 'keep' | 'discard' | 'none' +} + // NoteSpentError / NoteUnknownError / PendingNoteError from the service are // definitive and propagate; an unreachable service still yields the note, // unverified, at the sender's declared amount. export const receiveBearer = async ( input: string, existing: Bearer[], - options: FundOperationOptions = {}, -): Promise => { + options: ReceiveBearerOptions = {}, +): Promise => { // the forced mutation policy (see shared.ts) covers the rotate below const mutationOptions = withMutationSafety(options) const note = await receiveNote(input, existing) if (!note.verified || !note.callback) { - return {note, rotated: false} + 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 rotateOptions = rotationSecret + ? {...mutationOptions, randomSecret: () => rotationSecret} + : mutationOptions + // 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' } try { assertFundOwner(options) - const rotatedUrl = await secureReceivedNote(note, mutationOptions) - return {note: {...note, url: rotatedUrl}, rotated: true} + const rotatedUrl = await secureReceivedNote(note, rotateOptions) + return {note: {...note, url: rotatedUrl}, rotated: true, stage: disposition(true, false)} } catch (err) { // A classified rotate refusal can still be a LANDED rotate: the // callback is a GET and HTTP stacks retry GETs, so the service may @@ -51,21 +121,16 @@ export const receiveBearer = async ( return { note: {...note, url: withNewK1(note.url, carried[0], note.amount)}, rotated: true, + stage: disposition(true, false), } } if (outcome === 'unknown') { - const possibleCopy: NewBearer = { - url: withNewK1(note.url, carried[0], note.amount), - callback: note.callback, - amount: note.amount, - verified: false, - } - if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey return { - // the sender's copy may be burned - it cannot stay verified + // the sender's copy may be burned - it cannot stay verified; the + // staged record is the possible rotated copy note: {...note, verified: false}, rotated: false, - possibleCopy, + stage: disposition(false, true), rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`, } } @@ -91,30 +156,26 @@ export const receiveBearer = async ( url: withNewK1(note.url, err.newSecrets[0], note.amount), }, rotated: true, + stage: disposition(true, false), } } if (outcome === 'unknown') { - const possibleCopy: NewBearer = { - url: withNewK1(note.url, err.newSecrets[0], note.amount), - callback: note.callback, - amount: note.amount, - verified: false, - } - if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey return { note, rotated: false, - possibleCopy, + stage: disposition(false, true), rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`, } } } // a failed rotate never fails the receive - the note is money as it - // is; the caller warns that it must be treated as exposed + // is; the caller warns that it must be treated as exposed. The rotate + // provably never landed, so the staged record is worthless. return { note, rotated: false, rotationError: err instanceof Error ? err.message : String(err), + stage: disposition(false, false), } } } diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index cc910b6..1628ae0 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -29,12 +29,14 @@ import { } from 'lnurlcash-kit' import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint' import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint' import type {PollOptions} from './shared' -import type {FundOperationOptions} from './shared' +import type {FundOperationOptions, MintSignatureKeys} from './shared' import {assertFundOwner, withMutationSafety} from './shared' export type TransferOutcome = @@ -116,11 +118,18 @@ export type TransferOptions = { kit?: LnurlcashOptions assertOwner?: () => void // carve checkpoint - committed before the melt and the target - // settlement wait, so an abort mid-wait never strands the carve (see + // settlement wait, so an abort mid-wait can never strand the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve'] // 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). + allocateOutputSecrets?: OutputSecretAllocator + // trusted signing keys the source carve's landed outputs verify against + mintSignatureKeys?: MintSignatureKeys // Last durable checkpoint before the source melt can become irreversible. onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise } @@ -219,12 +228,13 @@ export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, - {poll = {}, kit = {}, assertOwner, onCarve, persistOutput, onMeltReady}: TransferOptions, + {poll = {}, kit = {}, assertOwner, onCarve, persistOutput, allocateOutputSecrets, mintSignatureKeys, onMeltReady}: TransferOptions, ): Promise => { const options: FundOperationOptions = withMutationSafety({ ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), + ...(mintSignatureKeys ? {mintSignatureKeys} : {}), }) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') @@ -235,6 +245,7 @@ export const transferBetweenMints = async ( const prepared = await prepareMint(targetMint, amountMsat, { ...options, persistOutput, + ...(allocateOutputSecrets ? {allocateOutputSecrets} : {}), beforePersist: ({grossMsat, server}) => { transferSources(bearers, server, grossMsat) }, @@ -253,7 +264,10 @@ export const transferBetweenMints = async ( } // carving burns its inputs server-side, so it happens only once the // target is known good and the invoice exists - const carve = await ensureExactAmount(offTarget, prepared.grossMsat, options) + const carve = await ensureExactAmount(offTarget, prepared.grossMsat, { + ...options, + ...(allocateOutputSecrets ? {allocateOutputSecrets} : {}), + }) const sourceServer = serverOf(carve.note.url) const invoice = prepared.invoice const claimMaterial: TransferClaimMaterial = { @@ -267,7 +281,9 @@ 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 = (kit.randomSecret ?? defaultRandomSecret)() + const [sourceRecoverySecret] = allocateOutputSecrets + ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) + : [(kit.randomSecret ?? defaultRandomSecret)()] if (carve.consumed.length === 0) assertFundOwner(options) await onMeltReady?.(carve, sourceRecoverySecret) assertFundOwner(options)