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
// 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`)
// exactly when - and only when - the payRequest advertised the capability
// (commentAllowed >= 64 or mintToHash): a new-generation mint
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must
// see the byte-identical request this wallet always sent.
// only when the payRequest advertises commentAllowed >= 64. The wallet's
// output secret must be durably staged before that quote request is sent;
// mintToHash alone is only an additive receipt extension, not permission
// to mint.
import {afterEach, describe, expect, it} from 'vitest'
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 persistOutput = async (): Promise<void> => undefined
// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
// 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 () => {
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 payRequest never carried it
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
// explicit LUD-17 transport form
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:\/\//)
// and the link is fully usable: settle the invoice, claim the note
@@ -150,9 +151,9 @@ describe('withdraw-link forms', () => {
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
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 () => {
@@ -166,13 +167,14 @@ describe('withdraw-link forms', () => {
minSendable: 1_000,
maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note',
commentAllowed: 64,
metadata: '[]',
},
],
// amount-less invoice: the kit skips its amount cross-check
['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')
// the mint-address payLink is authoritative - the payRequest came from it
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)
}
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 prepared = await prepareMint('mint@mint.example', 21_000, {
await expect(
prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
})
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// an old mint sees the byte-identical request this wallet always sent
const url = callbackUrl(seen)
expect(url.searchParams.get('comment')).toBeNull()
expect(url.searchParams.get('h')).toBeNull()
persistOutput,
}),
).rejects.toThrow(/64-character output commitment/i)
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
})
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 events: string[] = []
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
if (!noteSecret) throw new Error('named mode without a note secret')
const url = callbackUrl(seen)
// both spellings go out together: `comment` is the LUD-25 form
// 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(h).toBe(comment)
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 prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
await expect(
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
if (!noteSecret) throw new Error('named mode without a note secret')
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret))
it('rejects mintToHash without the mandatory comment capacity', async () => {
const seen: string[] = []
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 {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {fetchNoteInfo, noteK1, noteSignature} from 'lnurlcash-kit'
import type {Bearer} from './types'
import type {CarveResult} from './ops'
import {UncertainOutcomeError, ensureExactAmount} from './ops'
import {CarveCheckpointRequiredError, UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness'
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
@@ -23,7 +23,10 @@ describe('ensureExactAmount', () => {
const instance = await mint()
const k1 = secret('02')
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.verified).toBe(true)
expect(result.change?.amount).toBe(16_000)
@@ -39,7 +42,7 @@ describe('ensureExactAmount', () => {
const instance = await mint()
const first = await makeBearer(instance, secret('03'), 3_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.change).toBeUndefined()
expect(result.consumed).toHaveLength(2)
@@ -53,7 +56,7 @@ describe('ensureExactAmount', () => {
const instance = await mint()
const first = await makeBearer(instance, secret('05'), 3_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.change?.amount).toBe(2_000)
expect(result.consumed).toHaveLength(2)
@@ -68,7 +71,7 @@ describe('ensureExactAmount', () => {
const spentBearer = await makeBearer(instance, secret('07'), 50_000)
const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)),
callback: '',
verified: false,
}
await expect(
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000),
@@ -85,7 +88,7 @@ describe('ensureExactAmount', () => {
const instance = await mint({dropAfterMutation: true})
const k1 = secret('10')
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 changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
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 () => {
const instance = await mint({dropAfterMutation: true})
const instance = await mint()
const k1 = secret('11')
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
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'))
}
const checkpoints: CarveResult[] = []
const failure = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch,
fetch: everythingDyingFetch,
onCarve: (carve) => {
checkpoints.push(carve)
},
}).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(UncertainOutcomeError)
if (!(failure instanceof UncertainOutcomeError)) throw failure
expect(failure.possibleOutputs).toHaveLength(2)
const first = requiredValue(failure.possibleOutputs[0])
const second = requiredValue(failure.possibleOutputs[1])
expect(first.amount).toBe(5_000)
expect(second.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)
expect(failure.possibleOutputs).toEqual([])
expect(checkpoints).toHaveLength(1)
expect(checkpoints[0]?.consumed).toEqual([])
expect(checkpoints[0]?.note.amount).toBe(5_000)
expect(checkpoints[0]?.change?.amount).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 bearer = await makeBearer(instance, secret('12'), 21_000)
const seen: CarveResult[] = []
@@ -129,13 +139,82 @@ describe('ensureExactAmount', () => {
onCarve: (carve) => {
seen.push(carve)
},
mintSignatureKeys: signatureKeysFor(instance),
})
// the hook saw exactly the returned result, once
expect(seen).toHaveLength(1)
expect(seen[0]).toBe(result)
expect(seen).toHaveLength(2)
expect(seen[0]?.note.verified).toBe(false)
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)
})
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 () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('13'), 21_000)
@@ -149,7 +228,17 @@ describe('ensureExactAmount', () => {
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 bearer = await makeBearer(instance, secret('14'), 21_000)
await expect(
@@ -159,5 +248,6 @@ describe('ensureExactAmount', () => {
},
}),
).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 {requiredValue} from './test-utils'
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}$/
// the invoice whose quote the wallet bound to its own output hash
const boundInvoice = (instance: Mint) =>
[...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 () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
const noteSecret = requiredValue(prepared.noteSecret)
expect(noteSecret).toMatch(HEX32)
// the mint bound the quote to sha256 of the wallet's secret - the kit
// sends it as both comment and h, the mock read one of them
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret))
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
})
expect(prepared.noteSecret).toMatch(HEX32)
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret))
})
it('claims a named output at the wallet secret, without a rotate', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const noteSecret = requiredValue(prepared.noteSecret)
it('claims the output at the wallet secret without rotating it', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
})
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.verified).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`)
expect(noteK1(claimed.note.url)).toBe(noteSecret)
// no rotate happened: the note still stands at the wallet's own
// secret, and the payment preimage keys nothing at all
expect(instance.state.noteState(noteSecret)).toBe('outstanding')
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding')
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 prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret)))
const preimage = await settleLastInvoice(instance)
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput,
})
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret))
await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
expect(instance.state.noteState(preimage)).toBeNull()
})
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')
expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32)
})
})
+12 -49
View File
@@ -10,33 +10,21 @@ import {
import {claimMintedNote, prepareMint, receiveBearer} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness'
describe('mint -> claim -> rotate', () => {
it('mints a note from a paid invoice and rotates it immediately', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.invoice).toMatch(/^lnbc/)
expect(prepared.verifyUrl).toBeTruthy()
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')
})
import {
makeBearer,
mint,
noteUrl,
persistOutput,
secret,
settleLastInvoice,
} from './ops.testHarness'
describe('mint fee', () => {
it('grosses the invoice up for an advertised mint fee', async () => {
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)
await settleLastInvoice(instance)
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
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000)
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 () => {
const payer = await mint()
const payee = await mint()
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,
kit: {fetch: payeeFetch},
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
})
expect(result.outcome).toBe('settled')
@@ -34,6 +52,7 @@ describe('payWithBearers', () => {
const bearer = await makeBearer(instance, secret('32'), 50_000)
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
onCarve: () => undefined,
})
expect(result.outcome).toBe('settled')
expect(instance.state.noteState(secret('32'))).toBe('burned')
@@ -51,14 +70,35 @@ describe('payWithBearers', () => {
})
expect(result.outcome).toBe('failed-funds-returned')
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)
expect(rotatedNote.verified).toBe(true)
const returnedK1 = requiredValue(noteK1(rotatedNote.url))
expect(instance.state.noteState(returnedK1)).toBe('outstanding')
expect(rotatedNote.amount).toBe(21_000)
expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding')
})
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 () => {
@@ -90,9 +130,8 @@ describe('payWithBearers', () => {
},
}),
).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened:
// nothing sits pending at the mint
expect(instance.state.noteState(secret('36'))).toBe('burned')
// The checkpoint failed before either split or melt reached the mint.
expect(instance.state.noteState(secret('36'))).toBe('outstanding')
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 {noteK1} from 'lnurlcash-kit'
import type {CarveResult} from './ops'
import {ensureExactAmount, receiveBearer} from './ops'
import {claimFromPreimage, prepareMint} from './ops/mint'
import {requiredValue} from './test-utils'
import {
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
settleLastInvoice,
} from './ops.testHarness'
import {makeBearer, mint, noteUrl, retryingCbFetch, secret} from './ops.testHarness'
// 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
@@ -24,7 +17,10 @@ describe('mutation retry refusals', () => {
const instance = await mint()
const k1 = secret('60')
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
// and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned')
@@ -41,7 +37,10 @@ describe('mutation retry refusals', () => {
const instance = await mint()
const a = await makeBearer(instance, secret('61'), 20_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(instance.state.noteState(secret('61'))).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')
})
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 () => {
const instance = await mint()
const senderK1 = secret('63')
@@ -83,8 +69,16 @@ describe('mutation retry refusals', () => {
const k1 = secret('64')
const bearer = await makeBearer(instance, k1, 50_000)
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
const checkpoints: CarveResult[] = []
await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
onCarve: (carve) => {
checkpoints.push(carve)
},
}),
).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-crash.cases'
import './ops.signature.cases'
import './ops.mint-named.cases'
import './ops.mint-receive.cases'
import './ops.pay.cases'
+9
View File
@@ -24,6 +24,15 @@ afterEach(async () => {
export const secret = (seed: string): string =>
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 =>
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 {hashK1, noteK1} from 'lnurlcash-kit'
import {transferBetweenMints} from './ops'
import type {CarveResult} from './ops'
import {transferBetweenMints as transferBetweenMintsEngine} from './ops'
import type {CarveResult, TransferOptions} from './ops'
import type {Bearer} from './types'
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', () => {
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 target = await mint({testHooks: true})
const k1 = secret('40')
@@ -31,10 +46,9 @@ describe('transferBetweenMints', () => {
expect(result.sourceServer).not.toBe(result.targetServer)
await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
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))
expect(newK1).not.toBe(preimage)
expect(target.state.noteState(newK1)).toBe('outstanding')
@@ -45,9 +59,15 @@ describe('transferBetweenMints', () => {
const target = await mint()
const k1 = secret('41')
const bearer = await makeBearer(source, k1, 5_000)
let persisted = false
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/)
expect(persisted).toBe(false)
expect(source.state.noteState(k1)).toBe('outstanding')
})
@@ -72,6 +92,22 @@ describe('transferBetweenMints', () => {
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 () => {
const source = await mint({unconfirmedMutation: true})
const target = await mint({testHooks: true})
@@ -85,26 +121,6 @@ describe('transferBetweenMints', () => {
expect(result.outcome).toBe('settled')
await expectBurned(source, k1)
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 () => {
@@ -140,9 +156,9 @@ describe('transferBetweenMints', () => {
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 target = await mint({commentAllowed: 64, testHooks: true})
const target = await mint({testHooks: true})
const k1 = secret('48')
const bearer = await makeBearer(source, k1, 21_000)
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
@@ -153,7 +169,6 @@ describe('transferBetweenMints', () => {
expect(result.outcome).toBe('settled')
await expectBurned(source, k1)
const claimed = requiredValue(result.mintedAtTarget)
expect(claimed.rotated).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.verified).toBe(true)
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 () => {
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 bearer = await makeBearer(source, k1, 21_000)
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)
})
it('rescues a named transfer through the preimage when the target credited it anyway', 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 () => {
it('fires onCarve before the carve or melt reaches the mint', async () => {
const source = await mint()
const target = await mint({testHooks: true})
const bearer = await makeBearer(source, secret('51'), 21_000)
@@ -228,16 +214,21 @@ describe('transferBetweenMints', () => {
poll: fastPoll,
onCarve: (carve) => {
seen.push(carve)
// at hook time the melt must NOT have started: the carved note is
// still outstanding (a melt would leave it pending)
if (carve.consumed.length === 0) {
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(bearer.url)))).toBe('burned')
}
},
})
await settleWhenRequested(target)
const result = await pending
expect(result.outcome).toBe('settled')
expect(seen).toHaveLength(1)
expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id])
expect(seen).toHaveLength(2)
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 () => {
@@ -252,9 +243,25 @@ describe('transferBetweenMints', () => {
},
}),
).rejects.toThrow(/commit failed/)
// the split landed (the input is burned) but the melt never happened:
// nothing sits pending at the source, nothing was ever paid
expect(source.state.noteState(k1)).toBe('burned')
// The checkpoint failed before either split or melt reached the source.
expect(source.state.noteState(k1)).toBe('outstanding')
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.
//
// Fund-critical invariants enforced across the flows (see the project plan):
// - rotate on every receive, and immediately after claiming a fresh mint
// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice
// knows the payment hash, and the mint necessarily saw the preimage -
// 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
// - rotate every externally received bearer. Minted notes are staged at a
// wallet-chosen secret before invoice creation and claimed there without
// rotating; the payment preimage is not bearer material
// - a note's declared amount is a claim; the service's maxWithdrawable is
// authoritative
// - 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
export {UncertainOutcomeError} from './ops/shared'
export type {PollOptions} from './ops/shared'
export {ensureExactAmount} from './ops/carve'
export type {FundOperationOptions, PollOptions} from './ops/shared'
export {
CarveCheckpointRequiredError,
ensureExactAmount,
UnsupportedMultiBatchMergeError,
} from './ops/carve'
export type {CarveResult} from './ops/carve'
export {prepareMint, claimMintedNote} from './ops/mint'
export type {PreparedMint, ClaimedNote} from './ops/mint'
export {
MintedNoteSpentError,
prepareMint,
claimMintedNote,
recoverStagedMintOutput,
} from './ops/mint'
export type {
PreparedMint,
PrepareMintOptions,
ClaimedNote,
StagedMintRecovery,
} from './ops/mint'
export {receiveBearer} from './ops/receiveBearer'
export {payWithBearers} from './ops/pay'
export type {PayOutcome, PayResult, PayOptions} from './ops/pay'
export {transferBetweenMints} from './ops/transfer'
export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer'
export type {
TransferClaimMaterial,
TransferOptions,
+134 -188
View File
@@ -2,31 +2,37 @@
// and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from.
//
// A carve whose mutation landed reports itself through options.onCarve
// BEFORE returning, so a caller about to do something slow (a settlement
// wait) can durably commit first - a flow aborted mid-wait must never
// leave burned inputs looking spendable or strand the fresh outputs.
// Every output secret is chosen and reported through options.onCarve BEFORE
// its hash reaches the mint. That first checkpoint stages possible outputs
// without retiring inputs; a second checkpoint retires the inputs only after
// the mutation is known to have landed.
import {
AmbiguousMutationError,
mergeNotes,
defaultRandomSecret,
mergeBatches,
newSecretsOf,
noteK1,
probeBurnedNote,
requireNoteK1,
serverOf,
settleNote,
splitNote,
withNewK1,
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import {mergeAmbiguitySafe} from './carveRecovery'
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
// `consumed` - the mint call already burned every consumed input
// server-side, so the outputs are the only money left and must be tracked
// first; a crash between the two must strand a duplicate, never a secret
// The same shape serves both checkpoint phases: the pre-wire phase carries
// note/change with an empty consumed list, and the landed phase carries the
// already-staged note plus the inputs the caller can now retire.
export type CarveResult = {
// the exact-amount note, ready to hand over or melt
note: NewBearer
@@ -37,6 +43,22 @@ export type CarveResult = {
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
// 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
@@ -46,12 +68,12 @@ export type CarveResult = {
//
// Execution, mirroring lnurl-wallet's SendDialog:
// - one note already exact: returned as-is, nothing burned
// - several notes summing exactly: one merge, then settle (reads the true
// post-fee value back and rotates, since the read put k1 on the wire)
// - several notes summing exactly: one merge; folds spanning multiple
// requests are rejected because partial progress needs a larger journal
// - 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
// settled for its true value; the target part carries the mint's
// signature and needs no settle
// split takes many k1s - no merge round trip first); change stays
// unverified until refresh because a second settle mutation would create
// another crash window
export const ensureExactAmount = async (
bearers: Bearer[],
amountMsat: number,
@@ -60,8 +82,11 @@ export const ensureExactAmount = async (
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
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(
(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
// 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 k1s = pick.map((b) => requireNoteK1(b.url))
// a carve that burned inputs reports itself the moment its outcome is
// known, before the caller's flow moves on to anything slow or
// uncertain - see FundOperationOptions.onCarve
const checkpoint = async (result: CarveResult): Promise<CarveResult> => {
await options.onCarve?.(result)
return result
const checkpoint = async (result: CarveResult): Promise<void> => {
if (!options.onCarve) {
throw new CarveCheckpointRequiredError()
}
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) {
// already exact - hand over the note itself, untouched
@@ -109,94 +138,94 @@ export const ensureExactAmount = async (
}
if (total === amountMsat) {
// merge path: many notes, exact sum - merge into one, then settle it
// (true value + fresh secret; a failed settle leaves an unverified
// note a refresh can repair, not a lost secret)
assertFundOwner(options)
const merged = await mergeAmbiguitySafe(base, k1s, total, options)
const unverified: NewBearer = {
url: withNewK1(base.url, merged.k1, total, merged.signature),
if (mergeBatches(base.callback, k1s).length !== 1) {
throw new UnsupportedMultiBatchMergeError()
}
const mergeSecret = (options.randomSecret ?? defaultRandomSecret)()
const staged: NewBearer = {
url: withNewK1(base.url, mergeSecret, total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
}
// a merge whose answer was lost leaves the service in an unknown
// state from here - settling fires another mutation (the rotate
// inside settleNote) at it, whose own ambiguous failure would strand
// the rescued secret. Don't compound: return unverified and let a
// refresh repair.
if (merged.rescued) return checkpoint({note: unverified, consumed: pick})
try {
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
return checkpoint({
await stage({note: staged, consumed: pick})
assertFundOwner(options)
const merged = await mergeAmbiguitySafe(base, k1s, {
...mutationOptions,
randomSecret: () => mergeSecret,
})
// a landed merge reports its signature (carried in the URL's sig param)
// and earns verified only when that signature checks against a trusted
// 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: {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
amount: settled.amountMsat,
verified: true,
mintPubkey: base.mintPubkey,
...staged,
url: landedUrl,
verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys),
},
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
// 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 changeK1: string
let changeSignature: string | undefined
let partVerified = false
// true when the split's answer was lost and the probe proved the burn -
// the carried secrets were rescued, but the service is in an unknown
// state, so the change is NOT settled (that would fire another mutation
// at it, whose own ambiguous failure would strand the rescued secret)
let rescued = false
const stagedNote: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat),
callback: base.callback,
amount: amountMsat,
verified: 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)
try {
const parts = await splitNote(base.callback, k1s, amountMsat, options)
partK1 = parts.k1
const parts = await splitNote(base.callback, k1s, amountMsat, {
...mutationOptions,
randomSecret: preparedSecret,
})
partSignature = parts.signature
changeK1 = parts.change
changeSignature = parts.changeSignature
partVerified = true
} catch (err) {
if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one
// 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 === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo
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.',
[
{
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,
},
],
'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.',
[],
)
}
// 'gone': the burn landed - the carried secrets are the only money
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
} else {
// 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
@@ -205,79 +234,53 @@ export const ensureExactAmount = async (
// one output before deciding they are worthless.
const carried = newSecretsOf(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 === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume
// nothing, stop here
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.',
[
{
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,
},
],
'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.',
[],
)
}
// 'live': the split landed and this answer was its retried twin -
// 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 = {
url: withNewK1(base.url, partK1, amountMsat, partSignature),
url: noteUrl,
callback: base.callback,
amount: amountMsat,
verified: partVerified,
verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys),
mintPubkey: base.mintPubkey,
}
// settleNote: the change may be worth less than total - amount if this
// mint charges split fees (LUD-25 deducts them from change, never the
// split-off amount) - it comes back at its true value, or stays
// unverified at the naive pre-fee one for a refresh to repair
let change: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature),
// The change may be worth less than total - amount when the mint charges
// split fees. It remains unverified at that upper bound until refresh;
// settling it here would rotate to an unstaged secret in a second request.
const changeUrl =
changeSignature === undefined
? stagedChange.url
: withNewK1(base.url, changeK1, total - amountMsat, changeSignature)
const change: NewBearer = {
url: changeUrl,
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
}
if (!rescued) {
try {
const settled = await settleNote(
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})
const result = {note, change, consumed: pick}
await retire(note)
return result
}
// 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
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
// requests the invoice; paying it brings the note into existence;
// claimMintedNote watches the payment and converts it into a wallet-owned
// bearer note.
//
// 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.
// Minting: receiving over Lightning. Current LUD-25 minting is always
// comment-bound: the wallet chooses the future note secret, durably stages
// an unverified bearer for it, and only then sends sha256(secret) with the
// invoice request. Paying the invoice brings that already-tracked note into
// existence; claiming confirms its authoritative value and callback.
import {
AmbiguousMutationError,
buildNoteUrl,
claimMintedNote as fetchMintClaim,
defaultRandomSecret,
fetchInvoiceVerification,
fetchMintAddress,
fetchNoteInfo,
fetchPayRequest,
grossUpForMintFee,
hashK1,
isPreimage,
lightningAddressUsername,
mintAddressUrl,
namesMintOutput,
newSecretsOf,
NoteSpentError,
probeBurnedNote,
requestInvoice,
noteDeclaredAmount,
noteK1,
resolveMintInput,
rotateNote,
sameInvoice,
serverOf,
withNewK1,
} 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 type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared'
import {assertFundOwner, pollUntil, withMutationSafety} from './shared'
export type PreparedMint = {
invoice: string
verifyUrl: string | null
// how this mint keys the note the invoice pays for: 'named' mints credit
// a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints
// credit the payment preimage
mode: 'named' | 'unnamed'
// 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 wallet-chosen secret the minted note lands at. Its unverified note
// is persisted before the invoice request can put this secret's hash on
// the wire.
noteSecret: string
// the net note value asked for - the claim cross-checks the service's
// authoritative maxWithdrawable against it
expectedNoteValueMsat: number
@@ -78,6 +48,14 @@ export type PreparedMint = {
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
// its mint address (best-effort LUD-25 experimental endpoint), read its
// payRequest, gross the requested net amount up for the advertised mint
@@ -86,8 +64,11 @@ export type PreparedMint = {
export const prepareMint = async (
mintInput: string,
amountMsat: number,
options: FundOperationOptions = {},
options: PrepareMintOptions,
): 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) {
throw new Error('Amount must be a positive whole number of msat.')
}
@@ -101,14 +82,14 @@ export const prepareMint = async (
let payUrl = url
if (addressUrl) {
try {
nodeInfo = await fetchMintAddress(addressUrl, options)
nodeInfo = await fetchMintAddress(addressUrl, kitOptions)
payUrl = nodeInfo.payLink
} catch (error) {
// no mint-address support here - proceed with just the guess
if (!(error instanceof Error)) throw error
}
}
const info = await fetchPayRequest(payUrl, options)
const info = await fetchPayRequest(payUrl, kitOptions)
if (!info.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) {
throw new Error("Amount is outside this mint's sendable range.")
}
// a mint that names its outputs takes comment = sha256(secret) with the
// quote; one that doesn't gets the byte-identical request this wallet
// always sent, and its note stays keyed by the payment preimage
const named = namesMintOutput(info)
let noteSecret: string | undefined
let h: string | undefined
if (named) {
noteSecret = defaultRandomSecret()
h = hashK1(noteSecret)
const server = serverOf(payUrl)
await beforePersist?.({grossMsat, server})
const noteSecret = (kitOptions.randomSecret ?? defaultRandomSecret)()
const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey
const staged: NewBearer = {
url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
callback: '',
// The URL retains the claimed amount for recovery, but the wallet's
// balance stays at zero until the mint confirms the authoritative value.
amount: 0,
verified: false,
pendingMint: mintPubkey ? {mintPubkey} : {},
}
const invoice = await requestInvoice(
info.callback,
grossMsat,
h ? {...options, h} : options,
)
assertFundOwner(options)
await persistOutput(staged)
assertFundOwner(options)
const invoice = await requestInvoice(info.callback, grossMsat, {
...kitOptions,
h: hashK1(noteSecret),
})
const prepared: PreparedMint = {
invoice: invoice.pr,
verifyUrl: invoice.verify ?? null,
mode: named ? 'named' : 'unnamed',
noteSecret,
expectedNoteValueMsat: amountMsat,
grossMsat,
mintUrl: payUrl,
withdrawLink: info.withdrawLink,
server: serverOf(payUrl),
server,
username: lightningAddressUsername(payUrl),
nodeInfo,
}
if (noteSecret) prepared.noteSecret = noteSecret
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
if (mintPubkey) prepared.mintPubkey = mintPubkey
return prepared
}
export type ClaimedNote = {
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
// 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
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:
// 'minted' resolves the wait ('spent' resolves it too - surfaced for the
// caller to reject, never retried); 'unminted' and 'pending' keep it
@@ -185,67 +171,27 @@ export const pollMintClaim = async (
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 = {
withdrawLink: string
noteSecret: string
// the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable
expectedNoteValueMsat: number
mintPubkey?: string
}
// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's
// verify URL and invoice as rescue anchors for a mint that took the hash
// but credited the preimage anyway. PreparedMint satisfies this.
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.
// The note was credited to the wallet's own secret, which rode to the mint
// only as a hash inside the invoice request. The payment preimage opens
// nothing, so the claim needs no preimage and no rotate follows it.
export const claimFromSecret = async (
target: NamedClaimTarget,
target: ClaimTarget,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
assertFundOwner(options)
let claim: MintClaim
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
}
const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options)
if (claim.state === 'spent') {
// a fresh wallet-chosen secret cannot already be spent - the service
// is lying about the note it just credited
throw new Error('The mint reports the freshly minted note as already spent.')
throw new MintedNoteSpentError()
}
if (claim.k1 !== target.noteSecret) {
// belt and suspenders: the kit's claimMintedNote returns the queried
@@ -267,141 +213,48 @@ export const claimFromSecret = async (
return {note, rotated: true}
}
// Polls until the minted note is claimable, then claims it. Unnamed mints:
// 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).
// Polls until the minted note is claimable at the wallet's staged secret.
export const claimMintedNote = async (
prepared: PreparedMint,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
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)
}
): Promise<ClaimedNote> => claimFromSecret(prepared, poll, options)
// The claim itself, once the payment's preimage is known: the preimage IS
// the note secret. The claim (an informational GET) puts that secret on
// the wire, and the mint has known it since it generated the invoice - so
// the fresh note is rotated immediately and unconditionally (observer
// race: anyone who saw the unpaid invoice knows the payment hash), before
// anything else happens with it.
export const claimFromPreimage = async (
claim: ClaimTarget,
preimage: string,
// One bounded observation for durable recovery. An unpaid invoice is normal:
// keep the staged output and try again at the next wallet/service activation.
export type StagedMintRecovery =
| {readonly state: 'unminted'}
| {readonly state: 'pending'}
| {readonly state: 'spent'}
| {readonly state: 'minted'; readonly note: NewBearer}
export const recoverStagedMintOutput = async (
staged: Bearer,
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
// declare the invoiced amount (a claim - not yet confirmed) so the note
// is self-describing even before the verifying GET below
const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat)
): Promise<StagedMintRecovery> => {
const noteSecret = noteK1(staged.url)
const expectedNoteValueMsat = noteDeclaredAmount(staged.url)
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)
// the service's maxWithdrawable is authoritative - SERVICE's own fee
// math might not match this wallet's estimate, and the note is worth
// exactly maxWithdrawable regardless
const noteInfo = await fetchNoteInfo(declaredUrl, options)
const mintPubkey = noteInfo.mintPubkey ?? claim.mintPubkey
const base: NewBearer = {
url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options))
if (claim.state === 'unminted') return {state: 'unminted'}
if (claim.state === 'pending') return {state: 'pending'}
if (claim.state === 'spent') return {state: 'spent'}
if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the pending output details.')
}
const note: NewBearer = {
url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true,
}
if (mintPubkey) base.mintPubkey = mintPubkey
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
if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey
return {state: 'minted', note}
}
+21 -29
View File
@@ -8,6 +8,7 @@ import {
NoteSpentError,
PendingNoteError,
decodeBolt11AmountMsat,
defaultRandomSecret,
fetchPayRequest,
isBolt11Invoice,
meltNote,
@@ -24,7 +25,7 @@ import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve'
import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared'
export type PayOutcome =
| 'settled'
@@ -41,16 +42,7 @@ export type PayResult = {
invoice: string
amountMsat: number
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
// 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
}
@@ -67,6 +59,9 @@ export type PayOptions = {
// so an abort mid-wait never strands the carve (see
// 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
@@ -82,13 +77,15 @@ export type PayOptions = {
export const payWithBearers = async (
bearers: Bearer[],
input: string,
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {},
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve, onReturnReady}: PayOptions = {},
): 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,
...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}),
}
})
let invoice: string
let amount: number
const trimmed = input.trim()
@@ -159,20 +156,21 @@ export const payWithBearers = async (
}
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
} catch {
// the verify budget ran out - probe the note itself with a rotate: a
// failed melt is only observable as the note becoming spendable again
// The verify budget ran out. Journal the exact output secret before the
// classification rotate reaches the mint, so a crash can recover either
// the returned source or the rotated replacement.
const recoverySecret = kit.randomSecret?.() ?? defaultRandomSecret()
await onReturnReady?.(carve, recoverySecret)
try {
const rotated = await rotateNote(carve.note.callback, k1, options)
// the rotate succeeded, so the mint restored the note - and k1 had
// been on the wire since the melt, so the rotation doubles as the
// 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
const rotated = await rotateNote(carve.note.callback, k1, {
...options,
randomSecret: () => recoverySecret,
})
return {
outcome: 'failed-funds-returned',
carve,
rotatedNote: {
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature),
url: withNewK1(carve.note.url, recoverySecret, amount, rotated.signature),
callback: carve.note.callback,
amount,
verified: true,
@@ -184,20 +182,14 @@ export const payWithBearers = async (
}
} catch (err) {
if (err instanceof PendingNoteError) {
// still locked mid-melt - no outcome either way
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
}
if (err instanceof NoteSpentError) {
// burned without a settled proof - the money is gone either way
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
}
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 = {
url: withNewK1(carve.note.url, err.newSecrets[0], amount),
url: withNewK1(carve.note.url, recoverySecret, amount),
callback: carve.note.callback,
amount,
verified: false,
+6 -4
View File
@@ -17,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, probeMutationOutput} from './shared'
import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared'
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note,
@@ -27,13 +27,15 @@ export const receiveBearer = async (
existing: Bearer[],
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
// the forced mutation policy (see shared.ts) covers the rotate below
const mutationOptions = withMutationSafety(options)
const note = await receiveNote(input, existing)
if (!note.verified || !note.callback) {
return {note, rotated: false}
}
try {
assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note, options)
const rotatedUrl = await secureReceivedNote(note, mutationOptions)
return {note: {...note, url: rotatedUrl}, rotated: true}
} catch (err) {
// A classified rotate refusal can still be a LANDED rotate: the
@@ -44,7 +46,7 @@ export const receiveBearer = async (
// it before believing the refusal.
const carried = newSecretsOf(err)
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') {
return {
note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
@@ -81,7 +83,7 @@ export const receiveBearer = async (
throw err
}
if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(note.url, options)
const outcome = await probeBurnedNote(note.url, mutationOptions)
if (outcome === 'gone') {
return {
note: {
+59 -12
View File
@@ -2,7 +2,15 @@
// every flow that waits on a payment uses, and the uncertainty type a lost
// 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 {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit'
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
// landed - the possible outputs the fresh secrets would control, for the
// caller to track unverified alongside the (kept) inputs. Never dropped:
// if the mutation did land, these are the only money left.
// caller to track unverified alongside the kept inputs. A pre-wire carve
// 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 {
readonly possibleOutputs: NewBearer[]
constructor(message: string, possibleOutputs: NewBearer[]) {
@@ -32,16 +41,54 @@ export class PollAbortedError extends Error {
export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void
// called by a carve the moment its mutation has LANDED server-side (the
// inputs are burned, the outputs are known), before the flow moves on to
// anything slow or uncertain - the caller's one chance to durably commit
// the changeset so an abort during a long settlement wait can never
// strand the outputs or leave burned inputs looking spendable. If it
// 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.
// Two durable phases for a mutating carve. Before wire use, unverified
// outputs are reported with `consumed: []`. After the mint mutation is
// known to have landed, the already-staged note is reported again with
// the inputs that can now be retired. A rejection in either phase stops
// the flow; exact-note carves call neither phase.
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 => {
+129 -171
View File
@@ -3,20 +3,9 @@
// that exist: this wallet requests an invoice FROM the target mint
// (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
// the target note exactly like any other minted receive. The target
// invoice settling is the transfer's ground truth: it can only settle if
// the source melt's payment arrived. How settling is observed and claimed
// 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).
// the target note exactly like any other minted receive. The target note
// appearing at the wallet's pre-staged secret is the transfer's ground
// truth: it can only appear if the source melt's payment arrived.
//
// When settlement never confirms, the source note itself is the oracle -
// a successful rotate proves the melt never burned it (funds returned),
@@ -25,16 +14,16 @@
import {
AmbiguousMutationError,
NoteSpentError,
NoteUnknownError,
PendingNoteError,
buildNoteUrl,
decodeBolt11AmountMsat,
fetchInvoiceVerification,
isPreimage,
defaultRandomSecret,
fetchNoteInfo,
meltNote,
newSecretsOf,
noteK1,
requireNoteK1,
rotateNote,
sameInvoice,
serverOf,
withNewK1,
} from 'lnurlcash-kit'
@@ -42,11 +31,11 @@ import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve'
import type {ClaimedNote, PreparedMint} from './mint'
import {claimFromPreimage, pollMintClaim, prepareMint} from './mint'
import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint'
import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint'
import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
import {assertFundOwner, withMutationSafety} from './shared'
export type TransferOutcome =
// the melt settled and the target note was claimed (and rotated)
@@ -85,13 +74,11 @@ export type TransferClaimMaterial = {
invoice: string
withdrawLink: string
expectedNoteValueMsat: number
// the preimage note, unverified, once the preimage is known - the
// preimage IS the note secret; the caller must track it and retry
note?: NewBearer
// named target only: the wallet-chosen secret the target note lands at.
note: NewBearer
// the wallet-chosen secret the target note lands at.
// 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
noteSecret?: string
noteSecret: string
}
export type TransferResult = {
@@ -101,8 +88,8 @@ export type TransferResult = {
quote: TransferQuote
// the invoice the source note was melted to pay
invoice: string
// the target invoice's verify URL - an unnamed target's ground truth
// (a named target may not serve one at all; the note itself is observed)
// the target invoice's optional verify URL (used for a payment receipt;
// the staged note itself is the settlement observation)
verifyUrl: string | null
sourceServer: string
targetServer: string
@@ -132,6 +119,10 @@ export type TransferOptions = {
// settlement wait, so an abort mid-wait never strands the carve (see
// 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
@@ -147,41 +138,113 @@ const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer =
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 (
bearers: Bearer[],
amountMsat: number,
targetMint: string,
{poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {},
{poll = {}, kit = {}, assertOwner, onCarve, persistOutput, onMeltReady}: TransferOptions,
): Promise<TransferResult> => {
const options: FundOperationOptions = {
const options: FundOperationOptions = withMutationSafety({
...kit,
...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}),
}
})
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
}
// resolving the target and requesting its invoice touches only the
// TARGET mint - a failure here (unreachable, no minting support, amount
// out of range) leaves every source note untouched
const prepared = await prepareMint(targetMint, amountMsat, options)
if (prepared.mode === 'unnamed' && !prepared.verifyUrl) {
throw new Error(
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.',
)
}
const prepared = await prepareMint(targetMint, amountMsat, {
...options,
persistOutput,
beforePersist: ({grossMsat, server}) => {
transferSources(bearers, server, grossMsat)
},
})
const verifyUrl = prepared.verifyUrl
const targetServer = prepared.server
// the source must be a DIFFERENT mint - value "moved" within one mint
// goes nowhere (melt pays an invoice; the same mint's invoice just
// re-mints into itself, paying fees for nothing)
const eligible = bearers.filter(
(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 offTarget = transferSources(bearers, targetServer, prepared.grossMsat)
const quote: TransferQuote = {
requestedMsat: amountMsat,
grossMsat: prepared.grossMsat,
@@ -197,25 +260,22 @@ export const transferBetweenMints = async (
invoice,
withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat,
}
if (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)
noteSecret: prepared.noteSecret,
note: namedClaimNote(prepared, prepared.noteSecret),
}
// from here on the carve's fresh secrets exist only in this result - the
// flow never throws again; every outcome carries them
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
const k1 = requireNoteK1(carve.note.url)
const sourceRecoverySecret = (kit.randomSecret ?? defaultRandomSecret)()
if (carve.consumed.length === 0) assertFundOwner(options)
await onMeltReady?.(carve, sourceRecoverySecret)
assertFundOwner(options)
try {
await meltNote(carve.note.callback, k1, invoice, options)
} catch (err) {
if (err instanceof NoteSpentError) {
// this melt names a single note, so this is unambiguous - it was
// already gone before the melt even started
} catch (error) {
if (!(error instanceof Error)) throw error
if (error instanceof NoteSpentError) {
return {...base, outcome: 'note-already-spent'}
}
// 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
}
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 {
const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options)
if (
claim.state !== 'minted' ||
claim.k1 !== noteSecret ||
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
const claimed = await claimFromSecret(prepared, poll, options)
return {...base, outcome: 'settled', mintedAtTarget: claimed}
} catch (error) {
if (error instanceof MintedNoteSpentError) {
return {...base, outcome: 'settled-claim-failed', claimMaterial}
}
const note: NewBearer = {
url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat),
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},
}
throw new Error('The target note could not be confirmed at its staged secret.', {
cause: error,
})
}
} catch {
// 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
// as the way back to the money if the invoice settles later
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
// funds are back - re-secured, since the melt attempt put k1 on the
// wire. Reported separately from `carve` (see rotatedNote): the
@@ -367,10 +327,8 @@ export const transferBetweenMints = async (
return {...base, outcome: 'unknown-still-pending', claimMaterial}
}
if (err instanceof AmbiguousMutationError) {
// the rotate's answer was lost - pay.ts's reasoning: had the note
// still been pending the service would have said so, so the funds
// ARE back, but whether the rotation landed is unknown. Surface
// the possible fresh copy alongside the unchanged note.
// The rotate's answer was lost. Keep the transfer journal until
// recovery proves whether the original or recovery secret is live.
const rescuedNote: NewBearer = {
url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount),
callback: carve.note.callback,
@@ -378,7 +336,7 @@ export const transferBetweenMints = async (
verified: false,
}
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}
}
+15
View File
@@ -35,6 +35,15 @@ export type Bearer = {
// blank mirror, kept only so this bearer displays like any other
// (amount/host/label/state)
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
updatedAt: number
}
@@ -46,4 +55,10 @@ export type NewBearer = {
verified: boolean
mintPubkey?: 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.
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 {
@@ -15,7 +15,9 @@ import type { BearerChangeset } from '@/lnurlcash/storage';
import { lockTrustedMint } from '@/lnurlcash/trustedMints';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { msatToSats } from '@/lnurlcash/units';
import { recoverPendingTransferSource } from '@/lnurlcash/ops';
import type { CarveResult } from '@/lnurlcash/ops';
import { recoverStagedMintOutput } from '@/lnurlcash/ops/mint';
import type { WalletOwnerFence } from './walletOwnerFence';
export class TrustedMintPostCommitError extends Error {
@@ -160,10 +162,30 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
const commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise<Bearer> =>
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[] = [];
if (!existing) additions.push(carve.note);
if (carve.change) additions.push(carve.change);
if (!staged) additions.push(carve.note);
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();
const next = await applyBearerChangeset(
options.requireKey(),
@@ -171,13 +193,14 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
{
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
upsert: upserts,
},
{ beforeCommit: ownerFence },
);
const added = next.slice(0, additions.length);
bearers.value = next;
await lockCommittedBearers(added);
const committed = existing ?? added[0];
await lockCommittedBearers([...added, ...upserts]);
const committed = upserts[0] ?? added[0];
if (!committed) throw new Error('The carved note was not tracked.');
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 (
id: string,
ownerFence: WalletOwnerFence,
@@ -297,6 +548,11 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
applyChangeset,
commitCarve,
updateBearer,
reserveStagedMintSource,
restoreStagedMintSource,
finalizeStagedMintOutput,
finalizeSpentStagedMintOutput,
recoverPendingMints,
allocateCashSecrets,
markSpent,
removeNote,