feat(ops): draw mint-touching output secrets from caller allocation

This commit is contained in:
2026-09-05 11:34:01 +02:00
parent e6d0712258
commit 22b1c5595c
14 changed files with 463 additions and 44 deletions
+35 -4
View File
@@ -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<typeof wallet.beginFundOperation> | 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;
}
};
+53
View File
@@ -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)
})
})
+19 -1
View File
@@ -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])))
})
})
+61
View File
@@ -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')
+41
View File
@@ -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)
})
})
+18
View File
@@ -26,6 +26,24 @@ export const secret = (seed: string): string =>
export const persistOutput = async (): Promise<void> => 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<readonly string[]> => {
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<void> => 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 =
+34
View File
@@ -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})
+4 -1
View File
@@ -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'
+43
View File
@@ -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<readonly string[]>
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<readonly string[]> => {
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
}
+18 -4
View File
@@ -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<CarveResult> => {
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 = {
+10 -1
View File
@@ -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<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).
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),
+21 -4
View File
@@ -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<void>
@@ -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<PayResult> => {
// 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, {
+85 -24
View File
@@ -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<void>
}
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<ClaimedNote> => {
options: ReceiveBearerOptions = {},
): Promise<ReceivedNote> => {
// 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),
}
}
}
+21 -5
View File
@@ -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<void>
}
@@ -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<TransferResult> => {
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)