feat(funds): recover staged mutations

This commit is contained in:
2026-09-05 10:22:51 +02:00
parent a30c4bb2cc
commit 3876791a0b
22 changed files with 1838 additions and 969 deletions
+57 -29
View File
@@ -8,10 +8,10 @@
// mock emits either, selected by its withdrawLinkForm option, so do NOT // mock emits either, selected by its withdrawLinkForm option, so do NOT
// assume a default anywhere in the receive path); and the LUD-06 quote // assume a default anywhere in the receive path); and the LUD-06 quote
// request must carry the output-naming hash (as BOTH `comment` and `h`) // request must carry the output-naming hash (as BOTH `comment` and `h`)
// exactly when - and only when - the payRequest advertised the capability // only when the payRequest advertises commentAllowed >= 64. The wallet's
// (commentAllowed >= 64 or mintToHash): a new-generation mint // output secret must be durably staged before that quote request is sent;
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must // mintToHash alone is only an additive receipt extension, not permission
// see the byte-identical request this wallet always sent. // to mint.
import {afterEach, describe, expect, it} from 'vitest' import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {createMockMint} from 'lnurlcash-conformance/mock-mint'
@@ -47,6 +47,7 @@ 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
// 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
@@ -123,7 +124,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) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput})
// 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)
@@ -140,7 +141,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) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput})
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
@@ -150,9 +151,9 @@ describe('withdraw-link forms', () => {
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) })
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000)
expect(m.state.noteState(preimage)).toBe('burned') expect(m.state.noteState(preimage)).toBeNull()
expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding')
}) })
it('accepts an HTTPS withdraw link', async () => { it('accepts an HTTPS withdraw link', async () => {
@@ -166,13 +167,14 @@ describe('withdraw-link forms', () => {
minSendable: 1_000, minSendable: 1_000,
maxSendable: 100_000_000_000, maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note', withdrawLink: 'https://mint.example/note',
commentAllowed: 64,
metadata: '[]', metadata: '[]',
}, },
], ],
// 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}) const prepared = await prepareMint('mint@mint.example', 21_000, {fetch, persistOutput})
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')
@@ -210,27 +212,39 @@ describe('quote output-naming wire contract', () => {
return new URL(hit) return new URL(hit)
} }
it('sends no comment/h to a mint that never advertised output naming', async () => { it('rejects a mint that cannot carry the mandatory output commitment', async () => {
const seen: string[] = [] const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, { await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen), fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
}) persistOutput,
expect(prepared.mode).toBe('unnamed') }),
expect(prepared.noteSecret).toBeUndefined() ).rejects.toThrow(/64-character output commitment/i)
// an old mint sees the byte-identical request this wallet always sent expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
const url = callbackUrl(seen)
expect(url.searchParams.get('comment')).toBeNull()
expect(url.searchParams.get('h')).toBeNull()
}) })
it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => { it('persists the wallet output before sending its hash as comment and h', async () => {
const seen: string[] = [] const seen: string[] = []
const events: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, { const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), fetch: (input, init) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery')
return capturingFetch(
routesFor(payRequestFixture({commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}`})),
seen,
)(input, init)
},
persistOutput: async (note) => {
events.push('persist')
expect(note.verified).toBe(false)
expect(note.callback).toBe('')
expect(note.amount).toBe(0)
expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY)
},
}) })
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
const url = callbackUrl(seen) const url = callbackUrl(seen)
// both spellings go out together: `comment` is the LUD-25 form // both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read // lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
@@ -239,16 +253,30 @@ describe('quote output-naming wire contract', () => {
expect(comment).toMatch(/^[0-9a-f]{64}$/) expect(comment).toMatch(/^[0-9a-f]{64}$/)
expect(h).toBe(comment) expect(h).toBe(comment)
expect(comment).toBe(hashK1(noteSecret)) expect(comment).toBe(hashK1(noteSecret))
expect(prepared.mintPubkey).toBe(MINT_PUBKEY)
expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0)
expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice'))
}) })
it('names the output when only the mintToHash spelling is advertised', async () => { it('does not request an invoice when output persistence fails', async () => {
const seen: string[] = [] const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, { await expect(
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
persistOutput: () => Promise.reject(new Error('storage full')),
}),
).rejects.toThrow(/storage full/)
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
}) })
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret it('rejects mintToHash without the mandatory comment capacity', async () => {
if (!noteSecret) throw new Error('named mode without a note secret') const seen: string[] = []
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret)) await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
persistOutput,
}),
).rejects.toThrow(/64-character output commitment/i)
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
}) })
}) })
+165
View File
@@ -0,0 +1,165 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import type {CarveResult} from './ops'
import {ensureExactAmount, UnsupportedMultiBatchMergeError} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, secret} from './ops.testHarness'
const secretSource = (...secrets: string[]): (() => string) => {
let index = 0
return () => {
const next = secrets[index]
if (!next) throw new Error('The test exhausted its prepared secrets.')
index += 1
return next
}
}
describe('ensureExactAmount crash safety', () => {
it('stages both split secrets before either hash reaches the mint', async () => {
const instance = await mint()
const sourceSecret = secret('70')
const targetSecret = secret('71')
const changeSecret = secret('72')
const bearer = await makeBearer(instance, sourceSecret, 21_000)
let staged: CarveResult | 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()
expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret)
expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret)
expect(instance.state.noteState(sourceSecret)).toBe('outstanding')
}
return fetch(input, init)
}
const result = await ensureExactAmount([bearer], 5_000, {
fetch: observingFetch,
randomSecret: secretSource(targetSecret, changeSecret),
onCarve: (prepared) => {
staged = prepared
},
})
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id])
})
it('stages the merge secret before its hash reaches the mint', async () => {
const instance = await mint()
const firstSecret = secret('73')
const secondSecret = secret('74')
const mergedSecret = secret('75')
const first = await makeBearer(instance, firstSecret, 3_000)
const second = await makeBearer(instance, secondSecret, 4_000)
let staged: CarveResult | 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()
expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret)
expect(instance.state.noteState(firstSecret)).toBe('outstanding')
expect(instance.state.noteState(secondSecret)).toBe('outstanding')
}
return fetch(input, init)
}
await ensureExactAmount([first, second], 7_000, {
fetch: observingFetch,
randomSecret: secretSource(mergedSecret),
onCarve: (prepared) => {
staged = prepared
},
})
expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id])
})
it('does not expose a staged split output to a second ambiguous rotation', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('76'), 21_000)
let mutationCount = 0
const dropSecondMutation: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (!url.includes('/w/cb')) return fetch(input, init)
mutationCount += 1
const response = await fetch(input, init)
if (mutationCount === 2) {
await response.arrayBuffer()
throw new Error('second mutation response lost')
}
return response
}
const result = await ensureExactAmount([bearer], 5_000, {
fetch: dropSecondMutation,
mutationRetries: 0,
randomSecret: secretSource(secret('77'), secret('78')),
onCarve: () => undefined,
})
expect(mutationCount).toBe(1)
const targetSecret = requiredValue(noteK1(result.note.url))
const changeSecret = requiredValue(noteK1(requiredValue(result.change).url))
expect(instance.state.noteState(targetSecret)).toBe('outstanding')
expect(instance.state.noteState(changeSecret)).toBe('outstanding')
})
it('rejects a multi-batch merge before any partial fold can land', async () => {
const instance = await mint()
const bearers = await Promise.all(
Array.from({length: 21}, (_, index) =>
makeBearer(instance, secret((128 + index).toString(16)), 1_000),
),
)
let mutationCount = 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')) mutationCount += 1
return fetch(input, init)
}
const failure = ensureExactAmount(bearers, 21_000, {
fetch: countingFetch,
onCarve: () => undefined,
})
await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError)
expect(mutationCount).toBe(0)
expect(
bearers.every(
(bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding',
),
).toBe(true)
})
it('checks ownership again immediately before a merge mutation', async () => {
const instance = await mint()
const firstSecret = secret('79')
const secondSecret = secret('7a')
const first = await makeBearer(instance, firstSecret, 3_000)
const second = await makeBearer(instance, secondSecret, 4_000)
let ownerChecks = 0
let mutationCount = 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')) mutationCount += 1
return fetch(input, init)
}
await expect(
ensureExactAmount([first, second], 7_000, {
fetch: countingFetch,
assertOwner: () => {
ownerChecks += 1
if (ownerChecks === 3) throw new Error('wallet owner changed')
},
onCarve: () => undefined,
}),
).rejects.toThrow(/owner changed/)
expect(mutationCount).toBe(0)
expect(instance.state.noteState(firstSecret)).toBe('outstanding')
expect(instance.state.noteState(secondSecret)).toBe('outstanding')
})
})
+118 -28
View File
@@ -1,11 +1,11 @@
import {describe, expect, it} from 'vitest' import {describe, expect, it} from 'vitest'
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit' 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 {UncertainOutcomeError, ensureExactAmount} from './ops' import {CarveCheckpointRequiredError, UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness' import {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 () => {
@@ -23,7 +23,10 @@ describe('ensureExactAmount', () => {
const instance = await mint() const instance = await mint()
const k1 = secret('02') const k1 = secret('02')
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,
mintSignatureKeys: signatureKeysFor(instance),
})
expect(result.note.amount).toBe(5_000) expect(result.note.amount).toBe(5_000)
expect(result.note.verified).toBe(true) expect(result.note.verified).toBe(true)
expect(result.change?.amount).toBe(16_000) expect(result.change?.amount).toBe(16_000)
@@ -39,7 +42,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) const result = await ensureExactAmount([first, second], 7_000, {onCarve: () => undefined})
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)
@@ -53,7 +56,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) const result = await ensureExactAmount([first, second], 5_000, {onCarve: () => undefined})
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)
@@ -68,7 +71,7 @@ describe('ensureExactAmount', () => {
const spentBearer = await makeBearer(instance, secret('07'), 50_000) const spentBearer = await makeBearer(instance, secret('07'), 50_000)
const unverified: Bearer = { const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)), ...(await makeBearer(instance, secret('08'), 50_000)),
callback: '', verified: false,
} }
await expect( await expect(
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000), ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000),
@@ -85,7 +88,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) const result = await ensureExactAmount([bearer], 5_000, {onCarve: () => undefined})
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)
@@ -95,33 +98,40 @@ describe('ensureExactAmount', () => {
}) })
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => { it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const instance = await mint({dropAfterMutation: true}) const instance = await mint()
const k1 = secret('11') const k1 = secret('11')
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000)
const probeKillingFetch: typeof fetch = (input, init) => { // every mutation answer is lost - the first attempt's AND the forced
// byte-identical replay's (the mint executed both; the replay is the
// recorded success served again, then dropped by the wrapper) - and
// every informational probe is unreachable, so neither the mutation
// nor the probe can be confirmed
const everythingDyingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) return fetch(input, init) if (url.includes('/w/cb')) {
const response = await fetch(input, init)
await response.arrayBuffer()
throw new Error('mutation response lost')
}
return Promise.reject(new Error('probe unreachable')) return Promise.reject(new Error('probe unreachable'))
} }
const checkpoints: CarveResult[] = []
const failure = await ensureExactAmount([bearer], 5_000, { const failure = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch, fetch: everythingDyingFetch,
onCarve: (carve) => {
checkpoints.push(carve)
},
}).catch((error: unknown) => error) }).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(UncertainOutcomeError) expect(failure).toBeInstanceOf(UncertainOutcomeError)
if (!(failure instanceof UncertainOutcomeError)) throw failure if (!(failure instanceof UncertainOutcomeError)) throw failure
expect(failure.possibleOutputs).toHaveLength(2) expect(failure.possibleOutputs).toEqual([])
const first = requiredValue(failure.possibleOutputs[0]) expect(checkpoints).toHaveLength(1)
const second = requiredValue(failure.possibleOutputs[1]) expect(checkpoints[0]?.consumed).toEqual([])
expect(first.amount).toBe(5_000) expect(checkpoints[0]?.note.amount).toBe(5_000)
expect(second.amount).toBe(16_000) expect(checkpoints[0]?.change?.amount).toBe(16_000)
expect(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable,
).toBe(5_000)
expect(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
).toBe(16_000)
}) })
it('reports a mutation-bearing carve through onCarve before returning', async () => { it('stages outputs before wire and retires inputs only after the mutation lands', async () => {
const instance = await mint() const instance = await mint()
const bearer = await makeBearer(instance, secret('12'), 21_000) const bearer = await makeBearer(instance, secret('12'), 21_000)
const seen: CarveResult[] = [] const seen: CarveResult[] = []
@@ -129,13 +139,82 @@ describe('ensureExactAmount', () => {
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve)
}, },
mintSignatureKeys: signatureKeysFor(instance),
}) })
// the hook saw exactly the returned result, once expect(seen).toHaveLength(2)
expect(seen).toHaveLength(1) expect(seen[0]?.note.verified).toBe(false)
expect(seen[0]).toBe(result) expect(seen[0]?.consumed).toEqual([])
expect(seen[0]?.change).toBeDefined()
expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url))
expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe(
noteK1(requiredValue(result.change).url),
)
// the landed checkpoint re-reports the SAME staged output (same secret)
// - its URL has since gained the mint's signature
expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url))
expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull()
expect(seen[1]?.note.verified).toBe(true)
expect(seen[1]?.change).toBeUndefined()
expect(seen[1]?.consumed).toEqual(result.consumed)
expect(result.consumed).toHaveLength(1) expect(result.consumed).toHaveLength(1)
}) })
it('does not stage the split change again during the landed checkpoint', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('17'), 21_000)
const trackedSecrets = new Set<string>()
const retiredIds = new Set<string>()
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: (carve) => {
// checkpoints identify an output by its secret: the landed phase's
// URL has gained a signature the staged phase could not have had
const k1 = noteK1(carve.note.url)
if (k1) trackedSecrets.add(k1)
const changeK1 = carve.change ? noteK1(carve.change.url) : null
if (changeK1) trackedSecrets.add(changeK1)
for (const consumed of carve.consumed) retiredIds.add(consumed.id)
},
})
expect(trackedSecrets).toEqual(
new Set([
requiredValue(noteK1(result.note.url)),
requiredValue(noteK1(requiredValue(result.change).url)),
]),
)
expect(retiredIds).toEqual(new Set([bearer.id]))
})
it('keeps staged outputs recoverable when the landed checkpoint fails', async () => {
const instance = await mint()
const sourceSecret = secret('15')
const bearer = await makeBearer(instance, sourceSecret, 21_000)
const seen: CarveResult[] = []
await expect(
ensureExactAmount([bearer], 5_000, {
onCarve: (carve) => {
seen.push(carve)
if (carve.consumed.length > 0) throw new Error('final commit failed')
},
}),
).rejects.toThrow(/final commit failed/)
expect(seen).toHaveLength(2)
expect(seen[0]?.consumed).toEqual([])
expect(seen[0]?.change).toBeDefined()
expect(instance.state.noteState(sourceSecret)).toBe('burned')
expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe(
'outstanding',
)
expect(
instance.state.noteState(
requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)),
),
).toBe('outstanding')
})
it('never fires onCarve for an already-exact note - nothing was burned', async () => { it('never fires onCarve for an already-exact note - nothing was burned', async () => {
const instance = await mint() const instance = await mint()
const bearer = await makeBearer(instance, secret('13'), 21_000) const bearer = await makeBearer(instance, secret('13'), 21_000)
@@ -149,7 +228,17 @@ describe('ensureExactAmount', () => {
expect(result.consumed).toEqual([]) expect(result.consumed).toEqual([])
}) })
it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => { it('requires a typed durable checkpoint before a mutating carve', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('16'), 21_000)
await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf(
CarveCheckpointRequiredError,
)
expect(instance.state.noteState(secret('16'))).toBe('outstanding')
})
it('propagates an onCarve failure before the carve reaches the mint', async () => {
const instance = await mint() const instance = await mint()
const bearer = await makeBearer(instance, secret('14'), 21_000) const bearer = await makeBearer(instance, secret('14'), 21_000)
await expect( await expect(
@@ -159,5 +248,6 @@ describe('ensureExactAmount', () => {
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/)
expect(instance.state.noteState(secret('14'))).toBe('outstanding')
}) })
}) })
+32 -85
View File
@@ -4,118 +4,65 @@ 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, settleLastInvoice} from './ops.testHarness' import {mint, persistOutput, settleLastInvoice} from './ops.testHarness'
const HEX32 = /^[0-9a-f]{64}$/ const HEX32 = /^[0-9a-f]{64}$/
// the invoice whose quote the wallet bound to its own output hash
const boundInvoice = (instance: Mint) => const boundInvoice = (instance: Mint) =>
[...instance.state.invoices.values()].find((invoice) => invoice.boundTo) [...instance.state.invoices.values()].find((invoice) => invoice.boundTo)
describe('mint -> claim, named output (comment/mintToHash)', () => { describe('mint -> claim, comment-bound output', () => {
it('names the quote with the hash of a wallet-chosen secret', async () => { it('names the quote with the hash of a wallet-chosen secret', async () => {
const instance = await mint({commentAllowed: 64, 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, {
expect(prepared.mode).toBe('named') persistOutput,
const noteSecret = requiredValue(prepared.noteSecret) })
expect(noteSecret).toMatch(HEX32) expect(prepared.noteSecret).toMatch(HEX32)
// the mint bound the quote to sha256 of the wallet's secret - the kit expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret))
// sends it as both comment and h, the mock read one of them
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret))
}) })
it('claims a named output at the wallet secret, without a rotate', async () => { it('claims the output at the wallet secret without rotating it', async () => {
const instance = await mint({commentAllowed: 64, 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, {
const noteSecret = requiredValue(prepared.noteSecret) persistOutput,
})
const preimage = await settleLastInvoice(instance) const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) })
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true) expect(claimed.note.verified).toBe(true)
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`) expect(claimed.note.callback).toBe(`${instance.url}/w/cb`)
expect(noteK1(claimed.note.url)).toBe(noteSecret) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
// no rotate happened: the note still stands at the wallet's own expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding')
// secret, and the payment preimage keys nothing at all
expect(instance.state.noteState(noteSecret)).toBe('outstanding')
expect(instance.state.noteState(preimage)).toBeNull() expect(instance.state.noteState(preimage)).toBeNull()
}) })
it('names the output through the mintToHash spelling too', async () => { it('times out cleanly when the invoice is never paid', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
})
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
})
it('uses the required comment even when the optional h extension is advertised', async () => {
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, {
expect(prepared.mode).toBe('named') persistOutput,
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret))) })
const preimage = await settleLastInvoice(instance) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret))
await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) })
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
expect(instance.state.noteState(preimage)).toBeNull() expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32)
})
it('keeps an unnamed mint on the preimage path', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// the quote was NOT bound - nothing named an output
expect(boundInvoice(instance)).toBeUndefined()
})
it('times out cleanly when a named mint invoice is never paid', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
})
it('rescues through the preimage when the mint credited it anyway and serves verify', async () => {
// the mintToHashIgnoresH adversary: claims the capability, binds
// nothing, credits the payment hash - the preimage is still the money
const instance = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
expect(instance.state.noteState(preimage)).toBe('outstanding')
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 300,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
// claimed through the preimage path and rotated off it, because that
// preimage rode the invoice
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('fails loudly when the mint credits the preimage and serves no verify', async () => {
const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
// verify is withheld on the unnamed fallback - there the preimage IS
// the note, so a compliant mint never serves it
expect(prepared.verifyUrl).toBeNull()
const preimage = await settleLastInvoice(instance)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}),
).rejects.toThrow(/not confirmed/i)
// the money exists at the preimage but is unreachable without verify -
// the wallet must NOT report a success here
expect(instance.state.noteState(preimage)).toBe('outstanding')
}) })
}) })
+12 -49
View File
@@ -10,33 +10,21 @@ import {
import {claimMintedNote, prepareMint, receiveBearer} from './ops' import {claimMintedNote, prepareMint, receiveBearer} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness' import {
makeBearer,
describe('mint -> claim -> rotate', () => { mint,
it('mints a note from a paid invoice and rotates it immediately', async () => { noteUrl,
const instance = await mint({testHooks: true}) persistOutput,
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) secret,
expect(prepared.invoice).toMatch(/^lnbc/) settleLastInvoice,
expect(prepared.verifyUrl).toBeTruthy() } from './ops.testHarness'
expect(prepared.expectedNoteValueMsat).toBe(21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
describe('mint fee', () => {
it('grosses the invoice up for an advertised mint fee', async () => { it('grosses the invoice up for an advertised mint fee', async () => {
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,
})
expect(prepared.grossMsat).toBeGreaterThan(100_000) expect(prepared.grossMsat).toBeGreaterThan(100_000)
await settleLastInvoice(instance) await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
@@ -48,31 +36,6 @@ describe('mint -> claim -> rotate', () => {
// from the gross, so the note nets between the asked value and the gross // from the gross, so the note nets between the asked value and the gross
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000) expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000)
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat) expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat)
expect(claimed.rotated).toBe(true)
})
it('refuses a verify response naming a different invoice', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await settleLastInvoice(instance)
// a verify answer that names any invoice but the requested one must
// stop the claim - the preimage it carries belongs to another payment
const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}`
await expect(
claimMintedNote({...prepared, invoice: other}, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
}),
).rejects.toThrow(/different invoice/)
})
it('times out cleanly when the invoice is never paid', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
}) })
}) })
+50 -11
View File
@@ -18,10 +18,28 @@ describe('payWithBearers', () => {
it('pays a Lightning Address by requesting an invoice first', async () => { it('pays a Lightning Address by requesting an invoice first', async () => {
const payer = await mint() const payer = await mint()
const payee = await mint()
const bearer = await makeBearer(payer, secret('31'), 21_000) const bearer = await makeBearer(payer, secret('31'), 21_000)
const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, { const payeeFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) {
return Promise.resolve(
Response.json({
tag: 'payRequest',
callback: 'https://payee.example/invoice',
minSendable: 1_000,
maxSendable: 100_000_000,
metadata: '[]',
}),
)
}
if (url.startsWith('https://payee.example/invoice')) {
return Promise.resolve(Response.json({pr: 'lnbc210n1pjqrstuvwxyz'}))
}
return fetch(input, init)
}
const result = await payWithBearers([bearer], 'alice@payee.example', {
amountMsat: 21_000, amountMsat: 21_000,
kit: {fetch: payeeFetch},
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
}) })
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled')
@@ -34,6 +52,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},
onCarve: () => undefined,
}) })
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled')
expect(instance.state.noteState(secret('32'))).toBe('burned') expect(instance.state.noteState(secret('32'))).toBe('burned')
@@ -51,14 +70,35 @@ describe('payWithBearers', () => {
}) })
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')
// the returned funds come back re-secured at the classification
// rotate's fresh secret - carried as rotatedNote, NOT folded into the
// carve (which an onCarve checkpoint may already have committed)
const rotatedNote = requiredValue(result.rotatedNote) const rotatedNote = requiredValue(result.rotatedNote)
expect(rotatedNote.verified).toBe(true) expect(rotatedNote.verified).toBe(true)
const returnedK1 = requiredValue(noteK1(rotatedNote.url)) expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding')
expect(instance.state.noteState(returnedK1)).toBe('outstanding') })
expect(rotatedNote.amount).toBe(21_000)
it('waits for durable recovery-secret staging before rotating returned funds', async () => {
const instance = await mint({meltAlwaysFails: true})
const bearer = await makeBearer(instance, secret('39'), 21_000)
let releaseStage: (() => void) | undefined
const stageGate = new Promise<void>((resolve) => {
releaseStage = resolve
})
let stageStarted: (() => void) | undefined
const stageEntered = new Promise<void>((resolve) => {
stageStarted = resolve
})
const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
onReturnReady: async () => {
stageStarted?.()
await stageGate
},
})
await stageEntered
expect(instance.state.noteState(secret('39'))).toBe('outstanding')
releaseStage?.()
expect((await payment).outcome).toBe('failed-funds-returned')
expect(instance.state.noteState(secret('39'))).toBe('burned')
}) })
it('classifies a never-settling melt as unknown-still-pending', async () => { it('classifies a never-settling melt as unknown-still-pending', async () => {
@@ -90,9 +130,8 @@ describe('payWithBearers', () => {
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened: // The checkpoint failed before either split or melt reached the mint.
// nothing sits pending at the mint expect(instance.state.noteState(secret('36'))).toBe('outstanding')
expect(instance.state.noteState(secret('36'))).toBe('burned')
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
}) })
}) })
+19 -25
View File
@@ -1,17 +1,10 @@
import {describe, expect, it} from 'vitest' import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit' import {noteK1} from 'lnurlcash-kit'
import type {CarveResult} from './ops'
import {ensureExactAmount, receiveBearer} from './ops' import {ensureExactAmount, receiveBearer} from './ops'
import {claimFromPreimage, prepareMint} from './ops/mint'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import { import {makeBearer, mint, noteUrl, retryingCbFetch, secret} from './ops.testHarness'
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
settleLastInvoice,
} 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
@@ -24,7 +17,10 @@ describe('mutation retry refusals', () => {
const instance = await mint() const instance = await mint()
const k1 = secret('60') const k1 = secret('60')
const bearer = await makeBearer(instance, k1, 50_000) const bearer = await makeBearer(instance, k1, 50_000)
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}) const result = await ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
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
// and both outputs are live under the secrets the wallet carried // and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned') expect(instance.state.noteState(k1)).toBe('burned')
@@ -41,7 +37,10 @@ describe('mutation retry refusals', () => {
const instance = await mint() const instance = await mint()
const a = await makeBearer(instance, secret('61'), 20_000) const a = await makeBearer(instance, secret('61'), 20_000)
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, {fetch: retryingCbFetch()}) const result = await ensureExactAmount([a, b], 25_000, {
fetch: retryingCbFetch(),
onCarve: () => undefined,
})
expect(result.consumed).toHaveLength(2) expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(secret('61'))).toBe('burned') expect(instance.state.noteState(secret('61'))).toBe('burned')
expect(instance.state.noteState(secret('62'))).toBe('burned') expect(instance.state.noteState(secret('62'))).toBe('burned')
@@ -50,19 +49,6 @@ describe('mutation retry refusals', () => {
expect(instance.state.noteState(mergedK1)).toBe('outstanding') expect(instance.state.noteState(mergedK1)).toBe('outstanding')
}) })
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => { it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint() const instance = await mint()
const senderK1 = secret('63') const senderK1 = secret('63')
@@ -83,8 +69,16 @@ describe('mutation retry refusals', () => {
const k1 = secret('64') const k1 = secret('64')
const bearer = await makeBearer(instance, k1, 50_000) const bearer = await makeBearer(instance, k1, 50_000)
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
const checkpoints: CarveResult[] = []
await expect( await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}), ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
onCarve: (carve) => {
checkpoints.push(carve)
},
}),
).rejects.toThrow(/spent/i) ).rejects.toThrow(/spent/i)
expect(checkpoints).toHaveLength(1)
expect(checkpoints[0]?.consumed).toEqual([])
}) })
}) })
+145
View File
@@ -0,0 +1,145 @@
import {describe, expect, it} from 'vitest'
import {noteK1, noteSignature} from 'lnurlcash-kit'
import {ensureExactAmount} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness'
import {fetchNoteInfo} from 'lnurlcash-kit'
// Landed split/merge outputs carry the mint's signature in their URL (sig
// param), and earn verified:true only when that signature checks against a
// trusted CURRENT or PREVIOUS key for the mint. Invalid, missing, or
// wrong-key signatures - and mints with no trusted keys at all - keep the
// staged outputs unverified; the money is tracked either way (the notes
// exist at the mint), only the offline-verifiable badge is withheld.
describe('landed mutation signature verification', () => {
it('verifies a landed split against the current key and preserves its signature', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('a0'), 21_000)
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
mintSignatureKeys: signatureKeysFor(instance),
})
expect(result.note.verified).toBe(true)
const signature = noteSignature(result.note.url)
expect(signature).not.toBeNull()
expect(noteSignature(requiredValue(result.change).url)).not.toBeNull()
// change stays unverified (its true post-fee value is unknown until a
// refresh) even though its signature is preserved
expect(result.change?.verified).toBe(false)
})
it('verifies a landed merge against the current key and preserves its signature', async () => {
const instance = await mint()
const first = await makeBearer(instance, secret('a1'), 3_000)
const second = await makeBearer(instance, secret('a2'), 4_000)
const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined,
mintSignatureKeys: signatureKeysFor(instance),
})
expect(result.note.verified).toBe(true)
expect(noteSignature(result.note.url)).not.toBeNull()
})
it('verifies against the previous key after the mint rotated its signing key', async () => {
// the mint signs every output under its RETIRED key while advertising
// the current one - exactly the rekey window a holder must tolerate
const instance = await mint({
previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true,
})
const bearer = await makeBearer(instance, secret('a3'), 21_000)
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
mintSignatureKeys: signatureKeysFor(instance),
})
expect(instance.state.previousPubkeys).toHaveLength(1)
expect(result.note.verified).toBe(true)
expect(noteSignature(result.note.url)).not.toBeNull()
})
it('rejects verification when the previous key is not trusted', async () => {
const instance = await mint({
previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true,
})
const bearer = await makeBearer(instance, secret('a4'), 21_000)
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
// only the current key is trusted - the signature under the retired
// key must not verify
mintSignatureKeys: () => [instance.state.pubkey],
})
expect(result.note.verified).toBe(false)
// the note and its signature are still tracked, recoverable by refresh
expect(noteSignature(result.note.url)).not.toBeNull()
const partK1 = requiredValue(noteK1(result.note.url))
expect(instance.state.noteState(partK1)).toBe('outstanding')
expect(instance.state.noteState(secret('a4'))).toBe('burned')
})
it('keeps a wrong-key signature staged unverified', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('a5'), 21_000)
const stranger = await mint({privateKey: '33'.repeat(32)})
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
// the only trusted key on file belongs to a DIFFERENT mint
mintSignatureKeys: () => [stranger.state.pubkey],
})
expect(result.note.verified).toBe(false)
expect(noteSignature(result.note.url)).not.toBeNull()
expect(instance.state.noteState(secret('a5'))).toBe('burned')
expect(
instance.state.noteState(requiredValue(noteK1(result.note.url))),
).toBe('outstanding')
})
it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => {
const instance = await mint({signatures: false})
const k1 = secret('a6')
const bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
mintSignatureKeys: signatureKeysFor(instance),
})
// the mutation landed (the kit refused the missing signature, the
// rescue probe proved the burn): the inputs are retired, the staged
// outputs are tracked at exactly their staged secrets, unverified
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id])
expect(result.note.verified).toBe(false)
expect(noteSignature(result.note.url)).toBeNull()
expect(instance.state.noteState(k1)).toBe('burned')
const partK1 = requiredValue(noteK1(result.note.url))
expect(instance.state.noteState(partK1)).toBe('outstanding')
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
})
it('never verifies a landed mutation without any trusted keys on file', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('a7'), 21_000)
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined,
// no key source at all: nothing may be trusted blindly
})
expect(result.note.verified).toBe(false)
expect(noteSignature(result.note.url)).not.toBeNull()
expect(
instance.state.noteState(requiredValue(noteK1(result.note.url))),
).toBe('outstanding')
})
})
+2
View File
@@ -1,4 +1,6 @@
import './ops.carve.cases' import './ops.carve.cases'
import './ops.carve-crash.cases'
import './ops.signature.cases'
import './ops.mint-named.cases' import './ops.mint-named.cases'
import './ops.mint-receive.cases' import './ops.mint-receive.cases'
import './ops.pay.cases' import './ops.pay.cases'
+9
View File
@@ -24,6 +24,15 @@ afterEach(async () => {
export const secret = (seed: string): string => export const secret = (seed: string): string =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed))) bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)))
export const persistOutput = async (): Promise<void> => undefined
// the trusted signing keys a production caller would serve from the
// trusted-mint registry: the mock mint's current key plus any it retired
export const signatureKeysFor =
(instance: Mint) =>
(): readonly string[] =>
[instance.state.pubkey, ...instance.state.previousPubkeys]
export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string => export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string =>
buildNoteUrl(`${instance.url}/w`, k1, amountMsat) buildNoteUrl(`${instance.url}/w`, k1, amountMsat)
+75 -68
View File
@@ -2,15 +2,30 @@ import {describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {hashK1, noteK1} from 'lnurlcash-kit' import {hashK1, noteK1} from 'lnurlcash-kit'
import {transferBetweenMints} from './ops' import {transferBetweenMints as transferBetweenMintsEngine} from './ops'
import type {CarveResult} from './ops' import type {CarveResult, TransferOptions} from './ops'
import type {Bearer} from './types'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' import {
expectBurned,
makeBearer,
mint,
persistOutput,
secret,
settleWhenRequested,
} from './ops.testHarness'
const transferBetweenMints = (
bearers: Bearer[],
amountMsat: number,
targetMint: string,
options: Omit<TransferOptions, 'persistOutput'> = {},
) => transferBetweenMintsEngine(bearers, amountMsat, targetMint, {...options, persistOutput})
describe('transferBetweenMints', () => { describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
it('moves value to another mint: melt at source, claim + rotate at target', 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})
const k1 = secret('40') const k1 = secret('40')
@@ -31,10 +46,9 @@ describe('transferBetweenMints', () => {
expect(result.sourceServer).not.toBe(result.targetServer) expect(result.sourceServer).not.toBe(result.targetServer)
await expectBurned(source, k1) await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget) const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true) expect(claimed.note.verified).toBe(true)
expect(target.state.noteState(preimage)).toBe('burned') expect(target.state.noteState(preimage)).toBeNull()
const newK1 = requiredValue(noteK1(claimed.note.url)) const newK1 = requiredValue(noteK1(claimed.note.url))
expect(newK1).not.toBe(preimage) expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding') expect(target.state.noteState(newK1)).toBe('outstanding')
@@ -45,9 +59,15 @@ describe('transferBetweenMints', () => {
const target = await mint() const target = await mint()
const k1 = secret('41') const k1 = secret('41')
const bearer = await makeBearer(source, k1, 5_000) const bearer = await makeBearer(source, k1, 5_000)
let persisted = false
await expect( await expect(
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`), transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, {
persistOutput: () => {
persisted = true
},
}),
).rejects.toThrow(/enough/) ).rejects.toThrow(/enough/)
expect(persisted).toBe(false)
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding')
}) })
@@ -72,6 +92,22 @@ describe('transferBetweenMints', () => {
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding')
}) })
it('reports a definitively spent source before waiting on the target', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('54')
const bearer = await makeBearer(source, k1, 21_000)
source.state.settleMelt(k1)
const result = await transferBetweenMints(
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
)
expect(result.outcome).toBe('note-already-spent')
})
it('recovers from a melt whose answer was lost once the target invoice settles', async () => { it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
const source = await mint({unconfirmedMutation: true}) const source = await mint({unconfirmedMutation: true})
const target = await mint({testHooks: true}) const target = await mint({testHooks: true})
@@ -85,26 +121,6 @@ describe('transferBetweenMints', () => {
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled')
await expectBurned(source, k1) await expectBurned(source, k1)
expect(result.mintedAtTarget?.note.amount).toBe(21_000) expect(result.mintedAtTarget?.note.amount).toBe(21_000)
expect(result.mintedAtTarget?.rotated).toBe(true)
})
it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
const source = await mint()
const target = await mint({testHooks: true, echoWrongK1: true})
const k1 = secret('45')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll,
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled-claim-failed')
await expectBurned(source, k1)
const note = requiredValue(result.claimMaterial?.note)
expect(noteK1(note.url)).toBe(preimage)
expect(note.verified).toBe(false)
expect(note.amount).toBe(21_000)
expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
}) })
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => { it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
@@ -140,9 +156,9 @@ describe('transferBetweenMints', () => {
expect(rotatedNote.amount).toBe(21_000) expect(rotatedNote.amount).toBe(21_000)
}) })
it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => { it('lands the target note at the staged wallet secret without rotating', async () => {
const source = await mint() const source = await mint()
const target = await mint({commentAllowed: 64, testHooks: true}) const target = await mint({testHooks: true})
const k1 = secret('48') const k1 = secret('48')
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
@@ -153,7 +169,6 @@ describe('transferBetweenMints', () => {
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled')
await expectBurned(source, k1) await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget) const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true) expect(claimed.note.verified).toBe(true)
const newK1 = requiredValue(noteK1(claimed.note.url)) const newK1 = requiredValue(noteK1(claimed.note.url))
@@ -166,7 +181,7 @@ describe('transferBetweenMints', () => {
it('keeps the named claim material when the target never credits the note', async () => { it('keeps the named claim material when the target never credits the note', async () => {
const source = await mint({meltNeverSettles: true}) const source = await mint({meltNeverSettles: true})
const target = await mint({commentAllowed: 64, testHooks: true}) const target = await mint({testHooks: true})
const k1 = secret('49') const k1 = secret('49')
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
@@ -190,36 +205,7 @@ describe('transferBetweenMints', () => {
expect(noteK1(material.url)).toBe(noteSecret) expect(noteK1(material.url)).toBe(noteSecret)
}) })
it('rescues a named transfer through the preimage when the target credited it anyway', async () => { it('fires onCarve before the carve or melt reaches the mint', async () => {
const source = await mint()
// the mintToHashIgnoresH adversary: advertises naming, credits the
// payment hash - and (non-compliantly) still serves verify
const target = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const k1 = secret('50')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
})
const preimage = await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
// claimed through the preimage path and rotated off it, because that
// preimage rode the invoice
expect(target.state.noteState(preimage)).toBe('burned')
const newK1 = requiredValue(noteK1(claimed.note.url))
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
})
it('fires onCarve after the carve landed and before the melt starts', async () => {
const source = await mint() const source = await mint()
const target = await mint({testHooks: true}) const target = await mint({testHooks: true})
const bearer = await makeBearer(source, secret('51'), 21_000) const bearer = await makeBearer(source, secret('51'), 21_000)
@@ -228,16 +214,21 @@ describe('transferBetweenMints', () => {
poll: fastPoll, poll: fastPoll,
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve)
// at hook time the melt must NOT have started: the carved note is if (carve.consumed.length === 0) {
// still outstanding (a melt would leave it pending) expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull()
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding')
} else {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding') expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding')
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned')
}
}, },
}) })
await settleWhenRequested(target) await settleWhenRequested(target)
const result = await pending const result = await pending
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled')
expect(seen).toHaveLength(1) expect(seen).toHaveLength(2)
expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(seen[0]?.consumed).toEqual([])
expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id])
}) })
it('aborts before the melt when the carve commit hook fails', async () => { it('aborts before the melt when the carve commit hook fails', async () => {
@@ -252,9 +243,25 @@ describe('transferBetweenMints', () => {
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened: // The checkpoint failed before either split or melt reached the source.
// nothing sits pending at the source, nothing was ever paid expect(source.state.noteState(k1)).toBe('outstanding')
expect(source.state.noteState(k1)).toBe('burned')
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
}) })
it('aborts before an exact-note melt when the final checkpoint fails', async () => {
const source = await mint()
const target = await mint()
const k1 = secret('53')
const bearer = await makeBearer(source, k1, 21_000)
await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => {
throw new Error('source link commit failed')
},
}),
).rejects.toThrow(/source link commit failed/)
expect(source.state.noteState(k1)).toBe('outstanding')
})
}) })
+22 -11
View File
@@ -5,12 +5,9 @@
// new/changed notes out - it never mutates wallet state itself. // new/changed notes out - it never mutates wallet state itself.
// //
// Fund-critical invariants enforced across the flows (see the project plan): // Fund-critical invariants enforced across the flows (see the project plan):
// - rotate on every receive, and immediately after claiming a fresh mint // - rotate every externally received bearer. Minted notes are staged at a
// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice // wallet-chosen secret before invoice creation and claimed there without
// knows the payment hash, and the mint necessarily saw the preimage - // rotating; the payment preimage is not bearer material
// the preimage IS the note secret). A NAMED mint (LUD-25 comment /
// mintToHash) credits the note to a wallet-chosen secret instead; that
// secret never rode an invoice, so no rotate follows its claim
// - a note's declared amount is a claim; the service's maxWithdrawable is // - a note's declared amount is a claim; the service's maxWithdrawable is
// authoritative // authoritative
// - a melt's "OK" only means the payment is in flight; its verify URL (or // - a melt's "OK" only means the payment is in flight; its verify URL (or
@@ -29,15 +26,29 @@
// ops/shared.ts - bounded polling, UncertainOutcomeError // ops/shared.ts - bounded polling, UncertainOutcomeError
export {UncertainOutcomeError} from './ops/shared' export {UncertainOutcomeError} from './ops/shared'
export type {PollOptions} from './ops/shared' export type {FundOperationOptions, PollOptions} from './ops/shared'
export {ensureExactAmount} from './ops/carve' export {
CarveCheckpointRequiredError,
ensureExactAmount,
UnsupportedMultiBatchMergeError,
} from './ops/carve'
export type {CarveResult} from './ops/carve' export type {CarveResult} from './ops/carve'
export {prepareMint, claimMintedNote} from './ops/mint' export {
export type {PreparedMint, ClaimedNote} from './ops/mint' MintedNoteSpentError,
prepareMint,
claimMintedNote,
recoverStagedMintOutput,
} from './ops/mint'
export type {
PreparedMint,
PrepareMintOptions,
ClaimedNote,
StagedMintRecovery,
} from './ops/mint'
export {receiveBearer} from './ops/receiveBearer' export {receiveBearer} 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 {transferBetweenMints} from './ops/transfer' export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer'
export type { export type {
TransferClaimMaterial, TransferClaimMaterial,
TransferOptions, TransferOptions,
+134 -188
View File
@@ -2,31 +2,37 @@
// and/or splitting as needed, into a single fresh note worth exactly the // and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from. // target - the operation every send and every melt starts from.
// //
// A carve whose mutation landed reports itself through options.onCarve // Every output secret is chosen and reported through options.onCarve BEFORE
// BEFORE returning, so a caller about to do something slow (a settlement // its hash reaches the mint. That first checkpoint stages possible outputs
// wait) can durably commit first - a flow aborted mid-wait must never // without retiring inputs; a second checkpoint retires the inputs only after
// leave burned inputs looking spendable or strand the fresh outputs. // the mutation is known to have landed.
import { import {
AmbiguousMutationError, AmbiguousMutationError,
mergeNotes, defaultRandomSecret,
mergeBatches,
newSecretsOf, newSecretsOf,
noteK1, noteK1,
probeBurnedNote, probeBurnedNote,
requireNoteK1, requireNoteK1,
serverOf, serverOf,
settleNote,
splitNote, splitNote,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import {mergeAmbiguitySafe} from './carveRecovery'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared' import {
assertFundOwner,
landedNoteVerifies,
probeMutationOutput,
UncertainOutcomeError,
withMutationSafety,
} from './shared'
// the changeset stores apply after a mutation: `note`/`change` BEFORE // The same shape serves both checkpoint phases: the pre-wire phase carries
// `consumed` - the mint call already burned every consumed input // note/change with an empty consumed list, and the landed phase carries the
// server-side, so the outputs are the only money left and must be tracked // already-staged note plus the inputs the caller can now retire.
// first; a crash between the two must strand a duplicate, never a secret
export type CarveResult = { export type CarveResult = {
// the exact-amount note, ready to hand over or melt // the exact-amount note, ready to hand over or melt
note: NewBearer note: NewBearer
@@ -37,6 +43,22 @@ export type CarveResult = {
consumed: Bearer[] consumed: Bearer[]
} }
export class UnsupportedMultiBatchMergeError extends Error {
override readonly name = 'UnsupportedMultiBatchMergeError'
constructor() {
super('This carve would require multiple merge requests and cannot be performed safely.')
}
}
export class CarveCheckpointRequiredError extends Error {
override readonly name = 'CarveCheckpointRequiredError'
constructor() {
super('A mutating carve requires a durable onCarve checkpoint.')
}
}
// 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
@@ -46,12 +68,12 @@ export type CarveResult = {
// //
// Execution, mirroring lnurl-wallet's SendDialog: // Execution, mirroring lnurl-wallet's SendDialog:
// - one note already exact: returned as-is, nothing burned // - one note already exact: returned as-is, nothing burned
// - several notes summing exactly: one merge, then settle (reads the true // - several notes summing exactly: one merge; folds spanning multiple
// post-fee value back and rotates, since the read put k1 on the wire) // requests are rejected because partial progress needs a larger journal
// - total above target (one or many notes): a single split request (LUD-25 // - total above target (one or many notes): a single split request (LUD-25
// split takes many k1s - no merge round trip first), then the change is // split takes many k1s - no merge round trip first); change stays
// settled for its true value; the target part carries the mint's // unverified until refresh because a second settle mutation would create
// signature and needs no settle // another crash window
export const ensureExactAmount = async ( export const ensureExactAmount = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
@@ -60,8 +82,11 @@ export const ensureExactAmount = async (
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.')
} }
// the forced mutation policy (signatures required, one byte-identical
// replay) applies to every mint call below regardless of caller options
const mutationOptions = withMutationSafety(options)
const eligible = bearers.filter( const eligible = bearers.filter(
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), (b) => b.verified && !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
) )
// per-server greedy pick: smallest notes first until the target is // per-server greedy pick: smallest notes first until the target is
// covered (an exact single-note match short-circuits - no mutation at // covered (an exact single-note match short-circuits - no mutation at
@@ -86,13 +111,17 @@ export const ensureExactAmount = async (
const total = pick.reduce((sum, b) => sum + b.amount, 0) const total = pick.reduce((sum, b) => sum + b.amount, 0)
const k1s = pick.map((b) => requireNoteK1(b.url)) const k1s = pick.map((b) => requireNoteK1(b.url))
// a carve that burned inputs reports itself the moment its outcome is const checkpoint = async (result: CarveResult): Promise<void> => {
// known, before the caller's flow moves on to anything slow or if (!options.onCarve) {
// uncertain - see FundOperationOptions.onCarve throw new CarveCheckpointRequiredError()
const checkpoint = async (result: CarveResult): Promise<CarveResult> => {
await options.onCarve?.(result)
return result
} }
assertFundOwner(options)
await options.onCarve(result)
assertFundOwner(options)
}
const stage = (result: CarveResult): Promise<void> =>
checkpoint({...result, consumed: []})
const retire = (note: NewBearer): Promise<void> => checkpoint({note, consumed: pick})
if (pick.length === 1 && total === amountMsat) { if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched // already exact - hand over the note itself, untouched
@@ -109,94 +138,94 @@ export const ensureExactAmount = async (
} }
if (total === amountMsat) { if (total === amountMsat) {
// merge path: many notes, exact sum - merge into one, then settle it if (mergeBatches(base.callback, k1s).length !== 1) {
// (true value + fresh secret; a failed settle leaves an unverified throw new UnsupportedMultiBatchMergeError()
// note a refresh can repair, not a lost secret) }
assertFundOwner(options) const mergeSecret = (options.randomSecret ?? defaultRandomSecret)()
const merged = await mergeAmbiguitySafe(base, k1s, total, options) const staged: NewBearer = {
const unverified: NewBearer = { url: withNewK1(base.url, mergeSecret, total),
url: withNewK1(base.url, merged.k1, total, merged.signature),
callback: base.callback, callback: base.callback,
amount: total, amount: total,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} }
// a merge whose answer was lost leaves the service in an unknown await stage({note: staged, consumed: pick})
// state from here - settling fires another mutation (the rotate assertFundOwner(options)
// inside settleNote) at it, whose own ambiguous failure would strand const merged = await mergeAmbiguitySafe(base, k1s, {
// the rescued secret. Don't compound: return unverified and let a ...mutationOptions,
// refresh repair. randomSecret: () => mergeSecret,
if (merged.rescued) return checkpoint({note: unverified, consumed: pick}) })
try { // a landed merge reports its signature (carried in the URL's sig param)
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) // and earns verified only when that signature checks against a trusted
return checkpoint({ // current or previous key; a rescued merge saw no answer, so its note
// stays exactly as staged - unverified, same URL
const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature)
const result: CarveResult = merged.rescued
? {note: staged, consumed: pick}
: {
note: { note: {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), ...staged,
callback: settled.callback, url: landedUrl,
amount: settled.amountMsat, verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys),
verified: true,
mintPubkey: base.mintPubkey,
}, },
consumed: pick, consumed: pick,
})
} catch {
return checkpoint({note: unverified, consumed: pick})
} }
await retire(result.note)
return result
} }
// 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
let partK1: string const randomSecret = options.randomSecret ?? defaultRandomSecret
const partK1 = randomSecret()
const changeK1 = randomSecret()
let partSignature: string | undefined let partSignature: string | undefined
let changeK1: string
let changeSignature: string | undefined let changeSignature: string | undefined
let partVerified = false const stagedNote: NewBearer = {
// true when the split's answer was lost and the probe proved the burn - url: withNewK1(base.url, partK1, amountMsat),
// the carried secrets were rescued, but the service is in an unknown callback: base.callback,
// state, so the change is NOT settled (that would fire another mutation amount: amountMsat,
// at it, whose own ambiguous failure would strand the rescued secret) verified: false,
let rescued = false mintPubkey: base.mintPubkey,
}
const stagedChange: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
}
await stage({note: stagedNote, change: stagedChange, consumed: pick})
let secretIndex = 0
const preparedSecret = (): string => {
secretIndex += 1
if (secretIndex === 1) return partK1
if (secretIndex === 2) return changeK1
throw new Error('The split requested more output secrets than were staged.')
}
assertFundOwner(options) assertFundOwner(options)
try { try {
const parts = await splitNote(base.callback, k1s, amountMsat, options) const parts = await splitNote(base.callback, k1s, amountMsat, {
partK1 = parts.k1 ...mutationOptions,
randomSecret: preparedSecret,
})
partSignature = parts.signature partSignature = parts.signature
changeK1 = parts.change
changeSignature = parts.changeSignature changeSignature = parts.changeSignature
partVerified = true
} catch (err) { } catch (err) {
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one // the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth // input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options) const outcome = await probeBurnedNote(base.url, mutationOptions)
if (outcome === 'live') throw err // nothing burned - a plain failure if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') { if (outcome === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT // can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo // consuming the inputs, and stop here rather than spend from limbo
throw new UncertainOutcomeError( throw new UncertainOutcomeError(
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.', 'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.',
[ [],
{
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
) )
} }
// 'gone': the burn landed - the carried secrets are the only money // 'gone': the burn landed - the carried secrets are the only money
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
} else { } else {
// A classified refusal can still be a LANDED split: the callback is // A classified refusal can still be a LANDED split: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed // a GET and HTTP stacks retry GETs, so the service may have executed
@@ -205,79 +234,53 @@ export const ensureExactAmount = async (
// one output before deciding they are worthless. // one output before deciding they are worthless.
const carried = newSecretsOf(err) const carried = newSecretsOf(err)
if (carried.length !== 2) throw err if (carried.length !== 2) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options) const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions)
if (outcome === 'absent') throw err // never landed - a plain refusal if (outcome === 'absent') throw err // never landed - a plain refusal
if (outcome === 'unknown') { if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as // can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume // the ambiguous case above: track the possible outputs, consume
// nothing, stop here // nothing, stop here
throw new UncertainOutcomeError( throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.', 'The split was refused, but the refusal may have named a retry that already landed - its possible outputs were already staged unverified alongside the originals.',
[ [],
{
url: withNewK1(base.url, carried[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, carried[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
) )
} }
// 'live': the split landed and this answer was its retried twin - // 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left // the carried secrets are the only money left
partK1 = carried[0]
changeK1 = carried[1]
rescued = true
} }
} }
// the landed answer's signatures ride in the URLs (sig param); a rescued
// split saw no answer, so its notes keep the staged sig-less URLs. Only a
// signature that verifies against a trusted current/previous key earns
// verified:true - anything else stays staged unverified for a refresh.
const noteUrl =
partSignature === undefined
? stagedNote.url
: withNewK1(base.url, partK1, amountMsat, partSignature)
const note: NewBearer = { const note: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat, partSignature), url: noteUrl,
callback: base.callback, callback: base.callback,
amount: amountMsat, amount: amountMsat,
verified: partVerified, verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys),
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} }
// settleNote: the change may be worth less than total - amount if this // The change may be worth less than total - amount when the mint charges
// mint charges split fees (LUD-25 deducts them from change, never the // split fees. It remains unverified at that upper bound until refresh;
// split-off amount) - it comes back at its true value, or stays // settling it here would rotate to an unstaged secret in a second request.
// unverified at the naive pre-fee one for a refresh to repair const changeUrl =
let change: NewBearer = { changeSignature === undefined
url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature), ? stagedChange.url
: withNewK1(base.url, changeK1, total - amountMsat, changeSignature)
const change: NewBearer = {
url: changeUrl,
callback: base.callback, callback: base.callback,
amount: total - amountMsat, amount: total - amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} }
if (!rescued) { const result = {note, change, consumed: pick}
try { await retire(note)
const settled = await settleNote( return result
base.url,
changeK1,
total - amountMsat,
changeSignature,
options,
)
change = {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey,
}
} catch (error) {
// settle is best-effort - the unverified change above is still tracked
if (!(error instanceof Error)) throw error
}
}
return checkpoint({note, change, consumed: pick})
} }
// smallest-first accumulation until the target is covered; null when the // smallest-first accumulation until the target is covered; null when the
@@ -301,60 +304,3 @@ const better = (a: Bearer[], b: Bearer[], target: number): boolean => {
if (wasteA !== wasteB) return wasteA < wasteB if (wasteA !== wasteB) return wasteA < wasteB
return a.length < b.length return a.length < b.length
} }
// merge with the full ambiguity protocol: probe one input, rescue the
// carried secret only once the burn is confirmed, surface it unverified
// when the probe can't tell either
const mergeAmbiguitySafe = async (
base: Bearer,
k1s: string[],
total: number,
options: FundOperationOptions,
): Promise<{k1: string; signature?: string; rescued: boolean}> => {
try {
const merged = await mergeNotes(base.callback, k1s, options)
return {k1: merged.k1, signature: merged.signature, rescued: false}
} catch (err) {
if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
// 'gone': the burn landed - the carried secret is the only money left
return {k1: err.newSecrets[0], rescued: true}
}
// same retry-refusal rescue as the split path: the refusal may name a
// merge that already landed - probe the would-be combined note
const carried = newSecretsOf(err)
if (carried.length !== 1) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, carried[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
return {k1: carried[0], rescued: true}
}
}
+50
View File
@@ -0,0 +1,50 @@
// A merge failure can hide a landed mutation because callback GETs may be
// retried below the engine. This module owns the probes that distinguish a
// plain refusal from a live or still-uncertain wallet-chosen output.
import {AmbiguousMutationError, mergeNotes, newSecretsOf, probeBurnedNote} from 'lnurlcash-kit'
import type {Bearer} from '../types'
import type {FundOperationOptions} from './shared'
import {probeMutationOutput, UncertainOutcomeError} from './shared'
export type MergeOutcome = {
readonly rescued: boolean
// the mint's signature over the combined note - present only when the
// merge's own answer was seen; a rescued merge (lost answer, retried
// twin) has none to offer
readonly signature?: string
}
export const mergeAmbiguitySafe = async (
base: Bearer,
k1s: string[],
options: FundOperationOptions,
): Promise<MergeOutcome> => {
try {
const merged = await mergeNotes(base.callback, k1s, options)
return {rescued: false, signature: merged.signature}
} catch (error) {
if (error instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw error
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - its possible combined note was already staged unverified alongside the originals.',
[],
)
}
return {rescued: true}
}
const carried = newSecretsOf(error)
if (carried.length !== 1) throw error
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw error
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry that already landed - its possible combined note was already staged unverified alongside the originals.',
[],
)
}
return {rescued: true}
}
}
+99 -246
View File
@@ -1,69 +1,39 @@
// Minting: receiving over Lightning. prepareMint resolves a mint and // Minting: receiving over Lightning. Current LUD-25 minting is always
// requests the invoice; paying it brings the note into existence; // comment-bound: the wallet chooses the future note secret, durably stages
// claimMintedNote watches the payment and converts it into a wallet-owned // an unverified bearer for it, and only then sends sha256(secret) with the
// bearer note. // invoice request. Paying the invoice brings that already-tracked note into
// // existence; claiming confirms its authoritative value and callback.
// Two mint generations exist on the wire, and this module speaks both:
//
// - UNNAMED (old): the minted note's k1 IS the payment preimage. The
// claim takes the preimage from the LUD-21 verify answer and rotates
// immediately - every routing node and anyone who saw the invoice knew
// that secret.
// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the
// wallet names the note at quote time with comment = sha256(secret) of
// a secret it chose, and the note lands at THAT secret. The preimage
// opens nothing, so no observer race exists and no rotate follows the
// claim; the claim poll itself is the placement proof (see
// claimFromSecret).
//
// The mode is decided per mint from the payRequest's capability
// advertisement (commentAllowed >= 64 or mintToHash), never assumed.
import { import {
AmbiguousMutationError,
buildNoteUrl, buildNoteUrl,
claimMintedNote as fetchMintClaim, claimMintedNote as fetchMintClaim,
defaultRandomSecret, defaultRandomSecret,
fetchInvoiceVerification,
fetchMintAddress, fetchMintAddress,
fetchNoteInfo,
fetchPayRequest, fetchPayRequest,
grossUpForMintFee, grossUpForMintFee,
hashK1, hashK1,
isPreimage,
lightningAddressUsername, lightningAddressUsername,
mintAddressUrl, mintAddressUrl,
namesMintOutput,
newSecretsOf,
NoteSpentError,
probeBurnedNote,
requestInvoice, requestInvoice,
noteDeclaredAmount,
noteK1,
resolveMintInput, resolveMintInput,
rotateNote,
sameInvoice,
serverOf, serverOf,
withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit'
import type {NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import {ceilMsatToSat} from '../units' import {ceilMsatToSat} from '../units'
import type {PollOptions} from './shared' import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared' import {assertFundOwner, pollUntil, withMutationSafety} from './shared'
export type PreparedMint = { export type PreparedMint = {
invoice: string invoice: string
verifyUrl: string | null verifyUrl: string | null
// how this mint keys the note the invoice pays for: 'named' mints credit // The wallet-chosen secret the minted note lands at. Its unverified note
// a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints // is persisted before the invoice request can put this secret's hash on
// credit the payment preimage // the wire.
mode: 'named' | 'unnamed' noteSecret: string
// named mode only: the wallet-chosen secret the minted note lands at.
// Drawn before the invoice is requested (its hash binds the quote) and
// carried in memory only - never persisted, never logged. Losing it
// after payment loses the note, but nothing here was ever recoverable
// after a crash: the invoice and verify URL die in the same memory
noteSecret?: string
// the net note value asked for - the claim cross-checks the service's // the net note value asked for - the claim cross-checks the service's
// authoritative maxWithdrawable against it // authoritative maxWithdrawable against it
expectedNoteValueMsat: number expectedNoteValueMsat: number
@@ -78,6 +48,14 @@ export type PreparedMint = {
nodeInfo: MintAddressInfo | null nodeInfo: MintAddressInfo | null
} }
export type PrepareMintOptions = FundOperationOptions & {
readonly persistOutput: (note: NewBearer) => void | Promise<void>
readonly beforePersist?: (quote: {
readonly grossMsat: number
readonly server: string
}) => void | Promise<void>
}
// Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover // Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover
// its mint address (best-effort LUD-25 experimental endpoint), read its // its mint address (best-effort LUD-25 experimental endpoint), read its
// payRequest, gross the requested net amount up for the advertised mint // payRequest, gross the requested net amount up for the advertised mint
@@ -86,8 +64,11 @@ export type PreparedMint = {
export const prepareMint = async ( export const prepareMint = async (
mintInput: string, mintInput: string,
amountMsat: number, amountMsat: number,
options: FundOperationOptions = {}, options: PrepareMintOptions,
): Promise<PreparedMint> => { ): Promise<PreparedMint> => {
const {beforePersist, persistOutput, ...rest} = options
// the forced mutation policy (see shared.ts) covers the invoice request
const kitOptions = withMutationSafety(rest)
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.')
} }
@@ -101,14 +82,14 @@ export const prepareMint = async (
let payUrl = url let payUrl = url
if (addressUrl) { if (addressUrl) {
try { try {
nodeInfo = await fetchMintAddress(addressUrl, options) nodeInfo = await fetchMintAddress(addressUrl, kitOptions)
payUrl = nodeInfo.payLink payUrl = nodeInfo.payLink
} catch (error) { } catch (error) {
// no mint-address support here - proceed with just the guess // no mint-address support here - proceed with just the guess
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error
} }
} }
const info = await fetchPayRequest(payUrl, options) const info = await fetchPayRequest(payUrl, kitOptions)
if (!info.withdrawLink) { if (!info.withdrawLink) {
throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).') throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).')
} }
@@ -118,52 +99,57 @@ export const prepareMint = async (
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
throw new Error("Amount is outside this mint's sendable range.") throw new Error("Amount is outside this mint's sendable range.")
} }
// a mint that names its outputs takes comment = sha256(secret) with the const server = serverOf(payUrl)
// quote; one that doesn't gets the byte-identical request this wallet await beforePersist?.({grossMsat, server})
// always sent, and its note stays keyed by the payment preimage const noteSecret = (kitOptions.randomSecret ?? defaultRandomSecret)()
const named = namesMintOutput(info) const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey
let noteSecret: string | undefined const staged: NewBearer = {
let h: string | undefined url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
if (named) { callback: '',
noteSecret = defaultRandomSecret() // The URL retains the claimed amount for recovery, but the wallet's
h = hashK1(noteSecret) // balance stays at zero until the mint confirms the authoritative value.
amount: 0,
verified: false,
pendingMint: mintPubkey ? {mintPubkey} : {},
} }
const invoice = await requestInvoice( assertFundOwner(options)
info.callback, await persistOutput(staged)
grossMsat, assertFundOwner(options)
h ? {...options, h} : options, const invoice = await requestInvoice(info.callback, grossMsat, {
) ...kitOptions,
h: hashK1(noteSecret),
})
const prepared: PreparedMint = { const prepared: PreparedMint = {
invoice: invoice.pr, invoice: invoice.pr,
verifyUrl: invoice.verify ?? null, verifyUrl: invoice.verify ?? null,
mode: named ? 'named' : 'unnamed', noteSecret,
expectedNoteValueMsat: amountMsat, expectedNoteValueMsat: amountMsat,
grossMsat, grossMsat,
mintUrl: payUrl, mintUrl: payUrl,
withdrawLink: info.withdrawLink, withdrawLink: info.withdrawLink,
server: serverOf(payUrl), server,
username: lightningAddressUsername(payUrl), username: lightningAddressUsername(payUrl),
nodeInfo, nodeInfo,
} }
if (noteSecret) prepared.noteSecret = noteSecret if (mintPubkey) prepared.mintPubkey = mintPubkey
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
return prepared return prepared
} }
export type ClaimedNote = { export type ClaimedNote = {
note: NewBearer note: NewBearer
// false when the note may be exposed to someone besides this wallet and
// the mint: an unnamed mint's fresh note whose rotate failed (its k1 was
// the payment preimage, transmitted and mint-known). A named mint's
// note always reports true - its secret never rode an invoice, so there
// is nothing to rotate away from
rotated: boolean rotated: boolean
// set when the rotate's answer was lost and the probe couldn't tell: the
// possible rotated copy, to track unverified alongside `note`
possibleCopy?: NewBearer possibleCopy?: NewBearer
rotationError?: string rotationError?: string
} }
export class MintedNoteSpentError extends Error {
override readonly name = 'MintedNoteSpentError'
constructor() {
super('The mint reports the freshly minted note as already spent.')
}
}
// Polls a named mint output's informational GET until the note is there: // Polls a named mint output's informational GET until the note is there:
// 'minted' resolves the wait ('spent' resolves it too - surfaced for the // 'minted' resolves the wait ('spent' resolves it too - surfaced for the
// caller to reject, never retried); 'unminted' and 'pending' keep it // caller to reject, never retried); 'unminted' and 'pending' keep it
@@ -185,67 +171,27 @@ export const pollMintClaim = async (
options, options,
) )
// what a claim needs once the preimage is known - PreparedMint satisfies
// this, and so does the target side of an inter-mint transfer (see
// transfer.ts)
export type ClaimTarget = { export type ClaimTarget = {
withdrawLink: string withdrawLink: string
noteSecret: string
// the net note value asked for - a claim, cross-checked against the // the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable // service's authoritative maxWithdrawable
expectedNoteValueMsat: number expectedNoteValueMsat: number
mintPubkey?: string mintPubkey?: string
} }
// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's // The note was credited to the wallet's own secret, which rode to the mint
// verify URL and invoice as rescue anchors for a mint that took the hash // only as a hash inside the invoice request. The payment preimage opens
// but credited the preimage anyway. PreparedMint satisfies this. // nothing, so the claim needs no preimage and no rotate follows it.
export type NamedClaimTarget = ClaimTarget & {
noteSecret: string
verifyUrl?: string | null
invoice?: string
}
// The named-mode claim: the note was credited to the wallet's own secret,
// which rode to the mint as a HASH inside the invoice request - the
// payment preimage opens nothing, so the claim needs no preimage and no
// rotate follows it. The rescue covers the mint that claimed the
// capability but credited the preimage (the kit's mintToHashIgnoresH
// adversary: the wallet was told no rotate is needed while the preimage
// is still the money): when that mint still serves the quote's verify, one
// check recovers the preimage and the note is claimed through the old
// path, WITH the rotate that path exists for. When it serves none there
// is nothing to rescue with - the error stays loud.
export const claimFromSecret = async ( export const claimFromSecret = async (
target: NamedClaimTarget, target: ClaimTarget,
poll: PollOptions = {}, poll: PollOptions = {},
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<ClaimedNote> => {
assertFundOwner(options) assertFundOwner(options)
let claim: MintClaim const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options)
try {
claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options)
} catch (err) {
if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) {
try {
const result = await fetchInvoiceVerification(target.verifyUrl, options)
if (
result.settled &&
sameInvoice(result.pr, target.invoice) &&
result.preimage &&
isPreimage(result.preimage)
) {
return claimFromPreimage(target, result.preimage, options)
}
} catch {
// the rescue is best-effort - the original error stands
}
}
throw err
}
if (claim.state === 'spent') { if (claim.state === 'spent') {
// a fresh wallet-chosen secret cannot already be spent - the service throw new MintedNoteSpentError()
// is lying about the note it just credited
throw new Error('The mint reports the freshly minted note as already spent.')
} }
if (claim.k1 !== target.noteSecret) { if (claim.k1 !== target.noteSecret) {
// belt and suspenders: the kit's claimMintedNote returns the queried // belt and suspenders: the kit's claimMintedNote returns the queried
@@ -267,141 +213,48 @@ export const claimFromSecret = async (
return {note, rotated: true} return {note, rotated: true}
} }
// Polls until the minted note is claimable, then claims it. Unnamed mints: // Polls until the minted note is claimable at the wallet's staged secret.
// watch the LUD-21 verify URL for settlement, take the preimage (which IS
// the note secret there), and claim through claimFromPreimage with its
// mandatory rotate. Named mints: watch the note itself appear at the
// wallet's own secret (see claimFromSecret).
export const claimMintedNote = async ( export const claimMintedNote = async (
prepared: PreparedMint, prepared: PreparedMint,
poll: PollOptions = {}, poll: PollOptions = {},
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<ClaimedNote> => claimFromSecret(prepared, poll, options)
if (prepared.mode === 'named') {
const noteSecret = prepared.noteSecret
if (!noteSecret) {
throw new Error('This mint was prepared in named mode without a note secret.')
}
return claimFromSecret({...prepared, noteSecret}, poll, options)
}
if (!prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
)
}
const verifyUrl = prepared.verifyUrl
const result = await pollVerifyUntilSettled(verifyUrl, poll, options)
// a settled report only means this wallet's invoice was paid if it's for
// the invoice this wallet actually requested
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.")
}
const preimage = result.preimage
if (!preimage || !isPreimage(preimage)) {
throw new Error('The payment settled but the service did not reveal the preimage.')
}
return claimFromPreimage(prepared, preimage, options)
}
// The claim itself, once the payment's preimage is known: the preimage IS // One bounded observation for durable recovery. An unpaid invoice is normal:
// the note secret. The claim (an informational GET) puts that secret on // keep the staged output and try again at the next wallet/service activation.
// the wire, and the mint has known it since it generated the invoice - so export type StagedMintRecovery =
// the fresh note is rotated immediately and unconditionally (observer | {readonly state: 'unminted'}
// race: anyone who saw the unpaid invoice knows the payment hash), before | {readonly state: 'pending'}
// anything else happens with it. | {readonly state: 'spent'}
export const claimFromPreimage = async ( | {readonly state: 'minted'; readonly note: NewBearer}
claim: ClaimTarget,
preimage: string, export const recoverStagedMintOutput = async (
staged: Bearer,
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<StagedMintRecovery> => {
// declare the invoiced amount (a claim - not yet confirmed) so the note const noteSecret = noteK1(staged.url)
// is self-describing even before the verifying GET below const expectedNoteValueMsat = noteDeclaredAmount(staged.url)
const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat) if (!staged.pendingMint || !noteSecret || expectedNoteValueMsat === null) {
throw new Error('The pending mint output is malformed.')
}
const withdrawLink = new URL(staged.url)
withdrawLink.searchParams.delete('k1')
withdrawLink.searchParams.delete('amount')
withdrawLink.searchParams.delete('sig')
assertFundOwner(options) assertFundOwner(options)
// the service's maxWithdrawable is authoritative - SERVICE's own fee const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options))
// math might not match this wallet's estimate, and the note is worth if (claim.state === 'unminted') return {state: 'unminted'}
// exactly maxWithdrawable regardless if (claim.state === 'pending') return {state: 'pending'}
const noteInfo = await fetchNoteInfo(declaredUrl, options) if (claim.state === 'spent') return {state: 'spent'}
const mintPubkey = noteInfo.mintPubkey ?? claim.mintPubkey if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) {
const base: NewBearer = { throw new Error('The mint did not return the pending output details.')
url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable), }
callback: noteInfo.callback, const note: NewBearer = {
amount: noteInfo.maxWithdrawable, url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true, verified: true,
} }
if (mintPubkey) base.mintPubkey = mintPubkey if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey
return {state: 'minted', note}
let url = base.url
let rotated = true
let possibleCopy: NewBearer | undefined
let rotationError: string | undefined
try {
const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options)
url = withNewK1(declaredUrl, rotatedNote.k1, noteInfo.maxWithdrawable, rotatedNote.signature)
} catch (err) {
rotated = false
if (err instanceof AmbiguousMutationError) {
// the rotate request may have landed despite the failure - the fresh
// secret it carried is then the only copy of this note
const outcome = await probeBurnedNote(declaredUrl, options)
if (outcome === 'gone') {
// the burn landed - adopt the fresh secret as the note
url = withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
// can't tell: the preimage note is returned either way - the
// possible rotated copy goes alongside it, both refreshable
possibleCopy = {
url: withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`
} else {
rotationError = err.message
}
} else {
// A classified refusal can still be a LANDED rotate: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// "Already spent" then PROVES the preimage note is burned - the
// carried fresh secret is the only money left, so probe it before
// believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1) {
const outcome = await probeMutationOutput(declaredUrl, carried[0], options)
if (outcome === 'live') {
// the rotate landed and this answer was its retried twin
url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
possibleCopy = {
url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
// the preimage note itself may be burned - it cannot stay verified
base.verified = false
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.`
} else if (err instanceof NoteSpentError) {
// a genuine already-spent refusal with no live output: the note
// the service just described is burned - it cannot stay verified
base.verified = false
rotationError = err.message
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
}
}
const claimed: ClaimedNote = {note: {...base, url}, rotated}
if (possibleCopy) claimed.possibleCopy = possibleCopy
if (rotationError) claimed.rotationError = rotationError
return claimed
} }
+21 -29
View File
@@ -8,6 +8,7 @@ import {
NoteSpentError, NoteSpentError,
PendingNoteError, PendingNoteError,
decodeBolt11AmountMsat, decodeBolt11AmountMsat,
defaultRandomSecret,
fetchPayRequest, fetchPayRequest,
isBolt11Invoice, isBolt11Invoice,
meltNote, meltNote,
@@ -24,7 +25,7 @@ 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} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared' import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared'
export type PayOutcome = export type PayOutcome =
| 'settled' | 'settled'
@@ -41,16 +42,7 @@ export type PayResult = {
invoice: string invoice: string
amountMsat: number amountMsat: number
verifyUrl: string | null verifyUrl: string | null
// on failed-funds-returned, when the classification rotate succeeded:
// the returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked
// spent, this note added
rotatedNote?: NewBearer rotatedNote?: NewBearer
// a fresh secret rescued from an ambiguous rotate during outcome
// classification - the caller must track it unverified; if the rotate
// landed, this is the only copy of the (returned) funds
rescuedNote?: NewBearer rescuedNote?: NewBearer
} }
@@ -67,6 +59,9 @@ 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']
// Called before the classification rotate puts its chosen output secret
// on the wire. Production callers durably link it to the carved source.
onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise<void>
} }
// A melt's resolved promise only means the payment is in flight; the // A melt's resolved promise only means the payment is in flight; the
@@ -82,13 +77,15 @@ export type PayOptions = {
export const payWithBearers = async ( export const payWithBearers = async (
bearers: Bearer[], bearers: Bearer[],
input: string, input: string,
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {}, {amountMsat, poll = {}, kit = {}, assertOwner, onCarve, onReturnReady}: PayOptions = {},
): Promise<PayResult> => { ): Promise<PayResult> => {
const options: FundOperationOptions = { // the forced mutation policy (see shared.ts) covers the carve, the melt,
// and the classification rotate regardless of caller kit options
const options: FundOperationOptions = withMutationSafety({
...kit, ...kit,
...(assertOwner ? {assertOwner} : {}), ...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}), ...(onCarve ? {onCarve} : {}),
} })
let invoice: string let invoice: string
let amount: number let amount: number
const trimmed = input.trim() const trimmed = input.trim()
@@ -159,20 +156,21 @@ export const payWithBearers = async (
} }
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
} catch { } catch {
// the verify budget ran out - probe the note itself with a rotate: a // The verify budget ran out. Journal the exact output secret before the
// failed melt is only observable as the note becoming spendable again // classification rotate reaches the mint, so a crash can recover either
// the returned source or the rotated replacement.
const recoverySecret = kit.randomSecret?.() ?? defaultRandomSecret()
await onReturnReady?.(carve, recoverySecret)
try { try {
const rotated = await rotateNote(carve.note.callback, k1, options) const rotated = await rotateNote(carve.note.callback, k1, {
// the rotate succeeded, so the mint restored the note - and k1 had ...options,
// been on the wire since the melt, so the rotation doubles as the randomSecret: () => recoverySecret,
// required re-securing of the returned funds. Reported separately })
// from `carve` (see PayResult.rotatedNote): the carve may already be
// committed, and its note is what this rotate burned
return { return {
outcome: 'failed-funds-returned', outcome: 'failed-funds-returned',
carve, carve,
rotatedNote: { rotatedNote: {
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), url: withNewK1(carve.note.url, recoverySecret, amount, rotated.signature),
callback: carve.note.callback, callback: carve.note.callback,
amount, amount,
verified: true, verified: true,
@@ -184,20 +182,14 @@ export const payWithBearers = async (
} }
} catch (err) { } catch (err) {
if (err instanceof PendingNoteError) { if (err instanceof PendingNoteError) {
// still locked mid-melt - no outcome either way
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
} }
if (err instanceof NoteSpentError) { if (err instanceof NoteSpentError) {
// burned without a settled proof - the money is gone either way
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// the rotate's answer was lost. Had the note still been pending,
// the service would have said so cleanly - so the funds ARE back,
// but whether the rotation landed is unknown: the original k1 may
// be live, or the fresh secret may be the only copy. Surface both.
const rescuedNote: NewBearer = { const rescuedNote: NewBearer = {
url: withNewK1(carve.note.url, err.newSecrets[0], amount), url: withNewK1(carve.note.url, recoverySecret, amount),
callback: carve.note.callback, callback: carve.note.callback,
amount, amount,
verified: false, verified: false,
+6 -4
View File
@@ -17,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive' import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint' import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, probeMutationOutput} from './shared' import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared'
// 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,
@@ -27,13 +27,15 @@ export const receiveBearer = async (
existing: Bearer[], existing: Bearer[],
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<ClaimedNote> => {
// the forced mutation policy (see shared.ts) covers the rotate below
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}
} }
try { try {
assertFundOwner(options) assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note, options) const rotatedUrl = await secureReceivedNote(note, mutationOptions)
return {note: {...note, url: rotatedUrl}, rotated: true} return {note: {...note, url: rotatedUrl}, rotated: true}
} 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
@@ -44,7 +46,7 @@ export const receiveBearer = async (
// it before believing the refusal. // it before believing the refusal.
const carried = newSecretsOf(err) const carried = newSecretsOf(err)
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) { if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
const outcome = await probeMutationOutput(note.url, carried[0], options) const outcome = await probeMutationOutput(note.url, carried[0], mutationOptions)
if (outcome === 'live') { if (outcome === 'live') {
return { return {
note: {...note, url: withNewK1(note.url, carried[0], note.amount)}, note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
@@ -81,7 +83,7 @@ export const receiveBearer = async (
throw err throw err
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(note.url, options) const outcome = await probeBurnedNote(note.url, mutationOptions)
if (outcome === 'gone') { if (outcome === 'gone') {
return { return {
note: { note: {
+59 -12
View File
@@ -2,7 +2,15 @@
// every flow that waits on a payment uses, and the uncertainty type a lost // every flow that waits on a payment uses, and the uncertainty type a lost
// mutation answer surfaces as. // mutation answer surfaces as.
import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit' import {
fetchInvoiceVerification,
fetchNoteInfo,
noteK1,
noteSignature,
serverOf,
verifyNoteSignature,
withNewK1,
} from 'lnurlcash-kit'
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit' import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit'
import type {NewBearer} from '../types' import type {NewBearer} from '../types'
@@ -10,8 +18,9 @@ import type {CarveResult} from './carve'
// a mutation's answer was lost AND the probe could not tell whether it // a mutation's answer was lost AND the probe could not tell whether it
// landed - the possible outputs the fresh secrets would control, for the // landed - the possible outputs the fresh secrets would control, for the
// caller to track unverified alongside the (kept) inputs. Never dropped: // caller to track unverified alongside the kept inputs. A pre-wire carve
// if the mutation did land, these are the only money left. // checkpoint may already have persisted them, in which case this list is
// empty so callers do not add duplicate records.
export class UncertainOutcomeError extends Error { export class UncertainOutcomeError extends Error {
readonly possibleOutputs: NewBearer[] readonly possibleOutputs: NewBearer[]
constructor(message: string, possibleOutputs: NewBearer[]) { constructor(message: string, possibleOutputs: NewBearer[]) {
@@ -32,16 +41,54 @@ export class PollAbortedError extends Error {
export type FundOperationOptions = LnurlcashOptions & { export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void readonly assertOwner?: () => void
// called by a carve the moment its mutation has LANDED server-side (the // Two durable phases for a mutating carve. Before wire use, unverified
// inputs are burned, the outputs are known), before the flow moves on to // outputs are reported with `consumed: []`. After the mint mutation is
// anything slow or uncertain - the caller's one chance to durably commit // known to have landed, the already-staged note is reported again with
// the changeset so an abort during a long settlement wait can never // the inputs that can now be retired. A rejection in either phase stops
// strand the outputs or leave burned inputs looking spendable. If it // the flow; exact-note carves call neither phase.
// rejects, the flow stops BEFORE anything further is spent and the
// rejection propagates: the carve state is then landed-but-maybe-
// uncommitted, which the caller must surface loudly. Never called for a
// carve that mutated nothing.
readonly onCarve?: (carve: CarveResult) => void | Promise<void> readonly onCarve?: (carve: CarveResult) => void | Promise<void>
// the trusted signing keys a landed mutation output's signature is
// checked against before it may report verified: the mint's pinned
// current key AND its previous one (a just-rotated mint's last notes
// stay verifiable). Production supplies the trusted-mint registry's
// keys; an absent source (or empty list) keeps every landed output
// staged unverified rather than trusting it blindly.
readonly mintSignatureKeys?: MintSignatureKeys
}
// the trusted signing keys available for one mint server
export type MintSignatureKeys = (server: string) => readonly string[]
// The mutation safety policy, forced where the engine talks to a mint:
// every mutation must come back signed (the signature is the only offline
// proof the mint issued the output) and a lost answer is retried exactly
// once, byte-identically (the replay the pre-wire staging exists to make
// safe). Both are lnurlcash-kit's own defaults - restating them AFTER the
// caller spread means no caller's options object can quietly strip either
// guarantee.
export const withMutationSafety = <Options extends LnurlcashOptions>(options: Options): Options => ({
...options,
requireSignatures: true,
mutationRetries: 1,
})
// A landed mutation output earns verified:true only when its signature
// (carried as the URL's sig param) verifies against a trusted key for its
// server. Missing, malformed, or wrong-key signatures - or no trusted keys
// on file at all - leave the note staged unverified; a refresh can repair
// it later. The money is never at stake here (the note exists at the mint
// either way), only the offline-verifiable badge is.
export const landedNoteVerifies = (
noteUrl: string,
keysFor: MintSignatureKeys | undefined,
): boolean => {
if (!keysFor) return false
const k1 = noteK1(noteUrl)
const amountMsat = noteDeclaredAmount(noteUrl)
const signature = noteSignature(noteUrl)
if (!k1 || amountMsat === null || !signature) return false
const keys = keysFor(serverOf(noteUrl))
return keys.length > 0 && verifyNoteSignature(k1, amountMsat, signature, [...keys])
} }
export const assertFundOwner = (options: FundOperationOptions): void => { export const assertFundOwner = (options: FundOperationOptions): void => {
+129 -171
View File
@@ -3,20 +3,9 @@
// that exist: this wallet requests an invoice FROM the target mint // that exist: this wallet requests an invoice FROM the target mint
// (grossed up for its advertised mint fee, so the note that comes out // (grossed up for its advertised mint fee, so the note that comes out
// nets the requested amount), melts source notes to pay it, then claims // nets the requested amount), melts source notes to pay it, then claims
// the target note exactly like any other minted receive. The target // the target note exactly like any other minted receive. The target note
// invoice settling is the transfer's ground truth: it can only settle if // appearing at the wallet's pre-staged secret is the transfer's ground
// the source melt's payment arrived. How settling is observed and claimed // truth: it can only appear if the source melt's payment arrived.
// depends on the target's minting mode (see mint.ts):
//
// - UNNAMED target: the verify URL is the observation - its settled
// response reveals the preimage, which IS the note's secret, and the
// claim rotates off it immediately.
// - NAMED target: the note itself is the observation - the wallet named
// the output with its own secret at quote time, so the informational
// GET reporting 'minted' IS the settlement proof, and the note is
// already at a secret nobody else has seen. No verify URL is needed
// (but one is used to rescue the mint that took the hash and credited
// the preimage anyway).
// //
// When settlement never confirms, the source note itself is the oracle - // When settlement never confirms, the source note itself is the oracle -
// a successful rotate proves the melt never burned it (funds returned), // a successful rotate proves the melt never burned it (funds returned),
@@ -25,16 +14,16 @@
import { import {
AmbiguousMutationError, AmbiguousMutationError,
NoteSpentError, NoteSpentError,
NoteUnknownError,
PendingNoteError, PendingNoteError,
buildNoteUrl, buildNoteUrl,
decodeBolt11AmountMsat, defaultRandomSecret,
fetchInvoiceVerification, fetchNoteInfo,
isPreimage,
meltNote, meltNote,
newSecretsOf,
noteK1, noteK1,
requireNoteK1, requireNoteK1,
rotateNote, rotateNote,
sameInvoice,
serverOf, serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
@@ -42,11 +31,11 @@ import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import type {CarveResult} from './carve' import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve' import {ensureExactAmount} from './carve'
import type {ClaimedNote, PreparedMint} from './mint' import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint'
import {claimFromPreimage, pollMintClaim, 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} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared' import {assertFundOwner, withMutationSafety} from './shared'
export type TransferOutcome = export type TransferOutcome =
// the melt settled and the target note was claimed (and rotated) // the melt settled and the target note was claimed (and rotated)
@@ -85,13 +74,11 @@ export type TransferClaimMaterial = {
invoice: string invoice: string
withdrawLink: string withdrawLink: string
expectedNoteValueMsat: number expectedNoteValueMsat: number
// the preimage note, unverified, once the preimage is known - the note: NewBearer
// preimage IS the note secret; the caller must track it and retry // the wallet-chosen secret the target note lands at.
note?: NewBearer
// named target only: the wallet-chosen secret the target note lands at.
// The note exists at this secret from the moment the invoice settles - // The note exists at this secret from the moment the invoice settles -
// the caller can rebuild the note URL from it and retry the claim // the caller can rebuild the note URL from it and retry the claim
noteSecret?: string noteSecret: string
} }
export type TransferResult = { export type TransferResult = {
@@ -101,8 +88,8 @@ export type TransferResult = {
quote: TransferQuote quote: TransferQuote
// the invoice the source note was melted to pay // the invoice the source note was melted to pay
invoice: string invoice: string
// the target invoice's verify URL - an unnamed target's ground truth // the target invoice's optional verify URL (used for a payment receipt;
// (a named target may not serve one at all; the note itself is observed) // the staged note itself is the settlement observation)
verifyUrl: string | null verifyUrl: string | null
sourceServer: string sourceServer: string
targetServer: string targetServer: string
@@ -132,6 +119,10 @@ export type TransferOptions = {
// settlement wait, so an abort mid-wait never strands the carve (see // settlement wait, so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve) // FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve'] onCarve?: FundOperationOptions['onCarve']
// persists the target note before its hash reaches the invoice callback
persistOutput: PrepareMintOptions['persistOutput']
// Last durable checkpoint before the source melt can become irreversible.
onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise<void>
} }
// the claim retry material for a named target: the note at the wallet's // the claim retry material for a named target: the note at the wallet's
@@ -147,41 +138,113 @@ const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer =
return note return note
} }
const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => {
const eligible = bearers.filter(
(bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url),
)
const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer)
if (eligible.length > 0 && candidates.length === 0) {
throw new Error("That's the mint these notes are already on - pick a different target.")
}
const totals = new Map<string, number>()
for (const bearer of candidates) {
const server = serverOf(bearer.url)
totals.set(server, (totals.get(server) ?? 0) + bearer.amount)
}
if (![...totals.values()].some((total) => total >= grossMsat)) {
throw new Error('No mint holds enough verified, unspent balance to cover that amount.')
}
return candidates
}
export type PendingTransferSourceRecovery =
| {readonly state: 'pending'}
| {readonly state: 'spent'}
| {readonly state: 'returned'; readonly note: NewBearer}
export const recoverPendingTransferSource = async (
staged: Bearer,
source: Bearer,
options: FundOperationOptions = {},
): Promise<PendingTransferSourceRecovery> => {
// a recovery only ever PROBES the melt outcome and rotates - it never
// replays the melt itself; the forced policy pins signature/retry safety
const mutationOptions = withMutationSafety(options)
const recoverySecret = staged.pendingMint?.sourceRecoverySecret
if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.')
const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount)
assertFundOwner(options)
try {
const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions)
const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable),
callback: recovered.callback,
amount: recovered.maxWithdrawable,
verified: true,
}
if (source.mintPubkey) note.mintPubkey = source.mintPubkey
return {state: 'returned', note}
} catch (error) {
if (error instanceof NoteSpentError) return {state: 'spent'}
if (!(error instanceof NoteUnknownError)) throw error
}
assertFundOwner(options)
try {
const rotated = await rotateNote(source.callback, requireNoteK1(source.url), {
...mutationOptions,
randomSecret: () => recoverySecret,
})
const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature),
callback: source.callback,
amount: source.amount,
verified: true,
}
if (source.mintPubkey) note.mintPubkey = source.mintPubkey
return {state: 'returned', note}
} catch (error) {
if (
error instanceof PendingNoteError ||
error instanceof NoteSpentError ||
error instanceof AmbiguousMutationError ||
newSecretsOf(error).includes(recoverySecret)
) {
return {state: 'pending'}
}
throw error
}
}
export const transferBetweenMints = async ( export const transferBetweenMints = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
targetMint: string, targetMint: string,
{poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {}, {poll = {}, kit = {}, assertOwner, onCarve, persistOutput, onMeltReady}: TransferOptions,
): Promise<TransferResult> => { ): Promise<TransferResult> => {
const options: FundOperationOptions = { const options: FundOperationOptions = withMutationSafety({
...kit, ...kit,
...(assertOwner ? {assertOwner} : {}), ...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}), ...(onCarve ? {onCarve} : {}),
} })
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.')
} }
// resolving the target and requesting its invoice touches only the // resolving the target and requesting its invoice touches only the
// TARGET mint - a failure here (unreachable, no minting support, amount // TARGET mint - a failure here (unreachable, no minting support, amount
// out of range) leaves every source note untouched // out of range) leaves every source note untouched
const prepared = await prepareMint(targetMint, amountMsat, options) const prepared = await prepareMint(targetMint, amountMsat, {
if (prepared.mode === 'unnamed' && !prepared.verifyUrl) { ...options,
throw new Error( persistOutput,
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', beforePersist: ({grossMsat, server}) => {
) transferSources(bearers, server, grossMsat)
} },
})
const verifyUrl = prepared.verifyUrl const verifyUrl = prepared.verifyUrl
const targetServer = prepared.server const targetServer = prepared.server
// the source must be a DIFFERENT mint - value "moved" within one mint // the source must be a DIFFERENT mint - value "moved" within one mint
// goes nowhere (melt pays an invoice; the same mint's invoice just // goes nowhere (melt pays an invoice; the same mint's invoice just
// re-mints into itself, paying fees for nothing) // re-mints into itself, paying fees for nothing)
const eligible = bearers.filter( const offTarget = transferSources(bearers, targetServer, prepared.grossMsat)
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
)
const offTarget = eligible.filter((b) => serverOf(b.url) !== targetServer)
if (eligible.length > 0 && offTarget.length === 0) {
throw new Error("That's the mint these notes are already on - pick a different target.")
}
const quote: TransferQuote = { const quote: TransferQuote = {
requestedMsat: amountMsat, requestedMsat: amountMsat,
grossMsat: prepared.grossMsat, grossMsat: prepared.grossMsat,
@@ -197,25 +260,22 @@ export const transferBetweenMints = async (
invoice, invoice,
withdrawLink: prepared.withdrawLink, withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat, expectedNoteValueMsat: prepared.expectedNoteValueMsat,
} noteSecret: prepared.noteSecret,
if (prepared.noteSecret) { note: namedClaimNote(prepared, prepared.noteSecret),
claimMaterial.noteSecret = prepared.noteSecret
// a named target's note is claimable from the wallet's secret alone,
// the moment the invoice settles - track it unverified from the start,
// so a melt that settles after the budget is never a lost note
claimMaterial.note = namedClaimNote(prepared, prepared.noteSecret)
} }
// from here on the carve's fresh secrets exist only in this result - the // from here on the carve's fresh secrets exist only in this result - the
// 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)()
if (carve.consumed.length === 0) assertFundOwner(options) if (carve.consumed.length === 0) assertFundOwner(options)
await onMeltReady?.(carve, sourceRecoverySecret)
assertFundOwner(options)
try { try {
await meltNote(carve.note.callback, k1, invoice, options) await meltNote(carve.note.callback, k1, invoice, options)
} catch (err) { } catch (error) {
if (err instanceof NoteSpentError) { if (!(error instanceof Error)) throw error
// this melt names a single note, so this is unambiguous - it was if (error instanceof NoteSpentError) {
// already gone before the melt even started
return {...base, outcome: 'note-already-spent'} return {...base, outcome: 'note-already-spent'}
} }
// anything else - a clean refusal, a dropped response, a lost answer - // anything else - a clean refusal, a dropped response, a lost answer -
@@ -223,119 +283,16 @@ export const transferBetweenMints = async (
// payment arrived, and the source probe tells the rest // payment arrived, and the source probe tells the rest
} }
try { try {
if (prepared.mode === 'named') {
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('The target mint was prepared without a note secret.')
// the note appearing at the wallet's own secret IS the settlement
// proof - the target only credits it once the melt's payment landed
try { try {
const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options) const claimed = await claimFromSecret(prepared, poll, options)
if ( return {...base, outcome: 'settled', mintedAtTarget: claimed}
claim.state !== 'minted' || } catch (error) {
claim.k1 !== noteSecret || if (error instanceof MintedNoteSpentError) {
claim.amountMsat === null ||
!claim.callback
) {
// 'spent' on a fresh wallet-chosen secret, a claim naming a
// secret other than the one polled (impossible through the kit,
// which returns the queried secret and rejects mismatched
// echoes - asserted anyway: building from it would track the
// wrong note), or an incomplete answer: the target misbehaved -
// the way back to the note is already in the claim material
return {...base, outcome: 'settled-claim-failed', claimMaterial} return {...base, outcome: 'settled-claim-failed', claimMaterial}
} }
const note: NewBearer = { throw new Error('The target note could not be confirmed at its staged secret.', {
url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat), cause: error,
callback: claim.callback, })
amount: claim.amountMsat,
verified: true,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
// no rotate: the secret never rode an invoice (see mint.ts)
return {...base, outcome: 'settled', mintedAtTarget: {note, rotated: true}}
} catch (err) {
// the note never appeared at the wallet's secret within budget.
// Rescue through the quote's verify when the target serves one:
// a mint that took the hash but credited the PREIMAGE (the
// mintToHashIgnoresH adversary) is claimable through the old path
if (verifyUrl) {
try {
const proof = await fetchInvoiceVerification(verifyUrl, options)
if (proof.settled && sameInvoice(proof.pr, invoice) && proof.preimage) {
if (isPreimage(proof.preimage)) {
try {
const claimed = await claimFromPreimage(prepared, proof.preimage, options)
return {...base, outcome: 'settled', mintedAtTarget: claimed}
} catch {
const note: NewBearer = {
url: buildNoteUrl(
prepared.withdrawLink,
proof.preimage,
prepared.expectedNoteValueMsat,
),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return {
...base,
outcome: 'settled-claim-failed',
claimMaterial: {...claimMaterial, note},
}
}
}
}
} catch {
// best-effort rescue - fall through to the source-note oracle
}
}
throw err
}
}
if (!verifyUrl) {
throw new Error(
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.',
)
}
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
// the proof-binding rule from pay.ts, extended for the gross-up: the
// verify URL is scoped to this invoice's payment hash, so an exact pr
// match binds it; short of that, a proof amount that is neither the
// invoiced gross nor the expected net belongs to another payment,
// while an undecodable one says nothing either way and is tolerated
const proofAmount = decodeBolt11AmountMsat(proof.pr)
if (
!sameInvoice(proof.pr, invoice) &&
proofAmount !== null &&
proofAmount !== prepared.grossMsat &&
proofAmount !== prepared.expectedNoteValueMsat
) {
return {...base, outcome: 'unknown-still-pending', claimMaterial}
}
if (!proof.preimage || !isPreimage(proof.preimage)) {
// settled, but the service won't reveal the preimage - the claim
// cannot complete automatically
return {...base, outcome: 'settled-claim-failed', claimMaterial}
}
try {
const claimed = await claimFromPreimage(prepared, proof.preimage, options)
return {...base, outcome: 'settled', mintedAtTarget: claimed}
} catch {
// the melt settled - the money is now the preimage note at the
// target and nowhere else; surface it rather than lose it
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat),
callback: '',
amount: prepared.expectedNoteValueMsat,
verified: false,
}
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
return {
...base,
outcome: 'settled-claim-failed',
claimMaterial: {...claimMaterial, note},
}
} }
} catch { } catch {
// the target invoice never settled within budget - the source note is // the target invoice never settled within budget - the source note is
@@ -345,7 +302,10 @@ export const transferBetweenMints = async (
// left but never arrived within the budget - the claim material stays // left but never arrived within the budget - the claim material stays
// as the way back to the money if the invoice settles later // as the way back to the money if the invoice settles later
try { try {
const rotated = await rotateNote(carve.note.callback, k1, options) const rotated = await rotateNote(carve.note.callback, k1, {
...options,
randomSecret: () => sourceRecoverySecret,
})
// the rotate succeeded: the melt provably never landed, and the // the rotate succeeded: the melt provably never landed, and the
// funds are back - re-secured, since the melt attempt put k1 on the // funds are back - re-secured, since the melt attempt put k1 on the
// wire. Reported separately from `carve` (see rotatedNote): the // wire. Reported separately from `carve` (see rotatedNote): the
@@ -367,10 +327,8 @@ export const transferBetweenMints = async (
return {...base, outcome: 'unknown-still-pending', claimMaterial} return {...base, outcome: 'unknown-still-pending', claimMaterial}
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// the rotate's answer was lost - pay.ts's reasoning: had the note // The rotate's answer was lost. Keep the transfer journal until
// still been pending the service would have said so, so the funds // recovery proves whether the original or recovery secret is live.
// ARE back, but whether the rotation landed is unknown. Surface
// the possible fresh copy alongside the unchanged note.
const rescuedNote: NewBearer = { const rescuedNote: NewBearer = {
url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount), url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount),
callback: carve.note.callback, callback: carve.note.callback,
@@ -378,7 +336,7 @@ export const transferBetweenMints = async (
verified: false, verified: false,
} }
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
return {...base, outcome: 'failed-funds-returned', rescuedNote} return {...base, outcome: 'unknown-still-pending', claimMaterial, rescuedNote}
} }
return {...base, outcome: 'unknown-still-pending', claimMaterial} return {...base, outcome: 'unknown-still-pending', claimMaterial}
} }
+15
View File
@@ -35,6 +35,15 @@ export type Bearer = {
// blank mirror, kept only so this bearer displays like any other // blank mirror, kept only so this bearer displays like any other
// (amount/host/label/state) // (amount/host/label/state)
deviceId?: string deviceId?: string
// A wallet-chosen mint output persisted before invoice creation. It stays
// zero-valued until the mint confirms it; transfers also remember the
// source record that must be locked when a late settlement is recovered.
pendingMint?: {
sourceBearerId?: string
sourceRecoverySecret?: string
mintPubkey?: string
retireAfter?: number
}
createdAt: number createdAt: number
updatedAt: number updatedAt: number
} }
@@ -46,4 +55,10 @@ export type NewBearer = {
verified: boolean verified: boolean
mintPubkey?: string mintPubkey?: string
deviceId?: string deviceId?: string
pendingMint?: {
sourceBearerId?: string
sourceRecoverySecret?: string
mintPubkey?: string
retireAfter?: number
}
} }
+350
View File
@@ -0,0 +1,350 @@
import { createPinia, setActivePinia } from 'pinia';
import { buildNoteUrl } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { NewBearer } from '@/lnurlcash/types';
import { useWalletStore } from './wallet';
type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [];
const mint = async (): Promise<Mint> => {
const instance = await createMockMint();
mints.push(instance);
return instance;
};
const stagedNote = (
instance: Mint,
secret: string,
sourceBearerId?: string,
sourceRecoverySecret?: string,
): NewBearer => ({
url: buildNoteUrl(`${instance.url}/w`, secret, 21_000),
callback: '',
amount: 0,
verified: false,
pendingMint: sourceBearerId ? { sourceBearerId, sourceRecoverySecret } : {},
});
const liveNote = (instance: Mint, secret: string): NewBearer => ({
url: buildNoteUrl(`${instance.url}/w`, secret, 21_000),
callback: `${instance.url}/w/cb`,
amount: 21_000,
verified: true,
});
// a present-but-non-serializing LockManager fake: walletFunds serializes its
// own mutations, so single-store tests only need locks to EXIST
const stubPassthroughLocks = (): void => {
vi.stubGlobal('navigator', {
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
});
};
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
stubPassthroughLocks();
stubLocalStorage();
setActivePinia(createPinia());
});
afterEach(async () => {
await Promise.all(mints.splice(0).map((instance) => instance.close()));
});
describe('pending mint recovery', () => {
it('keeps an unpaid staged output at zero value', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const [staged] = await wallet.addBearers(
[stagedNote(instance, 'a'.repeat(64))],
wallet.captureOwnerFence(),
);
if (!staged) throw new Error('Expected a staged bearer.');
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers).toEqual([staged]);
expect(wallet.balanceMsat).toBe(0);
});
it('retires an unminted NWC output after its safe settlement window', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const note = stagedNote(instance, '7'.repeat(64));
const [staged] = await wallet.addBearers(
[{ ...note, pendingMint: { retireAfter: Math.floor(Date.now() / 1000) - 1 } }],
wallet.captureOwnerFence(),
);
if (!staged) throw new Error('Expected a staged bearer.');
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false);
});
it('finalizes a paid stage under the same id and spends its transfer source atomically', async () => {
const storage = stubLocalStorage();
const instance = await mint();
const secret = 'b'.repeat(64);
const wallet = useWalletStore();
await wallet.create();
const [source] = await wallet.addBearers(
[liveNote(instance, 'c'.repeat(64))],
wallet.captureOwnerFence(),
);
if (!source) throw new Error('Expected a source bearer.');
const [staged] = await wallet.addBearers(
[stagedNote(instance, secret, source.id)],
wallet.captureOwnerFence(),
);
if (!staged) throw new Error('Expected a staged bearer.');
instance.state.creditNote(secret, 21_000);
const writes = vi.spyOn(storage, 'setItem');
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(writes.mock.calls.filter(([key]) => key === 'sattle_funds_v2')).toHaveLength(1);
expect(wallet.bearers.find((bearer) => bearer.id === source.id)?.spent).toBe(true);
expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({
amount: 21_000,
callback: `${instance.url}/w/cb`,
verified: true,
pendingMint: undefined,
});
expect(wallet.balanceMsat).toBe(21_000);
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.balanceMsat).toBe(21_000);
});
it('rejects finalization when the staged record is missing', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
await expect(
wallet.finalizeStagedMintOutput(
'missing',
liveNote(instance, 'd'.repeat(64)),
wallet.captureOwnerFence(),
),
).rejects.toThrow('record no longer exists');
});
it('reserves a transfer source in the same write that links its staged output', async () => {
const storage = stubLocalStorage();
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const [source] = await wallet.addBearers(
[liveNote(instance, 'e'.repeat(64))],
wallet.captureOwnerFence(),
);
const [staged] = await wallet.addBearers(
[stagedNote(instance, 'f'.repeat(64))],
wallet.captureOwnerFence(),
);
if (!source || !staged) throw new Error('Expected source and staged bearers.');
const writes = vi.spyOn(storage, 'setItem');
const recoverySecret = '2'.repeat(64);
await wallet.reserveStagedMintSource(
staged.id,
source.id,
recoverySecret,
wallet.captureOwnerFence(),
);
expect(writes.mock.calls.filter(([key]) => key === 'sattle_funds_v2')).toHaveLength(1);
expect(wallet.bearers.find((bearer) => bearer.id === source.id)?.spent).toBe(true);
expect(wallet.bearers.find((bearer) => bearer.id === staged.id)?.pendingMint).toEqual({
sourceBearerId: source.id,
sourceRecoverySecret: recoverySecret,
});
});
it('rejects a second reservation of the same source', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const [source] = await wallet.addBearers(
[liveNote(instance, '3'.repeat(64))],
wallet.captureOwnerFence(),
);
const [first, second] = await wallet.addBearers(
[stagedNote(instance, '4'.repeat(64)), stagedNote(instance, '5'.repeat(64))],
wallet.captureOwnerFence(),
);
if (!source || !first || !second) throw new Error('Expected source and staged bearers.');
await wallet.reserveStagedMintSource(
first.id,
source.id,
'6'.repeat(64),
wallet.captureOwnerFence(),
);
await expect(
wallet.reserveStagedMintSource(
second.id,
source.id,
'7'.repeat(64),
wallet.captureOwnerFence(),
),
).rejects.toThrow(/already reserved/i);
});
it('re-secures a live linked source after a crash before melt', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const sourceSecret = '8'.repeat(64);
const recoverySecret = '9'.repeat(64);
instance.state.creditNote(sourceSecret, 21_000);
const [source] = await wallet.addBearers(
[liveNote(instance, sourceSecret)],
wallet.captureOwnerFence(),
);
const [staged] = await wallet.addBearers(
[stagedNote(instance, 'a'.repeat(64))],
wallet.captureOwnerFence(),
);
if (!source || !staged) throw new Error('Expected source and staged bearers.');
await wallet.reserveStagedMintSource(
staged.id,
source.id,
recoverySecret,
wallet.captureOwnerFence(),
);
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false);
expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({
amount: 21_000,
verified: true,
});
expect(wallet.balanceMsat).toBe(21_000);
expect(instance.state.noteState(sourceSecret)).toBe('burned');
expect(instance.state.noteState(recoverySecret)).toBe('outstanding');
});
it('adopts the journaled source secret after a recovery rotate landed before a crash', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const sourceSecret = 'd'.repeat(64);
const recoverySecret = 'e'.repeat(64);
instance.state.creditNote(recoverySecret, 21_000);
const [source] = await wallet.addBearers(
[liveNote(instance, sourceSecret)],
wallet.captureOwnerFence(),
);
const [staged] = await wallet.addBearers(
[stagedNote(instance, 'f'.repeat(64), source?.id, recoverySecret)],
wallet.captureOwnerFence(),
);
if (!source || !staged) throw new Error('Expected source and staged bearers.');
await wallet.markSpent(source.id, wallet.captureOwnerFence());
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false);
expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({
amount: 21_000,
verified: true,
});
expect(wallet.balanceMsat).toBe(21_000);
});
it('deduplicates a returned source saved before its transfer journal was removed', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const sourceSecret = '1'.repeat(64);
const recoverySecret = '2'.repeat(64);
instance.state.creditNote(recoverySecret, 21_000);
const [source] = await wallet.addBearers(
[liveNote(instance, sourceSecret)],
wallet.captureOwnerFence(),
);
const [staged] = await wallet.addBearers(
[stagedNote(instance, '3'.repeat(64), source?.id, recoverySecret)],
wallet.captureOwnerFence(),
);
await wallet.addBearers([liveNote(instance, recoverySecret)], wallet.captureOwnerFence());
if (!source || !staged) throw new Error('Expected source and staged bearers.');
await wallet.markSpent(source.id, wallet.captureOwnerFence());
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false);
expect(wallet.bearers.filter((bearer) => bearer.url.includes(recoverySecret))).toHaveLength(1);
expect(wallet.balanceMsat).toBe(21_000);
});
it('returns a live source even when the staged target cannot be observed', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const sourceSecret = '4'.repeat(64);
const recoverySecret = '5'.repeat(64);
instance.state.creditNote(sourceSecret, 21_000);
const [source] = await wallet.addBearers(
[liveNote(instance, sourceSecret)],
wallet.captureOwnerFence(),
);
if (!source) throw new Error('Expected a source bearer.');
const [staged] = await wallet.addBearers(
[
{
url: buildNoteUrl('http://127.0.0.1:1/w', '6'.repeat(64), 21_000),
callback: '',
amount: 0,
verified: false,
pendingMint: { sourceBearerId: source.id, sourceRecoverySecret: recoverySecret },
},
],
wallet.captureOwnerFence(),
);
if (!staged) throw new Error('Expected a staged bearer.');
await wallet.markSpent(source.id, wallet.captureOwnerFence());
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false);
expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({
amount: 21_000,
verified: true,
});
expect(wallet.balanceMsat).toBe(21_000);
});
it('ends recovery when the staged target is definitively spent', async () => {
const instance = await mint();
const secret = '1'.repeat(64);
const wallet = useWalletStore();
await wallet.create();
const [staged] = await wallet.addBearers(
[stagedNote(instance, secret)],
wallet.captureOwnerFence(),
);
if (!staged) throw new Error('Expected a staged bearer.');
instance.state.creditNote(secret, 21_000);
instance.state.settleMelt(secret);
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({
spent: true,
pendingMint: undefined,
});
});
});
+262 -6
View File
@@ -1,6 +1,6 @@
// allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. // allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary.
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { deriveCashSecret, serverOf } from 'lnurlcash-kit'; import { deriveCashSecret, noteK1, serverOf } from 'lnurlcash-kit';
import type { CashNode } from 'lnurlcash-kit'; import type { CashNode } from 'lnurlcash-kit';
import { import {
@@ -15,7 +15,9 @@ import type { BearerChangeset } from '@/lnurlcash/storage';
import { lockTrustedMint } from '@/lnurlcash/trustedMints'; import { lockTrustedMint } from '@/lnurlcash/trustedMints';
import type { Bearer, NewBearer } from '@/lnurlcash/types'; import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { msatToSats } from '@/lnurlcash/units'; import { msatToSats } from '@/lnurlcash/units';
import { recoverPendingTransferSource } from '@/lnurlcash/ops';
import type { CarveResult } from '@/lnurlcash/ops'; import type { CarveResult } from '@/lnurlcash/ops';
import { recoverStagedMintOutput } from '@/lnurlcash/ops/mint';
import type { WalletOwnerFence } from './walletOwnerFence'; import type { WalletOwnerFence } from './walletOwnerFence';
export class TrustedMintPostCommitError extends Error { export class TrustedMintPostCommitError extends Error {
@@ -160,10 +162,30 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
const commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise<Bearer> => const commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise<Bearer> =>
mutate(async () => { mutate(async () => {
const existing = bearers.value.find((bearer) => bearer.url === carve.note.url); // a carve checkpoint is identified by its output secret, not the
// whole URL: the landed phase's note carries the mint's signature in
// the URL that the pre-wire staged phase could not have had yet
const sameOutput = (bearer: Bearer, note: NewBearer): boolean => {
const k1 = noteK1(note.url);
return (
k1 !== null &&
serverOf(bearer.url) === serverOf(note.url) &&
noteK1(bearer.url) === k1
);
};
const staged = bearers.value.find((bearer) => sameOutput(bearer, carve.note));
const change = carve.change;
const additions: NewBearer[] = []; const additions: NewBearer[] = [];
if (!existing) additions.push(carve.note); if (!staged) additions.push(carve.note);
if (carve.change) additions.push(carve.change); if (change && !bearers.value.some((bearer) => sameOutput(bearer, change))) {
additions.push(change);
}
// the landed checkpoint re-reports the staged note with its landed
// signature and verification state - refresh the record in place so
// a replayed checkpoint is a no-op rather than a duplicate
const upserts: Bearer[] = staged
? [{ ...staged, ...carve.note, id: staged.id, updatedAt: Date.now() }]
: [];
ownerFence(); ownerFence();
const next = await applyBearerChangeset( const next = await applyBearerChangeset(
options.requireKey(), options.requireKey(),
@@ -171,13 +193,14 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
{ {
add: additions, add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id), markSpent: carve.consumed.map((bearer) => bearer.id),
upsert: upserts,
}, },
{ beforeCommit: ownerFence }, { beforeCommit: ownerFence },
); );
const added = next.slice(0, additions.length); const added = next.slice(0, additions.length);
bearers.value = next; bearers.value = next;
await lockCommittedBearers(added); await lockCommittedBearers([...added, ...upserts]);
const committed = existing ?? added[0]; const committed = upserts[0] ?? added[0];
if (!committed) throw new Error('The carved note was not tracked.'); if (!committed) throw new Error('The carved note was not tracked.');
return committed; return committed;
}); });
@@ -206,6 +229,234 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
} }
}); });
const reserveStagedMintSource = (
stagedId: string,
sourceId: string,
sourceRecoverySecret: string,
ownerFence: WalletOwnerFence,
): Promise<void> =>
mutate(async () => {
options.setAuxiliaryError('');
const staged = bearers.value.find((bearer) => bearer.id === stagedId);
const source = bearers.value.find((bearer) => bearer.id === sourceId);
if (!staged || !source) throw new BearerNotFoundError();
if (!staged.pendingMint) throw new Error('The wallet record is not a pending mint output.');
if (source.spent) throw new Error('The source note is already reserved.');
ownerFence();
const linked: Bearer = {
...staged,
pendingMint: {
...staged.pendingMint,
sourceBearerId: source.id,
sourceRecoverySecret,
},
updatedAt: Date.now(),
};
bearers.value = await applyBearerChangeset(
options.requireKey(),
bearers.value,
{ add: [], markSpent: [source.id], upsert: [linked] },
{ beforeCommit: ownerFence },
);
});
const restoreStagedMintSource = (
stagedId: string,
note: NewBearer,
ownerFence: WalletOwnerFence,
): Promise<void> =>
mutate(async () => {
const staged = bearers.value.find((bearer) => bearer.id === stagedId);
if (!staged?.pendingMint?.sourceBearerId) throw new BearerNotFoundError();
const recoveryK1 = noteK1(note.url);
const existing = recoveryK1
? bearers.value.find(
(bearer) =>
bearer.id !== staged.id &&
serverOf(bearer.url) === serverOf(note.url) &&
noteK1(bearer.url) === recoveryK1,
)
: undefined;
ownerFence();
const additions = existing ? [] : [note];
const next = await applyBearerChangeset(
options.requireKey(),
bearers.value,
{
add: additions,
markSpent: [staged.pendingMint.sourceBearerId],
remove: [staged.id],
upsert: existing
? [{ ...existing, ...note, id: existing.id, updatedAt: Date.now() }]
: [],
},
{ beforeCommit: ownerFence },
);
bearers.value = next;
const committed = existing
? next.find((bearer) => bearer.id === existing.id)
: next[0];
if (!committed) throw new BearerNotFoundError();
await lockCommittedBearers([committed]);
});
const finalizeStagedMintOutput = async (
id: string,
note: NewBearer,
ownerFence: WalletOwnerFence,
): Promise<void> =>
mutate(async () => {
options.setAuxiliaryError('');
const current = bearers.value.find((bearer) => bearer.id === id);
if (!current) throw new BearerNotFoundError();
if (!current.pendingMint) {
if (
current.url === note.url &&
current.callback === note.callback &&
current.amount === note.amount &&
current.verified === note.verified &&
current.mintPubkey === note.mintPubkey
) {
return;
}
throw new Error('The wallet record is not a pending mint output.');
}
const sourceBearerId = current.pendingMint.sourceBearerId;
if (sourceBearerId && !bearers.value.some((bearer) => bearer.id === sourceBearerId)) {
throw new BearerNotFoundError();
}
ownerFence();
const confirmed: Bearer = {
...current,
...note,
pendingMint: undefined,
updatedAt: Date.now(),
};
const next = await applyBearerChangeset(
options.requireKey(),
bearers.value,
{
add: [],
markSpent: sourceBearerId ? [sourceBearerId] : [],
upsert: [confirmed],
},
{ beforeCommit: ownerFence },
);
bearers.value = next;
await lockCommittedBearers([confirmed]);
});
const finalizeSpentStagedMintOutput = (id: string, ownerFence: WalletOwnerFence): Promise<void> =>
mutate(async () => {
options.setAuxiliaryError('');
const current = bearers.value.find((bearer) => bearer.id === id);
if (!current) throw new BearerNotFoundError();
if (!current.pendingMint) return;
const sourceBearerId = current.pendingMint.sourceBearerId;
ownerFence();
const spent: Bearer = {
...current,
pendingMint: undefined,
spent: true,
updatedAt: Date.now(),
};
bearers.value = await applyBearerChangeset(
options.requireKey(),
bearers.value,
{
add: [],
markSpent: sourceBearerId ? [sourceBearerId] : [],
upsert: [spent],
},
{ beforeCommit: ownerFence },
);
});
let recoveryRun: Promise<void> | null = null;
const recoverPendingMints = (ownerFence: WalletOwnerFence): Promise<void> => {
if (recoveryRun) return recoveryRun;
recoveryRun = (async () => {
for (const staged of [...bearers.value]) {
if (!staged.pendingMint || staged.spent) continue;
try {
const recoverLinkedSource = async (): Promise<void> => {
const sourceBearerId = staged.pendingMint?.sourceBearerId;
if (!sourceBearerId) return;
const source = bearers.value.find((bearer) => bearer.id === sourceBearerId);
if (!source) throw new BearerNotFoundError();
const sourceRecovery = await recoverPendingTransferSource(staged, source, {
assertOwner: ownerFence,
});
switch (sourceRecovery.state) {
case 'pending':
break;
case 'returned':
await restoreStagedMintSource(staged.id, sourceRecovery.note, ownerFence);
break;
case 'spent':
await finalizeSpentStagedMintOutput(staged.id, ownerFence);
break;
}
};
const recovered = await recoverStagedMintOutput(staged, { assertOwner: ownerFence });
switch (recovered.state) {
case 'unminted':
if (
!staged.pendingMint.sourceBearerId &&
staged.pendingMint.retireAfter !== undefined &&
staged.pendingMint.retireAfter <= Math.floor(Date.now() / 1000)
) {
await removeNote(staged.id, ownerFence);
break;
}
await recoverLinkedSource();
break;
case 'pending':
await recoverLinkedSource();
break;
case 'minted':
await finalizeStagedMintOutput(staged.id, recovered.note, ownerFence);
break;
case 'spent':
await finalizeSpentStagedMintOutput(staged.id, ownerFence);
break;
}
} catch (error) {
ownerFence();
if (staged.pendingMint.sourceBearerId) {
try {
const source = bearers.value.find(
(bearer) => bearer.id === staged.pendingMint?.sourceBearerId,
);
if (!source) throw new BearerNotFoundError();
const sourceRecovery = await recoverPendingTransferSource(staged, source, {
assertOwner: ownerFence,
});
if (sourceRecovery.state === 'returned') {
await restoreStagedMintSource(staged.id, sourceRecovery.note, ownerFence);
continue;
}
} catch (sourceError) {
ownerFence();
if (sourceError instanceof TrustedMintPostCommitError) {
options.setAuxiliaryError(sourceError.message);
continue;
}
}
}
options.setAuxiliaryError(
error instanceof TrustedMintPostCommitError
? error.message
: 'A pending mint output could not be refreshed. It will be retried later.',
);
}
}
})().finally(() => {
recoveryRun = null;
});
return recoveryRun;
};
const markSpent = async ( const markSpent = async (
id: string, id: string,
ownerFence: WalletOwnerFence, ownerFence: WalletOwnerFence,
@@ -297,6 +548,11 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
applyChangeset, applyChangeset,
commitCarve, commitCarve,
updateBearer, updateBearer,
reserveStagedMintSource,
restoreStagedMintSource,
finalizeStagedMintOutput,
finalizeSpentStagedMintOutput,
recoverPendingMints,
allocateCashSecrets, allocateCashSecrets,
markSpent, markSpent,
removeNote, removeNote,