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