mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(nwc): await durable commits before responses
This commit is contained in:
@@ -57,8 +57,23 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from './nwc.testProtocol'
|
} from './nwc.testProtocol'
|
||||||
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||||
|
import {invoiceRetireAfter} from './nwc/invoices'
|
||||||
|
|
||||||
|
export const SIGNED_BOLT11_FIXTURE =
|
||||||
|
'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz'
|
||||||
|
|
||||||
describe('service: make_invoice / lookup_invoice', () => {
|
describe('service: make_invoice / lookup_invoice', () => {
|
||||||
it('issues an invoice, settles it in the background, and reports the preimage', async () => {
|
it('derives a safe retirement time from a signed BOLT11 invoice', () => {
|
||||||
|
expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a retirement time outside the safe integer range', () => {
|
||||||
|
const timestamp = 'q'.repeat(7)
|
||||||
|
const expiryTag = `xqv${'l'.repeat(12)}`
|
||||||
|
expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stages an output, settles it in the background, and reports the preimage', async () => {
|
||||||
const m = await mint({testHooks: true})
|
const m = await mint({testHooks: true})
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
defaultMint: `mint@127.0.0.1:${m.port}`,
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
@@ -88,7 +103,9 @@ describe('service: make_invoice / lookup_invoice', () => {
|
|||||||
}
|
}
|
||||||
expect(invoice).toMatch(/^lnbc/)
|
expect(invoice).toMatch(/^lnbc/)
|
||||||
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
|
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/)
|
||||||
|
const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-')))
|
||||||
|
expect(staged.verified).toBe(false)
|
||||||
|
expect(staged.callback).toBe('')
|
||||||
// before settlement the lookup reports the pending invoice
|
// before settlement the lookup reports the pending invoice
|
||||||
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
payment_hash: paymentHash,
|
payment_hash: paymentHash,
|
||||||
@@ -101,7 +118,7 @@ describe('service: make_invoice / lookup_invoice', () => {
|
|||||||
// mints the note
|
// mints the note
|
||||||
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
|
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
|
||||||
expect(settleRes.ok).toBe(true)
|
expect(settleRes.ok).toBe(true)
|
||||||
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
|
await waitFor(() => state.bearers.some((bearer) => bearer.verified))
|
||||||
|
|
||||||
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||||
payment_hash: paymentHash,
|
payment_hash: paymentHash,
|
||||||
@@ -112,11 +129,10 @@ describe('service: make_invoice / lookup_invoice', () => {
|
|||||||
const preimage = requiredString(settled.result?.preimage)
|
const preimage = requiredString(settled.result?.preimage)
|
||||||
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
|
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
|
||||||
|
|
||||||
// the minted note was claimed AND rotated before settlement was
|
// The staged record was updated in place before settlement became
|
||||||
// recorded: the preimage the client just learned is a burned secret,
|
// visible. The payment preimage is only a receipt and keys no note.
|
||||||
// and the wallet's fresh note is the only live one
|
expect(m.state.noteState(preimage)).toBeNull()
|
||||||
expect(m.state.noteState(preimage)).toBe('burned')
|
const minted = requiredValue(state.bearers.find((b) => b.id === staged.id))
|
||||||
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
|
|
||||||
expect(minted.amount).toBe(21_000)
|
expect(minted.amount).toBe(21_000)
|
||||||
expect(minted.verified).toBe(true)
|
expect(minted.verified).toBe(true)
|
||||||
expect(noteK1(minted.url)).not.toBe(preimage)
|
expect(noteK1(minted.url)).not.toBe(preimage)
|
||||||
@@ -124,129 +140,6 @@ describe('service: make_invoice / lookup_invoice', () => {
|
|||||||
await stop()
|
await stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('settles against a NAMED mint: note at the wallet secret, preimage a plain receipt', async () => {
|
|
||||||
const m = await mint({commentAllowed: 64, testHooks: true})
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
defaultMint: `mint@127.0.0.1:${m.port}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const made = await call(relay, walletServicePubkey, 'make_invoice', {
|
|
||||||
amount: 21_000,
|
|
||||||
})
|
|
||||||
expect(made.error).toBeNull()
|
|
||||||
const paymentHash = made.result?.payment_hash
|
|
||||||
if (typeof paymentHash !== 'string') {
|
|
||||||
throw new TypeError('make_invoice did not return a payment hash')
|
|
||||||
}
|
|
||||||
|
|
||||||
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
|
|
||||||
expect(settleRes.ok).toBe(true)
|
|
||||||
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
|
|
||||||
|
|
||||||
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
|
||||||
payment_hash: paymentHash,
|
|
||||||
})
|
|
||||||
expect(settled.error).toBeNull()
|
|
||||||
expect(settled.result?.state).toBe('settled')
|
|
||||||
// a named mint's verify preimage is an ordinary payment receipt -
|
|
||||||
// it keys nothing at the mint - so it is always safe to hand out
|
|
||||||
const preimage = requiredString(settled.result?.preimage)
|
|
||||||
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
|
|
||||||
expect(m.state.noteState(preimage)).toBeNull()
|
|
||||||
|
|
||||||
// the note was claimed at the wallet's own secret - unrotated,
|
|
||||||
// because nothing exposed it
|
|
||||||
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
|
|
||||||
expect(minted.amount).toBe(21_000)
|
|
||||||
expect(minted.verified).toBe(true)
|
|
||||||
const mintedK1 = requiredValue(noteK1(minted.url))
|
|
||||||
expect(m.state.noteState(mintedK1)).toBe('outstanding')
|
|
||||||
// the quote was bound to that secret's hash
|
|
||||||
expect([...m.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(mintedK1))
|
|
||||||
await stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rescues a NAMED mint that credited the preimage, and burns it before handing it out', async () => {
|
|
||||||
// the mintToHashIgnoresH adversary: advertises output naming, binds
|
|
||||||
// nothing, credits the payment hash - and (non-compliantly) still
|
|
||||||
// serves verify, so the preimage path can recover the note
|
|
||||||
const m = await mint({
|
|
||||||
commentAllowed: 64,
|
|
||||||
mintToHashIgnoresH: true,
|
|
||||||
verifyOnUnnamedMint: true,
|
|
||||||
testHooks: true,
|
|
||||||
})
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
defaultMint: `mint@127.0.0.1:${m.port}`,
|
|
||||||
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
|
|
||||||
})
|
|
||||||
|
|
||||||
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
|
|
||||||
const paymentHash = made.result?.payment_hash
|
|
||||||
if (typeof paymentHash !== 'string') {
|
|
||||||
throw new TypeError('make_invoice did not return a payment hash')
|
|
||||||
}
|
|
||||||
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
|
|
||||||
expect(settleRes.ok).toBe(true)
|
|
||||||
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
|
|
||||||
|
|
||||||
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
|
||||||
payment_hash: paymentHash,
|
|
||||||
})
|
|
||||||
expect(settled.result?.state).toBe('settled')
|
|
||||||
// the rescue claimed through the preimage path and rotated off it, so
|
|
||||||
// the receipt handed to the client is a burned secret
|
|
||||||
const preimage = requiredString(settled.result?.preimage)
|
|
||||||
expect(m.state.noteState(preimage)).toBe('burned')
|
|
||||||
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
|
|
||||||
const mintedK1 = requiredValue(noteK1(minted.url))
|
|
||||||
expect(mintedK1).not.toBe(preimage)
|
|
||||||
expect(m.state.noteState(mintedK1)).toBe('outstanding')
|
|
||||||
await stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('withholds the preimage when the rescue claim could not rotate', async () => {
|
|
||||||
// same lying mint, but the rotate's request never lands (the mint
|
|
||||||
// dies between the claim and the rotate): the preimage may still be
|
|
||||||
// the note's secret, so it stays a secret even though the invoice
|
|
||||||
// reads settled
|
|
||||||
const m = await mint({
|
|
||||||
commentAllowed: 64,
|
|
||||||
mintToHashIgnoresH: true,
|
|
||||||
verifyOnUnnamedMint: true,
|
|
||||||
testHooks: true,
|
|
||||||
})
|
|
||||||
const rotateFailingFetch: typeof fetch = (input, init) => {
|
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
|
||||||
if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'))
|
|
||||||
return fetch(input, init)
|
|
||||||
}
|
|
||||||
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
|
||||||
defaultMint: `mint@127.0.0.1:${m.port}`,
|
|
||||||
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
|
|
||||||
kit: {fetch: rotateFailingFetch},
|
|
||||||
})
|
|
||||||
|
|
||||||
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
|
|
||||||
const paymentHash = made.result?.payment_hash
|
|
||||||
if (typeof paymentHash !== 'string') {
|
|
||||||
throw new TypeError('make_invoice did not return a payment hash')
|
|
||||||
}
|
|
||||||
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
|
|
||||||
expect(settleRes.ok).toBe(true)
|
|
||||||
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
|
|
||||||
|
|
||||||
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
|
||||||
payment_hash: paymentHash,
|
|
||||||
})
|
|
||||||
expect(settled.result?.state).toBe('settled')
|
|
||||||
// settled, but NO receipt: an unrotated preimage may still be the note
|
|
||||||
expect(settled.result?.preimage).toBeUndefined()
|
|
||||||
// the note is tracked either way (it IS money)
|
|
||||||
expect(state.bearers.some((b) => b.id.startsWith('added-'))).toBe(true)
|
|
||||||
await stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
|
it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
|
||||||
const m = await mint({testHooks: true})
|
const m = await mint({testHooks: true})
|
||||||
const commit = deferred()
|
const commit = deferred()
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from './nwc.testProtocol'
|
} from './nwc.testProtocol'
|
||||||
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||||
|
import {SIGNED_BOLT11_FIXTURE} from './nwc.invoice-a.cases'
|
||||||
describe('service: make_invoice / lookup_invoice (continued)', () => {
|
describe('service: make_invoice / lookup_invoice (continued)', () => {
|
||||||
it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
|
it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
|
||||||
const m = await mint({testHooks: true})
|
const m = await mint({testHooks: true})
|
||||||
@@ -230,6 +231,99 @@ describe('service: make_invoice / lookup_invoice (continued)', () => {
|
|||||||
await stop()
|
await stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects invoice creation when the durable pending-output cap is reached', async () => {
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
defaultMint: 'mint@example.com',
|
||||||
|
})
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
state.bearers.push({
|
||||||
|
id: `pending-${index}`,
|
||||||
|
url: `https://mint.example/w?k1=${index.toString(16).padStart(64, '0')}`,
|
||||||
|
callback: '',
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: {},
|
||||||
|
createdAt: index,
|
||||||
|
updatedAt: index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
amount: 21_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe('QUOTA_EXCEEDED')
|
||||||
|
expect(state.bearers).toHaveLength(20)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes staged outputs when invoice preparation fails before publication', async () => {
|
||||||
|
const m = await mint({testHooks: true})
|
||||||
|
let rejectQuotes = true
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
kit: {
|
||||||
|
fetch: (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url
|
||||||
|
if (rejectQuotes && url.includes('comment=')) {
|
||||||
|
return Promise.resolve(new Response('quote failed', {status: 500}))
|
||||||
|
}
|
||||||
|
return fetch(input, init)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
const failed = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
|
||||||
|
expect(failed.error?.code).toBe('INTERNAL')
|
||||||
|
expect(state.bearers).toHaveLength(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectQuotes = false
|
||||||
|
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
|
||||||
|
expect(made.error).toBeNull()
|
||||||
|
expect(state.bearers).toHaveLength(1)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes an unpublished stage when retirement persistence fails', async () => {
|
||||||
|
const m = await mint({testHooks: true})
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||||
|
setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')),
|
||||||
|
kit: {
|
||||||
|
fetch: (input, init) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url
|
||||||
|
if (url.includes('comment=')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({pr: SIGNED_BOLT11_FIXTURE}), {
|
||||||
|
status: 200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fetch(input, init)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'make_invoice', {amount: 25_000})
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe('INTERNAL')
|
||||||
|
expect(state.bearers).toHaveLength(0)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
it('answers a make_invoice with a bad amount with OTHER', async () => {
|
it('answers a make_invoice with a bad amount with OTHER', async () => {
|
||||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||||
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||||
|
|||||||
@@ -71,8 +71,19 @@ describe('service ownership', () => {
|
|||||||
|
|
||||||
const service = await startService(LINKING_KEY, {
|
const service = await startService(LINKING_KEY, {
|
||||||
assertCurrentOwner: () => undefined,
|
assertCurrentOwner: () => undefined,
|
||||||
|
allocateOutputSecrets: () => Promise.resolve([]),
|
||||||
|
mintSignatureKeys: () => [],
|
||||||
|
recoverPendingMints: () => Promise.resolve(),
|
||||||
|
reserveMintOutputSource: () => Promise.resolve(),
|
||||||
|
finalizePaymentReturn: () => Promise.resolve(),
|
||||||
getBearers: () => [],
|
getBearers: () => [],
|
||||||
getDefaultMint: () => null,
|
getDefaultMint: () => null,
|
||||||
|
persistMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
discardMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
commitCarve: () => Promise.reject(new Error('unused')),
|
||||||
applyChangeset: () => Promise.resolve(),
|
applyChangeset: () => Promise.resolve(),
|
||||||
transport: relay.transport,
|
transport: relay.transport,
|
||||||
nowSeconds,
|
nowSeconds,
|
||||||
@@ -102,8 +113,19 @@ describe('service ownership', () => {
|
|||||||
LINKING_KEY,
|
LINKING_KEY,
|
||||||
{
|
{
|
||||||
assertCurrentOwner: () => undefined,
|
assertCurrentOwner: () => undefined,
|
||||||
|
allocateOutputSecrets: () => Promise.resolve([]),
|
||||||
|
mintSignatureKeys: () => [],
|
||||||
|
recoverPendingMints: () => Promise.resolve(),
|
||||||
|
reserveMintOutputSource: () => Promise.resolve(),
|
||||||
|
finalizePaymentReturn: () => Promise.resolve(),
|
||||||
getBearers: () => [],
|
getBearers: () => [],
|
||||||
getDefaultMint: () => null,
|
getDefaultMint: () => null,
|
||||||
|
persistMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
discardMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
setMintOutputRetirement: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
finalizeSpentMintOutput: () => Promise.reject(new Error('unused')),
|
||||||
|
commitCarve: () => Promise.reject(new Error('unused')),
|
||||||
applyChangeset: () => Promise.resolve(),
|
applyChangeset: () => Promise.resolve(),
|
||||||
transport: relay.transport,
|
transport: relay.transport,
|
||||||
nowSeconds,
|
nowSeconds,
|
||||||
|
|||||||
@@ -138,21 +138,38 @@ describe('service: pay_invoice (continued)', () => {
|
|||||||
// running out, and that wait is the test's own clock
|
// running out, and that wait is the test's own clock
|
||||||
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
|
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
|
||||||
})
|
})
|
||||||
state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)]
|
const original = await makeBearer(m, '01'.repeat(32), 21_000)
|
||||||
|
state.bearers = [original]
|
||||||
|
|
||||||
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
})
|
})
|
||||||
expect(response.error?.code).toBe('PAYMENT_FAILED')
|
expect(response.error?.code).toBe('PAYMENT_FAILED')
|
||||||
|
|
||||||
// the funds came back, re-secured: the old secret burned, a fresh one
|
|
||||||
// tracked unspent via the changeset - and no budget spend recorded
|
|
||||||
expect(m.state.noteState('01'.repeat(32))).toBe('burned')
|
expect(m.state.noteState('01'.repeat(32))).toBe('burned')
|
||||||
const returned = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
|
expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true)
|
||||||
expect(returned.spent).toBeUndefined()
|
expect(
|
||||||
expect(returned.amount).toBe(21_000)
|
state.bearers.find((bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent),
|
||||||
expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding')
|
).toBeDefined()
|
||||||
|
expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(21_000)
|
||||||
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
|
||||||
await stop()
|
await stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('conserves balance when a split payment fails and returns its carved funds', async () => {
|
||||||
|
const m = await mint({meltAlwaysFails: true})
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
|
||||||
|
})
|
||||||
|
state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)]
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe('PAYMENT_FAILED')
|
||||||
|
expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(30_000)
|
||||||
|
expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined()
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
// already-rotated (burned) note secret.
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
|
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||||
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||||
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||||
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
import {buildNoteUrl, fetchNoteInfo, noteK1, serverOf} from 'lnurlcash-kit'
|
||||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -114,6 +115,7 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let bearerCounter = 0
|
let bearerCounter = 0
|
||||||
|
let allocationCounter = 0
|
||||||
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
|
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
|
||||||
m.state.creditNote(k1, amountMsat)
|
m.state.creditNote(k1, amountMsat)
|
||||||
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
|
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
|
||||||
@@ -143,6 +145,7 @@ export const startTestService = async (options: {
|
|||||||
claimPoll?: typeof FAST_POLL
|
claimPoll?: typeof FAST_POLL
|
||||||
kit?: NwcServiceDeps['kit']
|
kit?: NwcServiceDeps['kit']
|
||||||
commitChangeset?: (changeset: NwcChangeset) => Promise<void>
|
commitChangeset?: (changeset: NwcChangeset) => Promise<void>
|
||||||
|
setMintOutputRetirement?: () => Promise<void>
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
relay: ReturnType<typeof createFakeRelay>
|
relay: ReturnType<typeof createFakeRelay>
|
||||||
walletServicePubkey: string
|
walletServicePubkey: string
|
||||||
@@ -164,8 +167,120 @@ export const startTestService = async (options: {
|
|||||||
}
|
}
|
||||||
const service = await startService(options.linkingKey ?? LINKING_KEY, {
|
const service = await startService(options.linkingKey ?? LINKING_KEY, {
|
||||||
assertCurrentOwner: () => undefined,
|
assertCurrentOwner: () => undefined,
|
||||||
|
// deterministic stand-in for the wallet's BIP-32 allocation path
|
||||||
|
allocateOutputSecrets: (_server, count) => {
|
||||||
|
const secrets = Array.from({length: count}, () => {
|
||||||
|
allocationCounter += 1
|
||||||
|
return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`)))
|
||||||
|
})
|
||||||
|
return Promise.resolve(secrets)
|
||||||
|
},
|
||||||
|
// the keys a production caller serves from the trusted-mint registry:
|
||||||
|
// each created mock mint's current key plus any it retired
|
||||||
|
mintSignatureKeys: (server) =>
|
||||||
|
mints.flatMap((m) =>
|
||||||
|
new URL(m.url).host === server ? [m.state.pubkey, ...m.state.previousPubkeys] : [],
|
||||||
|
),
|
||||||
|
recoverPendingMints: () => Promise.resolve(),
|
||||||
getBearers: () => state.bearers,
|
getBearers: () => state.bearers,
|
||||||
getDefaultMint: () => options.defaultMint ?? null,
|
getDefaultMint: () => options.defaultMint ?? null,
|
||||||
|
persistMintOutput: (note) => {
|
||||||
|
bearerCounter += 1
|
||||||
|
const bearer: Bearer = {
|
||||||
|
...note,
|
||||||
|
id: `staged-${bearerCounter}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
state.bearers.push(bearer)
|
||||||
|
return Promise.resolve(bearer)
|
||||||
|
},
|
||||||
|
discardMintOutput: (staged) => {
|
||||||
|
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id)
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
setMintOutputRetirement: (staged, retireAfter) => {
|
||||||
|
const override = options.setMintOutputRetirement?.()
|
||||||
|
if (override) return override
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id)
|
||||||
|
if (index < 0) return Promise.reject(new Error('The staged bearer is missing.'))
|
||||||
|
const updated: Bearer = {
|
||||||
|
...staged,
|
||||||
|
pendingMint: {...staged.pendingMint, retireAfter},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
state.bearers[index] = updated
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
reserveMintOutputSource: (staged, sourceId, recoverySecret) => {
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id)
|
||||||
|
const source = state.bearers.find((bearer) => bearer.id === sourceId)
|
||||||
|
if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.'))
|
||||||
|
source.spent = true
|
||||||
|
state.bearers[index] = {
|
||||||
|
...state.bearers[index]!,
|
||||||
|
pendingMint: {
|
||||||
|
...state.bearers[index]!.pendingMint,
|
||||||
|
sourceBearerId: sourceId,
|
||||||
|
sourceRecoverySecret: recoverySecret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
finalizeMintOutput: async (staged, note) => {
|
||||||
|
const changeset: NwcChangeset = {add: [], markSpent: []}
|
||||||
|
await options.commitChangeset?.(changeset)
|
||||||
|
state.changesets.push(changeset)
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id)
|
||||||
|
if (index < 0) throw new Error('The staged bearer is missing.')
|
||||||
|
state.bearers[index] = {...staged, ...note, pendingMint: undefined, updatedAt: Date.now()}
|
||||||
|
},
|
||||||
|
finalizePaymentReturn: async (staged, note) => {
|
||||||
|
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id)
|
||||||
|
if (index < 0) throw new Error('The staged bearer is missing.')
|
||||||
|
const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId
|
||||||
|
if (sourceId) {
|
||||||
|
const source = state.bearers.find((bearer) => bearer.id === sourceId)
|
||||||
|
if (source) source.spent = true
|
||||||
|
}
|
||||||
|
state.bearers[index] = {...state.bearers[index]!, ...note, pendingMint: undefined, updatedAt: Date.now()}
|
||||||
|
},
|
||||||
|
finalizeSpentMintOutput: (staged) => {
|
||||||
|
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id)
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
commitCarve: async (carve) => {
|
||||||
|
// a checkpoint is identified by its output secret: the landed phase's
|
||||||
|
// URL carries a signature the staged phase's did not have yet
|
||||||
|
const sameOutput = (url: string, noteUrl: string): boolean =>
|
||||||
|
serverOf(url) === serverOf(noteUrl) &&
|
||||||
|
noteK1(url) !== null &&
|
||||||
|
noteK1(url) === noteK1(noteUrl)
|
||||||
|
const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter(
|
||||||
|
(note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)),
|
||||||
|
)
|
||||||
|
const changeset: NwcChangeset = {
|
||||||
|
add: additions,
|
||||||
|
markSpent: carve.consumed.map((bearer) => bearer.id),
|
||||||
|
}
|
||||||
|
await options.commitChangeset?.(changeset)
|
||||||
|
state.changesets.push(changeset)
|
||||||
|
for (const bearer of state.bearers) {
|
||||||
|
if (changeset.markSpent.includes(bearer.id)) bearer.spent = true
|
||||||
|
}
|
||||||
|
for (const note of additions) {
|
||||||
|
bearerCounter += 1
|
||||||
|
state.bearers.unshift({
|
||||||
|
...note,
|
||||||
|
id: `carve-${bearerCounter}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url))
|
||||||
|
if (!committed) throw new Error('The carved note was not committed.')
|
||||||
|
return committed
|
||||||
|
},
|
||||||
applyChangeset: async (changeset: NwcChangeset) => {
|
applyChangeset: async (changeset: NwcChangeset) => {
|
||||||
await options.commitChangeset?.(changeset)
|
await options.commitChangeset?.(changeset)
|
||||||
state.changesets.push(changeset)
|
state.changesets.push(changeset)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type {LnurlcashOptions} from 'lnurlcash-kit'
|
|||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
||||||
import type {PreparedMint} from '../ops'
|
import type {PreparedMint} from '../ops'
|
||||||
|
import type {CarveResult} from '../ops'
|
||||||
|
import type {OutputSecretAllocator} from '../ops/allocation'
|
||||||
|
import type {MintSignatureKeys} from '../ops/shared'
|
||||||
import type {PollOptions} from '../ops/shared'
|
import type {PollOptions} from '../ops/shared'
|
||||||
|
|
||||||
import type {NwcConnectionInfo} from './connection'
|
import type {NwcConnectionInfo} from './connection'
|
||||||
@@ -31,6 +34,38 @@ export type NwcServiceDeps = {
|
|||||||
// mint - NIP-47's make_invoice carries no mint choice)
|
// mint - NIP-47's make_invoice carries no mint choice)
|
||||||
getDefaultMint: () => string | null
|
getDefaultMint: () => string | null
|
||||||
assertCurrentOwner: () => void
|
assertCurrentOwner: () => void
|
||||||
|
// the wallet's durable allocation path (BIP-32 reservation) for every
|
||||||
|
// note output secret a request stages
|
||||||
|
allocateOutputSecrets: (
|
||||||
|
server: string,
|
||||||
|
count: number,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<readonly string[]>
|
||||||
|
// trusted current+previous signing keys a landed carve output verifies
|
||||||
|
// against - the trusted-mint registry, owner-scoped
|
||||||
|
mintSignatureKeys: MintSignatureKeys
|
||||||
|
recoverPendingMints: (assertOwner: () => void) => Promise<void>
|
||||||
|
persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise<Bearer>
|
||||||
|
discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>
|
||||||
|
setMintOutputRetirement: (
|
||||||
|
staged: Bearer,
|
||||||
|
retireAfter: number,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<void>
|
||||||
|
reserveMintOutputSource: (
|
||||||
|
staged: Bearer,
|
||||||
|
sourceId: string,
|
||||||
|
recoverySecret: string,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<void>
|
||||||
|
finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise<void>
|
||||||
|
finalizePaymentReturn: (
|
||||||
|
staged: Bearer,
|
||||||
|
note: NewBearer,
|
||||||
|
assertOwner: () => void,
|
||||||
|
) => Promise<void>
|
||||||
|
finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>
|
||||||
|
commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise<Bearer>
|
||||||
applyChangeset: (
|
applyChangeset: (
|
||||||
changeset: NwcChangeset,
|
changeset: NwcChangeset,
|
||||||
connection: NwcConnectionInfo,
|
connection: NwcConnectionInfo,
|
||||||
@@ -61,6 +96,7 @@ export type PendingInvoice = {
|
|||||||
createdAt: number // unix seconds
|
createdAt: number // unix seconds
|
||||||
expiresAt?: number
|
expiresAt?: number
|
||||||
prepared: PreparedMint
|
prepared: PreparedMint
|
||||||
|
stagedOutput: Bearer
|
||||||
state: 'pending' | 'settled' | 'failed'
|
state: 'pending' | 'settled' | 'failed'
|
||||||
preimage?: string
|
preimage?: string
|
||||||
settledAt?: number
|
settledAt?: number
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import {prepareMint} from '../ops'
|
|||||||
import {PollAbortedError} from '../ops/shared'
|
import {PollAbortedError} from '../ops/shared'
|
||||||
|
|
||||||
import type {PendingInvoice, RequestContext} from './context'
|
import type {PendingInvoice, RequestContext} from './context'
|
||||||
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
|
import {invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim} from './invoices'
|
||||||
import {handlePayInvoice} from './pay'
|
import {handlePayInvoice} from './pay'
|
||||||
import type {NwcRequest, NwcResponse} from './protocol'
|
import type {NwcRequest, NwcResponse} from './protocol'
|
||||||
import {NWC_METHODS, errResult, okResult} from './protocol'
|
import {NWC_METHODS, errResult, okResult} from './protocol'
|
||||||
|
|
||||||
|
const MAX_PENDING_MINT_OUTPUTS = 20
|
||||||
|
|
||||||
// the same eligibility carve applies - the balance answers "what could
|
// the same eligibility carve applies - the balance answers "what could
|
||||||
// this wallet actually pay with right now"
|
// this wallet actually pay with right now"
|
||||||
const spendable = (bearer: Bearer): boolean =>
|
const spendable = (bearer: Bearer): boolean =>
|
||||||
@@ -55,18 +57,86 @@ const handleMakeInvoice = async (
|
|||||||
if (!mint) {
|
if (!mint) {
|
||||||
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.')
|
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.')
|
||||||
}
|
}
|
||||||
|
const now = ctx.nowSeconds()
|
||||||
|
if (
|
||||||
|
ctx.deps
|
||||||
|
.getBearers()
|
||||||
|
.some(
|
||||||
|
(bearer) =>
|
||||||
|
!bearer.spent &&
|
||||||
|
bearer.pendingMint?.retireAfter !== undefined &&
|
||||||
|
bearer.pendingMint.retireAfter <= now,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await ctx.deps.recoverPendingMints(ctx.assertOwner)
|
||||||
|
}
|
||||||
|
const pendingOutputs = ctx.deps
|
||||||
|
.getBearers()
|
||||||
|
.filter((bearer) => bearer.pendingMint && !bearer.spent).length
|
||||||
|
if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) {
|
||||||
|
return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.')
|
||||||
|
}
|
||||||
let prepared: PreparedMint
|
let prepared: PreparedMint
|
||||||
|
let stagedOutput: Bearer | undefined
|
||||||
try {
|
try {
|
||||||
prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {})
|
prepared = await prepareMint(mint, amountMsat, {
|
||||||
|
...(ctx.deps.kit ?? {}),
|
||||||
|
assertOwner: ctx.assertOwner,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
|
||||||
|
persistOutput: async (note) => {
|
||||||
|
stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner)
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (stagedOutput) {
|
||||||
|
try {
|
||||||
|
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
return errResult(
|
||||||
|
'make_invoice',
|
||||||
|
'INTERNAL',
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err))
|
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err))
|
||||||
}
|
}
|
||||||
|
if (!stagedOutput) {
|
||||||
|
return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.')
|
||||||
|
}
|
||||||
|
const retireAfter = invoiceRetireAfter(prepared.invoice)
|
||||||
|
if (retireAfter !== null) {
|
||||||
|
try {
|
||||||
|
await ctx.deps.setMintOutputRetirement(
|
||||||
|
stagedOutput,
|
||||||
|
retireAfter,
|
||||||
|
ctx.assertOwner,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
return errResult(
|
||||||
|
'make_invoice',
|
||||||
|
'INTERNAL',
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return errResult(
|
||||||
|
'make_invoice',
|
||||||
|
'INTERNAL',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
const entry: PendingInvoice = {
|
const entry: PendingInvoice = {
|
||||||
invoice: prepared.invoice,
|
invoice: prepared.invoice,
|
||||||
paymentHash: resolvePaymentHash(prepared),
|
paymentHash: resolvePaymentHash(prepared),
|
||||||
amountMsat: prepared.grossMsat,
|
amountMsat: prepared.grossMsat,
|
||||||
createdAt: ctx.nowSeconds(),
|
createdAt: ctx.nowSeconds(),
|
||||||
prepared,
|
prepared,
|
||||||
|
stagedOutput,
|
||||||
state: 'pending',
|
state: 'pending',
|
||||||
}
|
}
|
||||||
if (typeof params.description === 'string' && params.description) {
|
if (typeof params.description === 'string' && params.description) {
|
||||||
|
|||||||
+64
-114
@@ -1,26 +1,16 @@
|
|||||||
// The invoice registry the two-phase make_invoice / lookup_invoice pair
|
// The invoice registry the two-phase make_invoice / lookup_invoice pair
|
||||||
// shares: make_invoice answers with the invoice immediately while this
|
// shares. A minted note always lives at the wallet-chosen secret that was
|
||||||
// module's settleAndClaim watches settlement in the background, claims
|
// durably staged before quote creation; the payment preimage is only a
|
||||||
// the minted note, and only THEN records the settlement the lookup
|
// receipt. Settlement becomes visible only after the staged bearer has
|
||||||
// reports.
|
// been replaced with the confirmed note details.
|
||||||
//
|
|
||||||
// THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint the payment
|
|
||||||
// preimage IS the minted note's initial secret. NIP-47 clients expect it
|
|
||||||
// as the settlement receipt, so it is handed out - but only after
|
|
||||||
// claimFromPreimage has rotated the fresh note, when that secret is
|
|
||||||
// burned and worthless. If the rotation failed (claimed.rotated ===
|
|
||||||
// false) the preimage is withheld: fund safety over spec comfort. On a
|
|
||||||
// NAMED mint the note lives at a wallet-chosen secret from the start and
|
|
||||||
// the preimage opens nothing at all, so it is an ordinary payment receipt
|
|
||||||
// and always safe to hand out.
|
|
||||||
|
|
||||||
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
|
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
|
||||||
import {sha256} from '@noble/hashes/sha2.js'
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
|
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
|
|
||||||
import type {NewBearer} from '../types'
|
|
||||||
import type {PreparedMint} from '../ops'
|
import type {PreparedMint} from '../ops'
|
||||||
import {claimFromPreimage, claimFromSecret} from '../ops/mint'
|
import {MintedNoteSpentError} from '../ops'
|
||||||
|
import {claimFromSecret} from '../ops/mint'
|
||||||
import {pollVerifyUntilSettled} from '../ops/shared'
|
import {pollVerifyUntilSettled} from '../ops/shared'
|
||||||
import type {PollOptions} from '../ops/shared'
|
import type {PollOptions} from '../ops/shared'
|
||||||
|
|
||||||
@@ -32,18 +22,56 @@ export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
|
|||||||
maxWaitMs: 15 * 60_000,
|
maxWaitMs: 15 * 60_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify
|
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||||
// convention) - that suffix is the invoice's real payment hash. A service
|
const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x')
|
||||||
// that shapes its verify URL differently gets a wallet-local correlation
|
const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600
|
||||||
// id instead (sha256 of the invoice): still unique and stable for
|
const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60
|
||||||
// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
|
|
||||||
|
// The kit has already validated the invoice before this parser runs. Read
|
||||||
|
// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays
|
||||||
|
// durable forever rather than risking retirement while it could still settle.
|
||||||
|
export const invoiceRetireAfter = (invoice: string): number | null => {
|
||||||
|
const normalized = invoice.toLowerCase()
|
||||||
|
const separator = normalized.lastIndexOf('1')
|
||||||
|
if (separator < 1 || normalized.length - separator <= 6) return null
|
||||||
|
const words = [...normalized.slice(separator + 1, -6)].map((character) =>
|
||||||
|
BECH32_CHARSET.indexOf(character),
|
||||||
|
)
|
||||||
|
if (words.length < 7 || words.some((word) => word < 0)) return null
|
||||||
|
const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0)
|
||||||
|
if (!Number.isSafeInteger(timestamp)) return null
|
||||||
|
let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS
|
||||||
|
let offset = 7
|
||||||
|
// Conformance invoices omit the 65-byte signature; real BOLT11 invoices
|
||||||
|
// include its fixed 104 words after the tagged fields.
|
||||||
|
const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length
|
||||||
|
while (offset + 3 <= taggedEnd) {
|
||||||
|
const tag = words[offset]
|
||||||
|
const firstLength = words[offset + 1]
|
||||||
|
const secondLength = words[offset + 2]
|
||||||
|
if (tag === undefined || firstLength === undefined || secondLength === undefined) return null
|
||||||
|
const length = firstLength * 32 + secondLength
|
||||||
|
const valueStart = offset + 3
|
||||||
|
const valueEnd = valueStart + length
|
||||||
|
if (valueEnd > taggedEnd) return null
|
||||||
|
if (tag === BOLT11_EXPIRY_TAG) {
|
||||||
|
expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0)
|
||||||
|
if (!Number.isSafeInteger(expiry)) return null
|
||||||
|
}
|
||||||
|
offset = valueEnd
|
||||||
|
}
|
||||||
|
const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS
|
||||||
|
return Number.isSafeInteger(retireAfter) ? retireAfter : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// LUD-21 verify URLs conventionally end in the payment hash. A service
|
||||||
|
// that uses another shape gets a stable wallet-local correlation id.
|
||||||
export const resolvePaymentHash = (prepared: PreparedMint): string => {
|
export const resolvePaymentHash = (prepared: PreparedMint): string => {
|
||||||
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
|
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
|
||||||
if (fromVerify) return fromVerify.toLowerCase()
|
if (fromVerify) return fromVerify.toLowerCase()
|
||||||
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)))
|
return bytesToHex(sha256(utf8ToBytes(prepared.invoice)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// the NIP-47 transaction object make_invoice and lookup_invoice share
|
|
||||||
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
|
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
@@ -56,89 +84,18 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
|
|||||||
}
|
}
|
||||||
if (entry.description) result.description = entry.description
|
if (entry.description) result.description = entry.description
|
||||||
if (entry.expiresAt) result.expires_at = entry.expiresAt
|
if (entry.expiresAt) result.expires_at = entry.expiresAt
|
||||||
if (entry.state === 'settled' && entry.preimage) {
|
if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage
|
||||||
result.preimage = entry.preimage
|
|
||||||
}
|
|
||||||
if (entry.settledAt) result.settled_at = entry.settledAt
|
if (entry.settledAt) result.settled_at = entry.settledAt
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// the background half of make_invoice: watch the invoice, and once it
|
|
||||||
// settles claim the note (rotating it immediately on an unnamed mint) and
|
|
||||||
// hand the fresh bearer to the caller. Settlement is recorded LAST - after
|
|
||||||
// the claim and bearer commit - so lookup can only reveal durably tracked
|
|
||||||
// funds and, on an unnamed mint, an already-burned secret.
|
|
||||||
// Throws on any failure; the caller marks the entry failed and reports
|
|
||||||
// through deps.onError.
|
|
||||||
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
|
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
|
||||||
if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry)
|
|
||||||
if (!entry.prepared.verifyUrl) {
|
|
||||||
throw new Error(
|
|
||||||
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// the observation half is interruptible (the client may never pay, so
|
|
||||||
// the poll can legally outlive the service); once settlement is seen,
|
|
||||||
// everything below - claim, rotate, bearer commit - is fund-critical and
|
|
||||||
// deliberately ignores the stop signal: stop's drain awaits it
|
|
||||||
const result = await pollVerifyUntilSettled(
|
|
||||||
entry.prepared.verifyUrl,
|
|
||||||
{...(ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL), signal: ctx.stopSignal},
|
|
||||||
ctx.deps.kit ?? {},
|
|
||||||
)
|
|
||||||
// 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, entry.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.')
|
|
||||||
}
|
|
||||||
// claimFromPreimage IS claimMintedNote's claim half (poll above is the
|
|
||||||
// other half) - invoked in two steps here because NWC needs the
|
|
||||||
// preimage, which claimMintedNote deliberately discards
|
|
||||||
const claimed = await claimFromPreimage(entry.prepared, preimage, {
|
|
||||||
...(ctx.deps.kit ?? {}),
|
|
||||||
assertOwner: ctx.assertOwner,
|
|
||||||
})
|
|
||||||
const add: NewBearer[] = [claimed.note]
|
|
||||||
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
|
|
||||||
await ctx.deps.applyChangeset(
|
|
||||||
{add, markSpent: []},
|
|
||||||
ctx.connection(),
|
|
||||||
'make_invoice',
|
|
||||||
ctx.assertOwner,
|
|
||||||
)
|
|
||||||
entry.settledAt = ctx.nowSeconds()
|
|
||||||
entry.state = 'settled'
|
|
||||||
if (claimed.rotated) {
|
|
||||||
// see the header: only a rotated note makes the preimage a worthless
|
|
||||||
// secret, safe to hand out as the settlement receipt
|
|
||||||
entry.preimage = preimage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the named-mint half of settleAndClaim: the note lands at the wallet's
|
|
||||||
// own secret, so its APPEARANCE there is the settlement observation and no
|
|
||||||
// rotate follows. When the mint also serves the quote's verify, that
|
|
||||||
// answer's preimage is kept as the NIP-47 receipt (an ordinary payment
|
|
||||||
// proof on a named mint); a named mint without verify yields a settled
|
|
||||||
// lookup with no preimage - correct, just receipt-less.
|
|
||||||
const settleAndClaimNamed = async (
|
|
||||||
ctx: RequestContext,
|
|
||||||
entry: PendingInvoice,
|
|
||||||
): Promise<void> => {
|
|
||||||
const noteSecret = entry.prepared.noteSecret
|
|
||||||
if (!noteSecret) {
|
|
||||||
throw new Error('This mint was prepared in named mode without a note secret.')
|
|
||||||
}
|
|
||||||
const prepared = entry.prepared
|
const prepared = entry.prepared
|
||||||
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL
|
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL
|
||||||
let preimage: string | null = null
|
let preimage: string | null = null
|
||||||
if (prepared.verifyUrl) {
|
if (prepared.verifyUrl) {
|
||||||
// the interruptible observation half: watch settlement on verify,
|
// Observation remains interruptible. Once settlement is observed, the
|
||||||
// then claim by secret below (fund-critical, uninterruptible)
|
// claim and durable update ignore shutdown while stop drains the task.
|
||||||
const result = await pollVerifyUntilSettled(
|
const result = await pollVerifyUntilSettled(
|
||||||
prepared.verifyUrl,
|
prepared.verifyUrl,
|
||||||
{...claimPoll, signal: ctx.stopSignal},
|
{...claimPoll, signal: ctx.stopSignal},
|
||||||
@@ -149,30 +106,23 @@ const settleAndClaimNamed = async (
|
|||||||
}
|
}
|
||||||
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage
|
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage
|
||||||
}
|
}
|
||||||
// without a verify URL the claim poll IS the observation half, so it
|
let claimed
|
||||||
// stays interruptible; after a verify-observed settlement the note is
|
try {
|
||||||
// already there and this returns on the first check
|
claimed = await claimFromSecret(
|
||||||
const claimed = await claimFromSecret(
|
prepared,
|
||||||
{...prepared, noteSecret},
|
|
||||||
prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal},
|
prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal},
|
||||||
{
|
{
|
||||||
...(ctx.deps.kit ?? {}),
|
...(ctx.deps.kit ?? {}),
|
||||||
assertOwner: ctx.assertOwner,
|
assertOwner: ctx.assertOwner,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const add: NewBearer[] = [claimed.note]
|
} catch (error) {
|
||||||
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
|
if (!(error instanceof MintedNoteSpentError)) throw error
|
||||||
await ctx.deps.applyChangeset(
|
await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner)
|
||||||
{add, markSpent: []},
|
throw error
|
||||||
ctx.connection(),
|
}
|
||||||
'make_invoice',
|
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner)
|
||||||
ctx.assertOwner,
|
|
||||||
)
|
|
||||||
entry.settledAt = ctx.nowSeconds()
|
entry.settledAt = ctx.nowSeconds()
|
||||||
entry.state = 'settled'
|
entry.state = 'settled'
|
||||||
// the rotate guard still matters here: the rescue path (a mint that
|
if (preimage) entry.preimage = preimage
|
||||||
// took the hash but credited the PREIMAGE) claims through
|
|
||||||
// claimFromPreimage, and if its rotate failed the preimage is still a
|
|
||||||
// LIVE note secret - withheld exactly like the unnamed path
|
|
||||||
if (preimage && claimed.rotated) entry.preimage = preimage
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
fetchInvoiceVerification,
|
fetchInvoiceVerification,
|
||||||
isBolt11Invoice,
|
isBolt11Invoice,
|
||||||
noteK1,
|
noteK1,
|
||||||
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
|
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
@@ -33,19 +34,29 @@ import {errResult, okResult} from './protocol'
|
|||||||
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
|
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
|
||||||
const add: NewBearer[] = []
|
const add: NewBearer[] = []
|
||||||
const markSpent: string[] = result.carve.consumed.map((b) => b.id)
|
const markSpent: string[] = result.carve.consumed.map((b) => b.id)
|
||||||
if (result.carve.change) add.push(result.carve.change)
|
// the staged change's URL may lack the signature the landed result's URL
|
||||||
|
// carries - identify outputs by their secret, not the whole URL
|
||||||
|
const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null
|
||||||
|
if (
|
||||||
|
result.carve.change &&
|
||||||
|
!bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1)
|
||||||
|
) {
|
||||||
|
add.push(result.carve.change)
|
||||||
|
}
|
||||||
|
const carvedK1 = noteK1(result.carve.note.url)
|
||||||
|
const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined
|
||||||
if (result.outcome === 'failed-funds-returned') {
|
if (result.outcome === 'failed-funds-returned') {
|
||||||
add.push(result.rotatedNote ?? result.carve.note)
|
if (existing) {
|
||||||
|
const index = markSpent.indexOf(existing.id)
|
||||||
|
if (index >= 0) markSpent.splice(index, 1)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// settled / still-pending / already-spent: the carved note is gone or
|
// settled / still-pending / already-spent: the carved note is gone or
|
||||||
// locked. When it was one of the wallet's own bearers (an exact-match
|
// locked. When it was one of the wallet's own bearers (an exact-match
|
||||||
// carve), lock that bearer; a freshly carved note is never added -
|
// carve), lock that bearer; a freshly carved note is never added -
|
||||||
// it was born spent
|
// it was born spent
|
||||||
const carvedK1 = noteK1(result.carve.note.url)
|
|
||||||
const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined
|
|
||||||
if (existing) markSpent.push(existing.id)
|
if (existing) markSpent.push(existing.id)
|
||||||
}
|
}
|
||||||
if (result.rescuedNote) add.push(result.rescuedNote)
|
|
||||||
return {add, markSpent}
|
return {add, markSpent}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +90,40 @@ export const handlePayInvoice = async (
|
|||||||
}
|
}
|
||||||
const bearers = ctx.deps.getBearers()
|
const bearers = ctx.deps.getBearers()
|
||||||
let result: PayResult
|
let result: PayResult
|
||||||
|
let committedCarve: Bearer | undefined
|
||||||
|
let returnStage: Bearer | undefined
|
||||||
try {
|
try {
|
||||||
result = await payWithBearers(bearers, invoice, {
|
result = await payWithBearers(bearers, invoice, {
|
||||||
poll: ctx.deps.poll ?? {},
|
poll: ctx.deps.poll ?? {},
|
||||||
kit: ctx.deps.kit ?? {},
|
kit: ctx.deps.kit ?? {},
|
||||||
assertOwner: ctx.assertOwner,
|
assertOwner: ctx.assertOwner,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
|
||||||
|
mintSignatureKeys: ctx.deps.mintSignatureKeys,
|
||||||
|
onCarve: async (carve) => {
|
||||||
|
committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner)
|
||||||
|
},
|
||||||
|
onReturnReady: async (carve, recoverySecret) => {
|
||||||
|
const source =
|
||||||
|
committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url)
|
||||||
|
if (!source) throw new Error('The carved note was not tracked.')
|
||||||
|
returnStage = await ctx.deps.persistMintOutput(
|
||||||
|
{
|
||||||
|
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
|
||||||
|
callback: '',
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: {retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60},
|
||||||
|
},
|
||||||
|
ctx.assertOwner,
|
||||||
|
)
|
||||||
|
await ctx.deps.reserveMintOutputSource(
|
||||||
|
returnStage,
|
||||||
|
source.id,
|
||||||
|
recoverySecret,
|
||||||
|
ctx.assertOwner,
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UncertainOutcomeError) {
|
if (err instanceof UncertainOutcomeError) {
|
||||||
@@ -109,6 +149,14 @@ export const handlePayInvoice = async (
|
|||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (returnStage) {
|
||||||
|
const returned = result.rotatedNote ?? result.rescuedNote
|
||||||
|
if (returned) {
|
||||||
|
await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner)
|
||||||
|
} else if (result.outcome !== 'unknown-still-pending') {
|
||||||
|
await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
const spendRecorded = (): void => {
|
const spendRecorded = (): void => {
|
||||||
// This conservative budget debit is persisted separately from bearer
|
// This conservative budget debit is persisted separately from bearer
|
||||||
// storage. A later bearer commit failure does not roll it back.
|
// storage. A later bearer commit failure does not roll it back.
|
||||||
@@ -119,7 +167,7 @@ export const handlePayInvoice = async (
|
|||||||
case 'settled': {
|
case 'settled': {
|
||||||
spendRecorded()
|
spendRecorded()
|
||||||
await ctx.deps.applyChangeset(
|
await ctx.deps.applyChangeset(
|
||||||
payChangeset(bearers, result),
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
ctx.connection(),
|
ctx.connection(),
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
ctx.assertOwner,
|
ctx.assertOwner,
|
||||||
@@ -142,7 +190,7 @@ export const handlePayInvoice = async (
|
|||||||
}
|
}
|
||||||
case 'failed-funds-returned':
|
case 'failed-funds-returned':
|
||||||
await ctx.deps.applyChangeset(
|
await ctx.deps.applyChangeset(
|
||||||
payChangeset(bearers, result),
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
ctx.connection(),
|
ctx.connection(),
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
ctx.assertOwner,
|
ctx.assertOwner,
|
||||||
@@ -154,7 +202,7 @@ export const handlePayInvoice = async (
|
|||||||
)
|
)
|
||||||
case 'note-already-spent':
|
case 'note-already-spent':
|
||||||
await ctx.deps.applyChangeset(
|
await ctx.deps.applyChangeset(
|
||||||
payChangeset(bearers, result),
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
ctx.connection(),
|
ctx.connection(),
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
ctx.assertOwner,
|
ctx.assertOwner,
|
||||||
@@ -167,7 +215,7 @@ export const handlePayInvoice = async (
|
|||||||
case 'unknown-still-pending':
|
case 'unknown-still-pending':
|
||||||
spendRecorded()
|
spendRecorded()
|
||||||
await ctx.deps.applyChangeset(
|
await ctx.deps.applyChangeset(
|
||||||
payChangeset(bearers, result),
|
payChangeset(ctx.deps.getBearers(), result),
|
||||||
ctx.connection(),
|
ctx.connection(),
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
ctx.assertOwner,
|
ctx.assertOwner,
|
||||||
|
|||||||
@@ -89,13 +89,24 @@ export const startService = async (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pay_invoice goes through the connection's queue (budget atomicity);
|
let invoiceQueue: Promise<unknown> = Promise.resolve()
|
||||||
// everything else dispatches directly
|
|
||||||
|
// Payments serialize per connection for budget atomicity. Invoice
|
||||||
|
// creation serializes across the service so different clients cannot
|
||||||
|
// race the wallet-wide durable pending-output cap.
|
||||||
const dispatchSerialized = (
|
const dispatchSerialized = (
|
||||||
runtime: ConnectionRuntime,
|
runtime: ConnectionRuntime,
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
request: NwcRequest,
|
request: NwcRequest,
|
||||||
): Promise<NwcResponse> => {
|
): Promise<NwcResponse> => {
|
||||||
|
if (request.method === 'make_invoice') {
|
||||||
|
const run = invoiceQueue.then(
|
||||||
|
() => dispatch(ctx, request),
|
||||||
|
() => dispatch(ctx, request),
|
||||||
|
)
|
||||||
|
invoiceQueue = run.catch(() => undefined)
|
||||||
|
return run
|
||||||
|
}
|
||||||
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
|
if (request.method !== 'pay_invoice') return dispatch(ctx, request)
|
||||||
const run = runtime.queue.then(
|
const run = runtime.queue.then(
|
||||||
() => dispatch(ctx, request),
|
() => dispatch(ctx, request),
|
||||||
@@ -223,8 +234,12 @@ export const startService = async (
|
|||||||
accepting = false
|
accepting = false
|
||||||
for (const runtime of runtimes) runtime.sub.close()
|
for (const runtime of runtimes) runtime.sub.close()
|
||||||
stopController.abort()
|
stopController.abort()
|
||||||
stopPromise = Promise.all([...inFlight])
|
stopPromise = Promise.allSettled([...inFlight])
|
||||||
.then(() => undefined)
|
.then((results) => {
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') throw result.reason
|
||||||
|
}
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
for (const runtime of runtimes) {
|
for (const runtime of runtimes) {
|
||||||
runtime.walletSecret?.fill(0)
|
runtime.walletSecret?.fill(0)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import type { NwcService, NwcServiceDeps } from '@/lnurlcash/nwc';
|
import type { NwcService, NwcServiceDeps } from '@/lnurlcash/nwc';
|
||||||
import type * as NwcExports from '@/lnurlcash/nwc';
|
import type * as NwcExports from '@/lnurlcash/nwc';
|
||||||
|
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
|
||||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
import type { NewBearer } from '@/lnurlcash/types';
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
import { useActivityStore } from './activity';
|
import { useActivityStore } from './activity';
|
||||||
@@ -29,7 +30,11 @@ const note = (secret: string): NewBearer => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.stubGlobal('navigator', {});
|
// a present-but-non-serializing LockManager fake: walletFunds serializes
|
||||||
|
// its own mutations, so single-store tests only need locks to EXIST
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
|
||||||
|
});
|
||||||
stubLocalStorage();
|
stubLocalStorage();
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
mocks.startService.mockResolvedValue({
|
mocks.startService.mockResolvedValue({
|
||||||
@@ -69,7 +74,7 @@ describe('NWC store changeset adapter', () => {
|
|||||||
start[1].assertCurrentOwner,
|
start[1].assertCurrentOwner,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
|
expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
|
||||||
expect(wallet.bearers).toHaveLength(2);
|
expect(wallet.bearers).toHaveLength(2);
|
||||||
expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true);
|
expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,16 +16,58 @@ import {
|
|||||||
import { setNwcTransportForTests, useNwcStore } from './nwc';
|
import { setNwcTransportForTests, useNwcStore } from './nwc';
|
||||||
import { useWalletStore } from './wallet';
|
import { useWalletStore } from './wallet';
|
||||||
|
|
||||||
|
// 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(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.stubGlobal('navigator', {});
|
stubPassthroughLocks();
|
||||||
stubLocalStorage();
|
stubLocalStorage();
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
setNwcTransportForTests(null);
|
setNwcTransportForTests(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('NWC wallet lifecycle drain', () => {
|
describe('NWC wallet lifecycle drain', () => {
|
||||||
|
it('keeps one service when enabling twice', async () => {
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create();
|
||||||
|
const relay = createFakeRelay();
|
||||||
|
setNwcTransportForTests(relay.transport);
|
||||||
|
const nwc = useNwcStore();
|
||||||
|
nwc.create(RELAYS, { maxMsat: 50_000, periodMs: 86_400_000 });
|
||||||
|
|
||||||
|
await nwc.setEnabled(true);
|
||||||
|
await nwc.setEnabled(true);
|
||||||
|
|
||||||
|
expect(relay.subscriptionCount()).toBe(1);
|
||||||
|
await nwc.stop();
|
||||||
|
expect(relay.subscriptionCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drains an accepted foreground fund operation before lock clears its fence', async () => {
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
await wallet.create('password');
|
||||||
|
const operation = wallet.beginFundOperation();
|
||||||
|
let lockSettled = false;
|
||||||
|
|
||||||
|
const locking = wallet.lock().then(() => {
|
||||||
|
lockSettled = true;
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(() => wallet.captureOwnerFence()).toThrow());
|
||||||
|
expect(lockSettled).toBe(false);
|
||||||
|
operation.ownerFence();
|
||||||
|
|
||||||
|
operation.complete();
|
||||||
|
await locking;
|
||||||
|
expect(wallet.state).toBe('locked');
|
||||||
|
});
|
||||||
|
|
||||||
it('commits an accepted melted payment before ordinary lock invalidates its owner fence', async () => {
|
it('commits an accepted melted payment before ordinary lock invalidates its owner fence', async () => {
|
||||||
// Given a real encrypted wallet and NWC service whose payment is held
|
// Given a real encrypted wallet and NWC service whose payment is held
|
||||||
// after the mint burned its note but before the wallet commits the delta
|
// after the mint burned its note but before the wallet commits the delta
|
||||||
|
|||||||
+79
-1
@@ -1,3 +1,4 @@
|
|||||||
|
// allow: SIZE_OK — indivisible NWC service lifecycle state machine and Pinia control surface.
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
writeNwcEnabled,
|
writeNwcEnabled,
|
||||||
} from '@/lnurlcash/nwc';
|
} from '@/lnurlcash/nwc';
|
||||||
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||||
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
import { msatToSats } from '@/lnurlcash/units';
|
import { msatToSats } from '@/lnurlcash/units';
|
||||||
import { TrustedMintPostCommitError, useWalletStore } from './wallet';
|
import { TrustedMintPostCommitError, useWalletStore } from './wallet';
|
||||||
import { useMintsStore } from './mints';
|
import { useMintsStore } from './mints';
|
||||||
@@ -142,11 +144,77 @@ export const useNwcStore = defineStore('nwc', () => {
|
|||||||
lastError.value = '';
|
lastError.value = '';
|
||||||
try {
|
try {
|
||||||
const ownerFence = wallet.captureOwnerFence();
|
const ownerFence = wallet.captureOwnerFence();
|
||||||
|
if (wallet.bearers.some((bearer) => bearer.pendingMint && !bearer.spent)) {
|
||||||
|
await wallet.recoverPendingMints(ownerFence);
|
||||||
|
if (token !== startToken || wallet.state !== 'unlocked' || !enabled.value) return;
|
||||||
|
}
|
||||||
const started = await startService(wallet.requireLinkingKey(), {
|
const started = await startService(wallet.requireLinkingKey(), {
|
||||||
// only spendable notes may back an NWC payment
|
// only spendable notes may back an NWC payment
|
||||||
getBearers: () => wallet.unspentBearers,
|
getBearers: () => wallet.unspentBearers,
|
||||||
getDefaultMint: () => mints.defaultMint,
|
getDefaultMint: () => mints.defaultMint,
|
||||||
assertCurrentOwner: ownerFence,
|
assertCurrentOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count, assertOwner) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, assertOwner),
|
||||||
|
// a mid-request lockdown must not verify anything new: no keys
|
||||||
|
mintSignatureKeys: (server) =>
|
||||||
|
wallet.state === 'unlocked' && wallet.pubkey !== null
|
||||||
|
? getTrustedMintVerificationKeys(server, wallet.pubkey)
|
||||||
|
: [],
|
||||||
|
recoverPendingMints: (assertOwner) => wallet.recoverPendingMints(assertOwner),
|
||||||
|
persistMintOutput: async (note, assertOwner) => {
|
||||||
|
const [persisted] = await wallet.addBearers([note], assertOwner);
|
||||||
|
if (!persisted) throw new Error('The pending mint output was not persisted.');
|
||||||
|
return persisted;
|
||||||
|
},
|
||||||
|
discardMintOutput: async (staged, assertOwner) => {
|
||||||
|
await wallet.removeNote(staged.id, assertOwner);
|
||||||
|
},
|
||||||
|
setMintOutputRetirement: async (staged, retireAfter, assertOwner) =>
|
||||||
|
wallet.updateBearer(
|
||||||
|
staged.id,
|
||||||
|
{ pendingMint: { ...staged.pendingMint, retireAfter } },
|
||||||
|
assertOwner,
|
||||||
|
),
|
||||||
|
reserveMintOutputSource: async (staged, sourceId, recoverySecret, assertOwner) => {
|
||||||
|
await wallet.reserveStagedMintSource(staged.id, sourceId, recoverySecret, assertOwner);
|
||||||
|
},
|
||||||
|
finalizeMintOutput: async (staged, note, assertOwner) => {
|
||||||
|
try {
|
||||||
|
await wallet.finalizeStagedMintOutput(staged.id, note, assertOwner);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
lastError.value = error.message;
|
||||||
|
}
|
||||||
|
await activity.log(
|
||||||
|
'nwc',
|
||||||
|
`Received ${formatSats(note.amount)} sats via NWC.`,
|
||||||
|
(error) => {
|
||||||
|
lastError.value = error.message;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
finalizePaymentReturn: async (staged, note, assertOwner) => {
|
||||||
|
try {
|
||||||
|
await wallet.finalizeStagedMintOutput(staged.id, note, assertOwner);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
lastError.value = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finalizeSpentMintOutput: async (staged, assertOwner) => {
|
||||||
|
await wallet.finalizeSpentStagedMintOutput(staged.id, assertOwner);
|
||||||
|
},
|
||||||
|
commitCarve: async (carve, assertOwner) => {
|
||||||
|
try {
|
||||||
|
return await wallet.commitCarve(carve, assertOwner);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
lastError.value = error.message;
|
||||||
|
const committed = wallet.bearers.find((bearer) => bearer.url === carve.note.url);
|
||||||
|
if (!committed) throw error;
|
||||||
|
return committed;
|
||||||
|
}
|
||||||
|
},
|
||||||
applyChangeset,
|
applyChangeset,
|
||||||
transport: transportOverride ?? undefined,
|
transport: transportOverride ?? undefined,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -193,7 +261,13 @@ export const useNwcStore = defineStore('nwc', () => {
|
|||||||
running.value = false;
|
running.value = false;
|
||||||
const priorStop = stopping;
|
const priorStop = stopping;
|
||||||
const activeStop = active?.stop() ?? Promise.resolve();
|
const activeStop = active?.stop() ?? Promise.resolve();
|
||||||
const completion = Promise.all([priorStop, activeStop, ...pendingStarts]).then(() => undefined);
|
const completion = Promise.allSettled([priorStop, activeStop, ...pendingStarts]).then(
|
||||||
|
(results) => {
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') throw result.reason;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
pendingStop = completion;
|
pendingStop = completion;
|
||||||
stopping = completion.catch(() => undefined);
|
stopping = completion.catch(() => undefined);
|
||||||
return completion;
|
return completion;
|
||||||
@@ -232,6 +306,10 @@ export const useNwcStore = defineStore('nwc', () => {
|
|||||||
|
|
||||||
// ---- settings ----
|
// ---- settings ----
|
||||||
const setEnabled = async (value: boolean): Promise<void> => {
|
const setEnabled = async (value: boolean): Promise<void> => {
|
||||||
|
if (value === enabled.value) {
|
||||||
|
if (value && !running.value && pendingStarts.size === 0) await start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ownerId = ownerFromWallet();
|
const ownerId = ownerFromWallet();
|
||||||
writeNwcEnabled(ownerId, value);
|
writeNwcEnabled(ownerId, value);
|
||||||
enabled.value = value;
|
enabled.value = value;
|
||||||
|
|||||||
+36
-2
@@ -67,6 +67,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
let currentMaterial: WalletMaterialV2 | null = null;
|
let currentMaterial: WalletMaterialV2 | null = null;
|
||||||
let lifecycleToken = 0;
|
let lifecycleToken = 0;
|
||||||
let acceptingOwnerWork = false;
|
let acceptingOwnerWork = false;
|
||||||
|
const foregroundFundOperations = new Set<Promise<void>>();
|
||||||
|
|
||||||
const lockWarningSecondsLeft = ref<number | null>(null);
|
const lockWarningSecondsLeft = ref<number | null>(null);
|
||||||
const runTransition = createWalletTransitionQueue({
|
const runTransition = createWalletTransitionQueue({
|
||||||
@@ -105,12 +106,22 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
pubkey.value = null;
|
pubkey.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const drainAcceptedOwnerWork = async (): Promise<void> => {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
stopWalletNwcSession(),
|
||||||
|
...foregroundFundOperations,
|
||||||
|
]);
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') throw result.reason;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deactivateSession = async (): Promise<void> => {
|
const deactivateSession = async (): Promise<void> => {
|
||||||
acceptingOwnerWork = false;
|
acceptingOwnerWork = false;
|
||||||
stopOwnerChanges();
|
stopOwnerChanges();
|
||||||
idleWatch.stop();
|
idleWatch.stop();
|
||||||
try {
|
try {
|
||||||
await stopWalletNwcSession();
|
await drainAcceptedOwnerWork();
|
||||||
} finally {
|
} finally {
|
||||||
// even a rejected drain ends the session: 'locked' never holds key
|
// even a rejected drain ends the session: 'locked' never holds key
|
||||||
// material and no captured fence stays valid
|
// material and no captured fence stays valid
|
||||||
@@ -201,7 +212,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
// the drain runs before the fence is invalidated and the runtime is
|
// the drain runs before the fence is invalidated and the runtime is
|
||||||
// cleared so an in-flight fund-critical changeset can still commit
|
// cleared so an in-flight fund-critical changeset can still commit
|
||||||
// (its applyChangeset needs the live fence and key)
|
// (its applyChangeset needs the live fence and key)
|
||||||
await stopWalletNwcSession();
|
await drainAcceptedOwnerWork();
|
||||||
invalidateLifecycle();
|
invalidateLifecycle();
|
||||||
clearRuntime();
|
clearRuntime();
|
||||||
if (ownerId === null) await clearUnownedAuthorizations();
|
if (ownerId === null) await clearUnownedAuthorizations();
|
||||||
@@ -323,6 +334,28 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
return currentMaterial;
|
return currentMaterial;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const beginFundOperation = (): {
|
||||||
|
readonly ownerFence: () => void;
|
||||||
|
readonly complete: () => void;
|
||||||
|
} => {
|
||||||
|
const captured = ownerFence.capture();
|
||||||
|
let completePromise: (() => void) | undefined;
|
||||||
|
const completion = new Promise<void>((resolve) => {
|
||||||
|
completePromise = resolve;
|
||||||
|
});
|
||||||
|
foregroundFundOperations.add(completion);
|
||||||
|
let active = true;
|
||||||
|
return {
|
||||||
|
ownerFence: captured,
|
||||||
|
complete: () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
foregroundFundOperations.delete(completion);
|
||||||
|
completePromise?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const funds = createWalletFunds({
|
const funds = createWalletFunds({
|
||||||
requireKey,
|
requireKey,
|
||||||
requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex),
|
requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex),
|
||||||
@@ -355,5 +388,6 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
requireLinkingKey,
|
requireLinkingKey,
|
||||||
requireWalletMaterial,
|
requireWalletMaterial,
|
||||||
captureOwnerFence: ownerFence.capture,
|
captureOwnerFence: ownerFence.capture,
|
||||||
|
beginFundOperation,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user