mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(funds): recover staged mutations
This commit is contained in:
@@ -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, {
|
||||
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)
|
||||
})
|
||||
|
||||
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),
|
||||
})
|
||||
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))
|
||||
persistOutput,
|
||||
}),
|
||||
).rejects.toThrow(/64-character output commitment/i)
|
||||
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user