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