mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(ops): draw mint-touching output secrets from caller allocation
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
|||||||
} from 'lnurlcash-kit';
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
import { receiveBearer } from '@/lnurlcash/ops';
|
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 { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
||||||
import { useMintsStore } from '@/stores/mints';
|
import { useMintsStore } from '@/stores/mints';
|
||||||
@@ -95,18 +95,42 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
|
|||||||
if (busy.value || value === '') return;
|
if (busy.value || value === '') return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
clearError();
|
clearError();
|
||||||
|
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
let staged: Bearer | undefined;
|
||||||
try {
|
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, {
|
const claimed = await receiveBearer(value, wallet.bearers, {
|
||||||
assertOwner: ownerFence,
|
assertOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
|
stageRotation: async (note) => {
|
||||||
|
[staged] = await wallet.addBearers([note], ownerFence);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const note = claimed.note;
|
const note = claimed.note;
|
||||||
const server = new URL(note.url).host;
|
const server = new URL(note.url).host;
|
||||||
const wasTrusted = mints.isTrusted(server);
|
const wasTrusted = mints.isTrusted(server);
|
||||||
const notes: NewBearer[] = claimed.possibleCopy ? [note, claimed.possibleCopy] : [note];
|
|
||||||
let trustWarning = '';
|
let trustWarning = '';
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
trustWarning = error.message;
|
trustWarning = error.message;
|
||||||
@@ -132,8 +156,15 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok
|
|||||||
trustPrompt.openTrust(server, note.mintPubkey);
|
trustPrompt.openTrust(server, note.mintPubkey);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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)));
|
classifyError(error instanceof Error ? error : new Error(String(error)));
|
||||||
} finally {
|
} finally {
|
||||||
|
fundOperation?.complete();
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -250,4 +250,57 @@ describe('ensureExactAmount', () => {
|
|||||||
).rejects.toThrow(/commit failed/)
|
).rejects.toThrow(/commit failed/)
|
||||||
expect(instance.state.noteState(secret('14'))).toBe('outstanding')
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {hashK1, noteK1} from 'lnurlcash-kit'
|
|||||||
import {claimMintedNote, prepareMint} from './ops'
|
import {claimMintedNote, prepareMint} from './ops'
|
||||||
import {requiredValue} from './test-utils'
|
import {requiredValue} from './test-utils'
|
||||||
import type {Mint} from './ops.testHarness'
|
import type {Mint} from './ops.testHarness'
|
||||||
import {mint, persistOutput, settleLastInvoice} from './ops.testHarness'
|
import {mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness'
|
||||||
|
|
||||||
const HEX32 = /^[0-9a-f]{64}$/
|
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(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
|
||||||
expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32)
|
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])))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
|
|
||||||
import {claimMintedNote, prepareMint, receiveBearer} from './ops'
|
import {claimMintedNote, prepareMint, receiveBearer} from './ops'
|
||||||
import {requiredValue} from './test-utils'
|
import {requiredValue} from './test-utils'
|
||||||
|
import type {NewBearer} from './types'
|
||||||
import {
|
import {
|
||||||
makeBearer,
|
makeBearer,
|
||||||
mint,
|
mint,
|
||||||
@@ -40,6 +41,66 @@ describe('mint fee', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('receiveBearer', () => {
|
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 () => {
|
it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
|
||||||
const instance = await mint()
|
const instance = await mint()
|
||||||
const senderK1 = secret('20')
|
const senderK1 = secret('20')
|
||||||
|
|||||||
@@ -134,4 +134,45 @@ describe('payWithBearers', () => {
|
|||||||
expect(instance.state.noteState(secret('36'))).toBe('outstanding')
|
expect(instance.state.noteState(secret('36'))).toBe('outstanding')
|
||||||
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ export const secret = (seed: string): string =>
|
|||||||
|
|
||||||
export const persistOutput = async (): Promise<void> => undefined
|
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
|
// the trusted signing keys a production caller would serve from the
|
||||||
// trusted-mint registry: the mock mint's current key plus any it retired
|
// trusted-mint registry: the mock mint's current key plus any it retired
|
||||||
export const signatureKeysFor =
|
export const signatureKeysFor =
|
||||||
|
|||||||
@@ -25,6 +25,40 @@ const transferBetweenMints = (
|
|||||||
describe('transferBetweenMints', () => {
|
describe('transferBetweenMints', () => {
|
||||||
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
|
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 () => {
|
it('moves value to another mint at a wallet-chosen target secret', async () => {
|
||||||
const source = await mint()
|
const source = await mint()
|
||||||
const target = await mint({testHooks: true})
|
const target = await mint({testHooks: true})
|
||||||
|
|||||||
@@ -27,12 +27,14 @@
|
|||||||
|
|
||||||
export {UncertainOutcomeError} from './ops/shared'
|
export {UncertainOutcomeError} from './ops/shared'
|
||||||
export type {FundOperationOptions, PollOptions} from './ops/shared'
|
export type {FundOperationOptions, PollOptions} from './ops/shared'
|
||||||
|
export {OutputSecretAllocationRequiredError} from './ops/allocation'
|
||||||
|
export type {OutputSecretAllocator} from './ops/allocation'
|
||||||
export {
|
export {
|
||||||
CarveCheckpointRequiredError,
|
CarveCheckpointRequiredError,
|
||||||
ensureExactAmount,
|
ensureExactAmount,
|
||||||
UnsupportedMultiBatchMergeError,
|
UnsupportedMultiBatchMergeError,
|
||||||
} from './ops/carve'
|
} from './ops/carve'
|
||||||
export type {CarveResult} from './ops/carve'
|
export type {CarveOptions, CarveResult} from './ops/carve'
|
||||||
export {
|
export {
|
||||||
MintedNoteSpentError,
|
MintedNoteSpentError,
|
||||||
prepareMint,
|
prepareMint,
|
||||||
@@ -46,6 +48,7 @@ export type {
|
|||||||
StagedMintRecovery,
|
StagedMintRecovery,
|
||||||
} from './ops/mint'
|
} from './ops/mint'
|
||||||
export {receiveBearer} from './ops/receiveBearer'
|
export {receiveBearer} from './ops/receiveBearer'
|
||||||
|
export type {ReceiveBearerOptions, ReceivedNote} from './ops/receiveBearer'
|
||||||
export {payWithBearers} from './ops/pay'
|
export {payWithBearers} from './ops/pay'
|
||||||
export type {PayOutcome, PayResult, PayOptions} from './ops/pay'
|
export type {PayOutcome, PayResult, PayOptions} from './ops/pay'
|
||||||
export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer'
|
export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
withNewK1,
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
|
import type {OutputSecretAllocator} from './allocation'
|
||||||
|
import {requireOutputSecrets} from './allocation'
|
||||||
import {mergeAmbiguitySafe} from './carveRecovery'
|
import {mergeAmbiguitySafe} from './carveRecovery'
|
||||||
import type {FundOperationOptions} from './shared'
|
import type {FundOperationOptions} from './shared'
|
||||||
import {
|
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
|
// Selection: only notes that can actually take part - verified (callback
|
||||||
// known), not locally spent, holding a real k1 (device-backed mirrors are
|
// 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
|
// 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 (
|
export const ensureExactAmount = async (
|
||||||
bearers: Bearer[],
|
bearers: Bearer[],
|
||||||
amountMsat: number,
|
amountMsat: number,
|
||||||
options: FundOperationOptions = {},
|
options: CarveOptions = {},
|
||||||
): Promise<CarveResult> => {
|
): Promise<CarveResult> => {
|
||||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||||
throw new Error('Amount must be a positive whole number of msat.')
|
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) {
|
if (mergeBatches(base.callback, k1s).length !== 1) {
|
||||||
throw new UnsupportedMultiBatchMergeError()
|
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 = {
|
const staged: NewBearer = {
|
||||||
url: withNewK1(base.url, mergeSecret, total),
|
url: withNewK1(base.url, mergeSecret, total),
|
||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
@@ -177,8 +190,9 @@ export const ensureExactAmount = async (
|
|||||||
// split path: total above target - one split request across all picked
|
// split path: total above target - one split request across all picked
|
||||||
// k1s, carving the target off and leaving the change as a fresh note
|
// k1s, carving the target off and leaving the change as a fresh note
|
||||||
const randomSecret = options.randomSecret ?? defaultRandomSecret
|
const randomSecret = options.randomSecret ?? defaultRandomSecret
|
||||||
const partK1 = randomSecret()
|
const [partK1, changeK1] = options.allocateOutputSecrets
|
||||||
const changeK1 = randomSecret()
|
? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 2)
|
||||||
|
: [randomSecret(), randomSecret()]
|
||||||
let partSignature: string | undefined
|
let partSignature: string | undefined
|
||||||
let changeSignature: string | undefined
|
let changeSignature: string | undefined
|
||||||
const stagedNote: NewBearer = {
|
const stagedNote: NewBearer = {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit'
|
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
import {ceilMsatToSat} from '../units'
|
import {ceilMsatToSat} from '../units'
|
||||||
|
import type {OutputSecretAllocator} from './allocation'
|
||||||
|
import {requireOutputSecrets} from './allocation'
|
||||||
import type {PollOptions} from './shared'
|
import type {PollOptions} from './shared'
|
||||||
import type {FundOperationOptions} from './shared'
|
import type {FundOperationOptions} from './shared'
|
||||||
import {assertFundOwner, pollUntil, withMutationSafety} from './shared'
|
import {assertFundOwner, pollUntil, withMutationSafety} from './shared'
|
||||||
@@ -54,6 +56,11 @@ export type PrepareMintOptions = FundOperationOptions & {
|
|||||||
readonly grossMsat: number
|
readonly grossMsat: number
|
||||||
readonly server: string
|
readonly server: string
|
||||||
}) => void | Promise<void>
|
}) => void | Promise<void>
|
||||||
|
// the caller's durable allocation path for the staged note's secret.
|
||||||
|
// 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
|
// Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover
|
||||||
@@ -101,7 +108,9 @@ export const prepareMint = async (
|
|||||||
}
|
}
|
||||||
const server = serverOf(payUrl)
|
const server = serverOf(payUrl)
|
||||||
await beforePersist?.({grossMsat, server})
|
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 mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey
|
||||||
const staged: NewBearer = {
|
const staged: NewBearer = {
|
||||||
url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
|
url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
|
||||||
|
|||||||
@@ -17,14 +17,17 @@ import {
|
|||||||
resolveLnurlInput,
|
resolveLnurlInput,
|
||||||
rotateNote,
|
rotateNote,
|
||||||
sameInvoice,
|
sameInvoice,
|
||||||
|
serverOf,
|
||||||
withNewK1,
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit'
|
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
|
import type {OutputSecretAllocator} from './allocation'
|
||||||
|
import {requireOutputSecrets} from './allocation'
|
||||||
import type {CarveResult} from './carve'
|
import type {CarveResult} from './carve'
|
||||||
import {ensureExactAmount} from './carve'
|
import {ensureExactAmount} from './carve'
|
||||||
import type {PollOptions} from './shared'
|
import type {PollOptions} from './shared'
|
||||||
import type {FundOperationOptions} from './shared'
|
import type {FundOperationOptions, MintSignatureKeys} from './shared'
|
||||||
import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared'
|
import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared'
|
||||||
|
|
||||||
export type PayOutcome =
|
export type PayOutcome =
|
||||||
@@ -59,6 +62,14 @@ export type PayOptions = {
|
|||||||
// so an abort mid-wait never strands the carve (see
|
// so an abort mid-wait never strands the carve (see
|
||||||
// FundOperationOptions.onCarve)
|
// FundOperationOptions.onCarve)
|
||||||
onCarve?: 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
|
// Called before the classification rotate puts its chosen output secret
|
||||||
// on the wire. Production callers durably link it to the carved source.
|
// on the wire. Production callers durably link it to the carved source.
|
||||||
onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise<void>
|
onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise<void>
|
||||||
@@ -77,7 +88,7 @@ export type PayOptions = {
|
|||||||
export const payWithBearers = async (
|
export const payWithBearers = async (
|
||||||
bearers: Bearer[],
|
bearers: Bearer[],
|
||||||
input: string,
|
input: string,
|
||||||
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve, onReturnReady}: PayOptions = {},
|
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve, allocateOutputSecrets, mintSignatureKeys, onReturnReady}: PayOptions = {},
|
||||||
): Promise<PayResult> => {
|
): Promise<PayResult> => {
|
||||||
// the forced mutation policy (see shared.ts) covers the carve, the melt,
|
// the forced mutation policy (see shared.ts) covers the carve, the melt,
|
||||||
// and the classification rotate regardless of caller kit options
|
// and the classification rotate regardless of caller kit options
|
||||||
@@ -85,6 +96,7 @@ export const payWithBearers = async (
|
|||||||
...kit,
|
...kit,
|
||||||
...(assertOwner ? {assertOwner} : {}),
|
...(assertOwner ? {assertOwner} : {}),
|
||||||
...(onCarve ? {onCarve} : {}),
|
...(onCarve ? {onCarve} : {}),
|
||||||
|
...(mintSignatureKeys ? {mintSignatureKeys} : {}),
|
||||||
})
|
})
|
||||||
let invoice: string
|
let invoice: string
|
||||||
let amount: number
|
let amount: number
|
||||||
@@ -118,7 +130,10 @@ export const payWithBearers = async (
|
|||||||
amount = amountMsat
|
amount = amountMsat
|
||||||
}
|
}
|
||||||
|
|
||||||
const carve = await ensureExactAmount(bearers, amount, options)
|
const carve = await ensureExactAmount(bearers, amount, {
|
||||||
|
...options,
|
||||||
|
...(allocateOutputSecrets ? {allocateOutputSecrets} : {}),
|
||||||
|
})
|
||||||
const k1 = requireNoteK1(carve.note.url)
|
const k1 = requireNoteK1(carve.note.url)
|
||||||
if (carve.consumed.length === 0) assertFundOwner(options)
|
if (carve.consumed.length === 0) assertFundOwner(options)
|
||||||
let melt: MeltResult
|
let melt: MeltResult
|
||||||
@@ -159,7 +174,9 @@ export const payWithBearers = async (
|
|||||||
// The verify budget ran out. Journal the exact output secret before the
|
// The verify budget ran out. Journal the exact output secret before the
|
||||||
// classification rotate reaches the mint, so a crash can recover either
|
// classification rotate reaches the mint, so a crash can recover either
|
||||||
// the returned source or the rotated replacement.
|
// the returned source or the rotated replacement.
|
||||||
const recoverySecret = kit.randomSecret?.() ?? defaultRandomSecret()
|
const [recoverySecret] = allocateOutputSecrets
|
||||||
|
? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1)
|
||||||
|
: [(kit.randomSecret ?? defaultRandomSecret)()]
|
||||||
await onReturnReady?.(carve, recoverySecret)
|
await onReturnReady?.(carve, recoverySecret)
|
||||||
try {
|
try {
|
||||||
const rotated = await rotateNote(carve.note.callback, k1, {
|
const rotated = await rotateNote(carve.note.callback, k1, {
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
// bech32, lnurlw://), verify it with the issuing service, then rotate
|
// bech32, lnurlw://), verify it with the issuing service, then rotate
|
||||||
// immediately, since the previous holder (and anything that logged the URL
|
// immediately, since the previous holder (and anything that logged the URL
|
||||||
// in transit) still knows the old secret.
|
// in transit) still knows the old secret.
|
||||||
|
//
|
||||||
|
// Durability contract: when the caller stages (options.stageRotation), the
|
||||||
|
// 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 {
|
import {
|
||||||
AmbiguousMutationError,
|
AmbiguousMutationError,
|
||||||
@@ -11,32 +21,92 @@ import {
|
|||||||
PendingNoteError,
|
PendingNoteError,
|
||||||
newSecretsOf,
|
newSecretsOf,
|
||||||
probeBurnedNote,
|
probeBurnedNote,
|
||||||
|
requireNoteK1,
|
||||||
|
serverOf,
|
||||||
withNewK1,
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
import {receiveNote, secureReceivedNote} from '../receive'
|
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 type {FundOperationOptions} from './shared'
|
||||||
import {assertFundOwner, probeMutationOutput, withMutationSafety} 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
|
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
|
||||||
// definitive and propagate; an unreachable service still yields the note,
|
// definitive and propagate; an unreachable service still yields the note,
|
||||||
// unverified, at the sender's declared amount.
|
// unverified, at the sender's declared amount.
|
||||||
export const receiveBearer = async (
|
export const receiveBearer = async (
|
||||||
input: string,
|
input: string,
|
||||||
existing: Bearer[],
|
existing: Bearer[],
|
||||||
options: FundOperationOptions = {},
|
options: ReceiveBearerOptions = {},
|
||||||
): Promise<ClaimedNote> => {
|
): Promise<ReceivedNote> => {
|
||||||
// the forced mutation policy (see shared.ts) covers the rotate below
|
// the forced mutation policy (see shared.ts) covers the rotate below
|
||||||
const mutationOptions = withMutationSafety(options)
|
const mutationOptions = withMutationSafety(options)
|
||||||
const note = await receiveNote(input, existing)
|
const note = await receiveNote(input, existing)
|
||||||
if (!note.verified || !note.callback) {
|
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 {
|
try {
|
||||||
assertFundOwner(options)
|
assertFundOwner(options)
|
||||||
const rotatedUrl = await secureReceivedNote(note, mutationOptions)
|
const rotatedUrl = await secureReceivedNote(note, rotateOptions)
|
||||||
return {note: {...note, url: rotatedUrl}, rotated: true}
|
return {note: {...note, url: rotatedUrl}, rotated: true, stage: disposition(true, false)}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A classified rotate refusal can still be a LANDED rotate: the
|
// A classified rotate refusal can still be a LANDED rotate: the
|
||||||
// callback is a GET and HTTP stacks retry GETs, so the service may
|
// callback is a GET and HTTP stacks retry GETs, so the service may
|
||||||
@@ -51,21 +121,16 @@ export const receiveBearer = async (
|
|||||||
return {
|
return {
|
||||||
note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
|
note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
|
||||||
rotated: true,
|
rotated: true,
|
||||||
|
stage: disposition(true, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (outcome === 'unknown') {
|
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 {
|
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},
|
note: {...note, verified: false},
|
||||||
rotated: 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.`,
|
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),
|
url: withNewK1(note.url, err.newSecrets[0], note.amount),
|
||||||
},
|
},
|
||||||
rotated: true,
|
rotated: true,
|
||||||
|
stage: disposition(true, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (outcome === 'unknown') {
|
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 {
|
return {
|
||||||
note,
|
note,
|
||||||
rotated: false,
|
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.`,
|
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
|
// 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 {
|
return {
|
||||||
note,
|
note,
|
||||||
rotated: false,
|
rotated: false,
|
||||||
rotationError: err instanceof Error ? err.message : String(err),
|
rotationError: err instanceof Error ? err.message : String(err),
|
||||||
|
stage: disposition(false, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ import {
|
|||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
|
import type {OutputSecretAllocator} from './allocation'
|
||||||
|
import {requireOutputSecrets} from './allocation'
|
||||||
import type {CarveResult} from './carve'
|
import type {CarveResult} from './carve'
|
||||||
import {ensureExactAmount} from './carve'
|
import {ensureExactAmount} from './carve'
|
||||||
import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint'
|
import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint'
|
||||||
import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint'
|
import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint'
|
||||||
import type {PollOptions} from './shared'
|
import type {PollOptions} from './shared'
|
||||||
import type {FundOperationOptions} from './shared'
|
import type {FundOperationOptions, MintSignatureKeys} from './shared'
|
||||||
import {assertFundOwner, withMutationSafety} from './shared'
|
import {assertFundOwner, withMutationSafety} from './shared'
|
||||||
|
|
||||||
export type TransferOutcome =
|
export type TransferOutcome =
|
||||||
@@ -116,11 +118,18 @@ export type TransferOptions = {
|
|||||||
kit?: LnurlcashOptions
|
kit?: LnurlcashOptions
|
||||||
assertOwner?: () => void
|
assertOwner?: () => void
|
||||||
// carve checkpoint - committed before the melt and the target
|
// 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)
|
// FundOperationOptions.onCarve)
|
||||||
onCarve?: FundOperationOptions['onCarve']
|
onCarve?: FundOperationOptions['onCarve']
|
||||||
// persists the target note before its hash reaches the invoice callback
|
// persists the target note before its hash reaches the invoice callback
|
||||||
persistOutput: PrepareMintOptions['persistOutput']
|
persistOutput: PrepareMintOptions['persistOutput']
|
||||||
|
// the caller's durable allocation path: the staged target note's secret,
|
||||||
|
// the 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.
|
// Last durable checkpoint before the source melt can become irreversible.
|
||||||
onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise<void>
|
onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise<void>
|
||||||
}
|
}
|
||||||
@@ -219,12 +228,13 @@ export const transferBetweenMints = async (
|
|||||||
bearers: Bearer[],
|
bearers: Bearer[],
|
||||||
amountMsat: number,
|
amountMsat: number,
|
||||||
targetMint: string,
|
targetMint: string,
|
||||||
{poll = {}, kit = {}, assertOwner, onCarve, persistOutput, onMeltReady}: TransferOptions,
|
{poll = {}, kit = {}, assertOwner, onCarve, persistOutput, allocateOutputSecrets, mintSignatureKeys, onMeltReady}: TransferOptions,
|
||||||
): Promise<TransferResult> => {
|
): Promise<TransferResult> => {
|
||||||
const options: FundOperationOptions = withMutationSafety({
|
const options: FundOperationOptions = withMutationSafety({
|
||||||
...kit,
|
...kit,
|
||||||
...(assertOwner ? {assertOwner} : {}),
|
...(assertOwner ? {assertOwner} : {}),
|
||||||
...(onCarve ? {onCarve} : {}),
|
...(onCarve ? {onCarve} : {}),
|
||||||
|
...(mintSignatureKeys ? {mintSignatureKeys} : {}),
|
||||||
})
|
})
|
||||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||||
throw new Error('Amount must be a positive whole number of msat.')
|
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, {
|
const prepared = await prepareMint(targetMint, amountMsat, {
|
||||||
...options,
|
...options,
|
||||||
persistOutput,
|
persistOutput,
|
||||||
|
...(allocateOutputSecrets ? {allocateOutputSecrets} : {}),
|
||||||
beforePersist: ({grossMsat, server}) => {
|
beforePersist: ({grossMsat, server}) => {
|
||||||
transferSources(bearers, server, grossMsat)
|
transferSources(bearers, server, grossMsat)
|
||||||
},
|
},
|
||||||
@@ -253,7 +264,10 @@ export const transferBetweenMints = async (
|
|||||||
}
|
}
|
||||||
// carving burns its inputs server-side, so it happens only once the
|
// carving burns its inputs server-side, so it happens only once the
|
||||||
// target is known good and the invoice exists
|
// 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 sourceServer = serverOf(carve.note.url)
|
||||||
const invoice = prepared.invoice
|
const invoice = prepared.invoice
|
||||||
const claimMaterial: TransferClaimMaterial = {
|
const claimMaterial: TransferClaimMaterial = {
|
||||||
@@ -267,7 +281,9 @@ export const transferBetweenMints = async (
|
|||||||
// flow never throws again; every outcome carries them
|
// flow never throws again; every outcome carries them
|
||||||
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
|
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
|
||||||
const k1 = requireNoteK1(carve.note.url)
|
const k1 = requireNoteK1(carve.note.url)
|
||||||
const sourceRecoverySecret = (kit.randomSecret ?? defaultRandomSecret)()
|
const [sourceRecoverySecret] = allocateOutputSecrets
|
||||||
|
? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1)
|
||||||
|
: [(kit.randomSecret ?? defaultRandomSecret)()]
|
||||||
if (carve.consumed.length === 0) assertFundOwner(options)
|
if (carve.consumed.length === 0) assertFundOwner(options)
|
||||||
await onMeltReady?.(carve, sourceRecoverySecret)
|
await onMeltReady?.(carve, sourceRecoverySecret)
|
||||||
assertFundOwner(options)
|
assertFundOwner(options)
|
||||||
|
|||||||
Reference in New Issue
Block a user