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,
|
||||
} from './nwc.testProtocol'
|
||||
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', () => {
|
||||
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 {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||
defaultMint: `mint@127.0.0.1:${m.port}`,
|
||||
@@ -88,7 +103,9 @@ describe('service: make_invoice / lookup_invoice', () => {
|
||||
}
|
||||
expect(invoice).toMatch(/^lnbc/)
|
||||
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
|
||||
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||
payment_hash: paymentHash,
|
||||
@@ -101,7 +118,7 @@ describe('service: make_invoice / lookup_invoice', () => {
|
||||
// mints the note
|
||||
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))
|
||||
await waitFor(() => state.bearers.some((bearer) => bearer.verified))
|
||||
|
||||
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
|
||||
payment_hash: paymentHash,
|
||||
@@ -112,11 +129,10 @@ describe('service: make_invoice / lookup_invoice', () => {
|
||||
const preimage = requiredString(settled.result?.preimage)
|
||||
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
|
||||
|
||||
// the minted note was claimed AND rotated before settlement was
|
||||
// recorded: the preimage the client just learned is a burned secret,
|
||||
// and the wallet's fresh note is the only live one
|
||||
expect(m.state.noteState(preimage)).toBe('burned')
|
||||
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
|
||||
// The staged record was updated in place before settlement became
|
||||
// visible. The payment preimage is only a receipt and keys no note.
|
||||
expect(m.state.noteState(preimage)).toBeNull()
|
||||
const minted = requiredValue(state.bearers.find((b) => b.id === staged.id))
|
||||
expect(minted.amount).toBe(21_000)
|
||||
expect(minted.verified).toBe(true)
|
||||
expect(noteK1(minted.url)).not.toBe(preimage)
|
||||
@@ -124,129 +140,6 @@ describe('service: make_invoice / lookup_invoice', () => {
|
||||
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 () => {
|
||||
const m = await mint({testHooks: true})
|
||||
const commit = deferred()
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
waitFor,
|
||||
} from './nwc.testProtocol'
|
||||
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)', () => {
|
||||
it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
|
||||
const m = await mint({testHooks: true})
|
||||
@@ -230,6 +231,99 @@ describe('service: make_invoice / lookup_invoice (continued)', () => {
|
||||
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 () => {
|
||||
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||
const response = await call(relay, walletServicePubkey, 'make_invoice', {
|
||||
|
||||
@@ -71,8 +71,19 @@ describe('service ownership', () => {
|
||||
|
||||
const service = await startService(LINKING_KEY, {
|
||||
assertCurrentOwner: () => undefined,
|
||||
allocateOutputSecrets: () => Promise.resolve([]),
|
||||
mintSignatureKeys: () => [],
|
||||
recoverPendingMints: () => Promise.resolve(),
|
||||
reserveMintOutputSource: () => Promise.resolve(),
|
||||
finalizePaymentReturn: () => Promise.resolve(),
|
||||
getBearers: () => [],
|
||||
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(),
|
||||
transport: relay.transport,
|
||||
nowSeconds,
|
||||
@@ -102,8 +113,19 @@ describe('service ownership', () => {
|
||||
LINKING_KEY,
|
||||
{
|
||||
assertCurrentOwner: () => undefined,
|
||||
allocateOutputSecrets: () => Promise.resolve([]),
|
||||
mintSignatureKeys: () => [],
|
||||
recoverPendingMints: () => Promise.resolve(),
|
||||
reserveMintOutputSource: () => Promise.resolve(),
|
||||
finalizePaymentReturn: () => Promise.resolve(),
|
||||
getBearers: () => [],
|
||||
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(),
|
||||
transport: relay.transport,
|
||||
nowSeconds,
|
||||
|
||||
@@ -138,21 +138,38 @@ describe('service: pay_invoice (continued)', () => {
|
||||
// running out, and that wait is the test's own clock
|
||||
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', {
|
||||
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||
})
|
||||
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')
|
||||
const returned = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
|
||||
expect(returned.spent).toBeUndefined()
|
||||
expect(returned.amount).toBe(21_000)
|
||||
expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding')
|
||||
expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true)
|
||||
expect(
|
||||
state.bearers.find((bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent),
|
||||
).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)
|
||||
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.
|
||||
|
||||
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 {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||
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 {
|
||||
@@ -114,6 +115,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
let bearerCounter = 0
|
||||
let allocationCounter = 0
|
||||
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
|
||||
m.state.creditNote(k1, amountMsat)
|
||||
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat)
|
||||
@@ -143,6 +145,7 @@ export const startTestService = async (options: {
|
||||
claimPoll?: typeof FAST_POLL
|
||||
kit?: NwcServiceDeps['kit']
|
||||
commitChangeset?: (changeset: NwcChangeset) => Promise<void>
|
||||
setMintOutputRetirement?: () => Promise<void>
|
||||
}): Promise<{
|
||||
relay: ReturnType<typeof createFakeRelay>
|
||||
walletServicePubkey: string
|
||||
@@ -164,8 +167,120 @@ export const startTestService = async (options: {
|
||||
}
|
||||
const service = await startService(options.linkingKey ?? LINKING_KEY, {
|
||||
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,
|
||||
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) => {
|
||||
await options.commitChangeset?.(changeset)
|
||||
state.changesets.push(changeset)
|
||||
|
||||
@@ -10,6 +10,9 @@ import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import type {NwcConnectionRecord} from '../storage/nwcConnections'
|
||||
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 {NwcConnectionInfo} from './connection'
|
||||
@@ -31,6 +34,38 @@ export type NwcServiceDeps = {
|
||||
// mint - NIP-47's make_invoice carries no mint choice)
|
||||
getDefaultMint: () => string | null
|
||||
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: (
|
||||
changeset: NwcChangeset,
|
||||
connection: NwcConnectionInfo,
|
||||
@@ -61,6 +96,7 @@ export type PendingInvoice = {
|
||||
createdAt: number // unix seconds
|
||||
expiresAt?: number
|
||||
prepared: PreparedMint
|
||||
stagedOutput: Bearer
|
||||
state: 'pending' | 'settled' | 'failed'
|
||||
preimage?: string
|
||||
settledAt?: number
|
||||
|
||||
@@ -12,11 +12,13 @@ import {prepareMint} from '../ops'
|
||||
import {PollAbortedError} from '../ops/shared'
|
||||
|
||||
import type {PendingInvoice, RequestContext} from './context'
|
||||
import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices'
|
||||
import {invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim} from './invoices'
|
||||
import {handlePayInvoice} from './pay'
|
||||
import type {NwcRequest, NwcResponse} 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
|
||||
// this wallet actually pay with right now"
|
||||
const spendable = (bearer: Bearer): boolean =>
|
||||
@@ -55,18 +57,86 @@ const handleMakeInvoice = async (
|
||||
if (!mint) {
|
||||
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 stagedOutput: Bearer | undefined
|
||||
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) {
|
||||
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))
|
||||
}
|
||||
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 = {
|
||||
invoice: prepared.invoice,
|
||||
paymentHash: resolvePaymentHash(prepared),
|
||||
amountMsat: prepared.grossMsat,
|
||||
createdAt: ctx.nowSeconds(),
|
||||
prepared,
|
||||
stagedOutput,
|
||||
state: 'pending',
|
||||
}
|
||||
if (typeof params.description === 'string' && params.description) {
|
||||
|
||||
+64
-114
@@ -1,26 +1,16 @@
|
||||
// The invoice registry the two-phase make_invoice / lookup_invoice pair
|
||||
// shares: make_invoice answers with the invoice immediately while this
|
||||
// module's settleAndClaim watches settlement in the background, claims
|
||||
// the minted note, and only THEN records the settlement the lookup
|
||||
// reports.
|
||||
//
|
||||
// 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.
|
||||
// shares. A minted note always lives at the wallet-chosen secret that was
|
||||
// durably staged before quote creation; the payment preimage is only a
|
||||
// receipt. Settlement becomes visible only after the staged bearer has
|
||||
// been replaced with the confirmed note details.
|
||||
|
||||
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
|
||||
import {sha256} from '@noble/hashes/sha2.js'
|
||||
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||
|
||||
import type {NewBearer} from '../types'
|
||||
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 type {PollOptions} from '../ops/shared'
|
||||
|
||||
@@ -32,18 +22,56 @@ export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
|
||||
maxWaitMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
// LUD-21 verify URLs end in /verify/<payment_hash> (the protocol's verify
|
||||
// convention) - that suffix is the invoice's real payment hash. A service
|
||||
// that shapes its verify URL differently gets a wallet-local correlation
|
||||
// id instead (sha256 of the invoice): still unique and stable for
|
||||
// make_invoice <-> lookup_invoice correlation, just not the on-chain hash.
|
||||
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||
const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x')
|
||||
const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600
|
||||
const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60
|
||||
|
||||
// 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 => {
|
||||
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1]
|
||||
if (fromVerify) return fromVerify.toLowerCase()
|
||||
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> => {
|
||||
const result: Record<string, unknown> = {
|
||||
type: 'incoming',
|
||||
@@ -56,89 +84,18 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
|
||||
}
|
||||
if (entry.description) result.description = entry.description
|
||||
if (entry.expiresAt) result.expires_at = entry.expiresAt
|
||||
if (entry.state === 'settled' && entry.preimage) {
|
||||
result.preimage = entry.preimage
|
||||
}
|
||||
if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage
|
||||
if (entry.settledAt) result.settled_at = entry.settledAt
|
||||
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> => {
|
||||
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 claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL
|
||||
let preimage: string | null = null
|
||||
if (prepared.verifyUrl) {
|
||||
// the interruptible observation half: watch settlement on verify,
|
||||
// then claim by secret below (fund-critical, uninterruptible)
|
||||
// Observation remains interruptible. Once settlement is observed, the
|
||||
// claim and durable update ignore shutdown while stop drains the task.
|
||||
const result = await pollVerifyUntilSettled(
|
||||
prepared.verifyUrl,
|
||||
{...claimPoll, signal: ctx.stopSignal},
|
||||
@@ -149,30 +106,23 @@ const settleAndClaimNamed = async (
|
||||
}
|
||||
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage
|
||||
}
|
||||
// without a verify URL the claim poll IS the observation half, so it
|
||||
// stays interruptible; after a verify-observed settlement the note is
|
||||
// already there and this returns on the first check
|
||||
const claimed = await claimFromSecret(
|
||||
{...prepared, noteSecret},
|
||||
let claimed
|
||||
try {
|
||||
claimed = await claimFromSecret(
|
||||
prepared,
|
||||
prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal},
|
||||
{
|
||||
...(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,
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof MintedNoteSpentError)) throw error
|
||||
await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner)
|
||||
throw error
|
||||
}
|
||||
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner)
|
||||
entry.settledAt = ctx.nowSeconds()
|
||||
entry.state = 'settled'
|
||||
// the rotate guard still matters here: the rescue path (a mint that
|
||||
// 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
|
||||
if (preimage) entry.preimage = preimage
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fetchInvoiceVerification,
|
||||
isBolt11Invoice,
|
||||
noteK1,
|
||||
withNewK1,
|
||||
} from 'lnurlcash-kit'
|
||||
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
@@ -33,19 +34,29 @@ import {errResult, okResult} from './protocol'
|
||||
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
|
||||
const add: NewBearer[] = []
|
||||
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') {
|
||||
add.push(result.rotatedNote ?? result.carve.note)
|
||||
if (existing) {
|
||||
const index = markSpent.indexOf(existing.id)
|
||||
if (index >= 0) markSpent.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
// carve), lock that bearer; a freshly carved note is never added -
|
||||
// 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 (result.rescuedNote) add.push(result.rescuedNote)
|
||||
return {add, markSpent}
|
||||
}
|
||||
|
||||
@@ -79,11 +90,40 @@ export const handlePayInvoice = async (
|
||||
}
|
||||
const bearers = ctx.deps.getBearers()
|
||||
let result: PayResult
|
||||
let committedCarve: Bearer | undefined
|
||||
let returnStage: Bearer | undefined
|
||||
try {
|
||||
result = await payWithBearers(bearers, invoice, {
|
||||
poll: ctx.deps.poll ?? {},
|
||||
kit: ctx.deps.kit ?? {},
|
||||
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) {
|
||||
if (err instanceof UncertainOutcomeError) {
|
||||
@@ -109,6 +149,14 @@ export const handlePayInvoice = async (
|
||||
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 => {
|
||||
// This conservative budget debit is persisted separately from bearer
|
||||
// storage. A later bearer commit failure does not roll it back.
|
||||
@@ -119,7 +167,7 @@ export const handlePayInvoice = async (
|
||||
case 'settled': {
|
||||
spendRecorded()
|
||||
await ctx.deps.applyChangeset(
|
||||
payChangeset(bearers, result),
|
||||
payChangeset(ctx.deps.getBearers(), result),
|
||||
ctx.connection(),
|
||||
'pay_invoice',
|
||||
ctx.assertOwner,
|
||||
@@ -142,7 +190,7 @@ export const handlePayInvoice = async (
|
||||
}
|
||||
case 'failed-funds-returned':
|
||||
await ctx.deps.applyChangeset(
|
||||
payChangeset(bearers, result),
|
||||
payChangeset(ctx.deps.getBearers(), result),
|
||||
ctx.connection(),
|
||||
'pay_invoice',
|
||||
ctx.assertOwner,
|
||||
@@ -154,7 +202,7 @@ export const handlePayInvoice = async (
|
||||
)
|
||||
case 'note-already-spent':
|
||||
await ctx.deps.applyChangeset(
|
||||
payChangeset(bearers, result),
|
||||
payChangeset(ctx.deps.getBearers(), result),
|
||||
ctx.connection(),
|
||||
'pay_invoice',
|
||||
ctx.assertOwner,
|
||||
@@ -167,7 +215,7 @@ export const handlePayInvoice = async (
|
||||
case 'unknown-still-pending':
|
||||
spendRecorded()
|
||||
await ctx.deps.applyChangeset(
|
||||
payChangeset(bearers, result),
|
||||
payChangeset(ctx.deps.getBearers(), result),
|
||||
ctx.connection(),
|
||||
'pay_invoice',
|
||||
ctx.assertOwner,
|
||||
|
||||
@@ -89,13 +89,24 @@ export const startService = async (
|
||||
)
|
||||
}
|
||||
|
||||
// pay_invoice goes through the connection's queue (budget atomicity);
|
||||
// everything else dispatches directly
|
||||
let invoiceQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
// 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 = (
|
||||
runtime: ConnectionRuntime,
|
||||
ctx: RequestContext,
|
||||
request: NwcRequest,
|
||||
): 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)
|
||||
const run = runtime.queue.then(
|
||||
() => dispatch(ctx, request),
|
||||
@@ -223,8 +234,12 @@ export const startService = async (
|
||||
accepting = false
|
||||
for (const runtime of runtimes) runtime.sub.close()
|
||||
stopController.abort()
|
||||
stopPromise = Promise.all([...inFlight])
|
||||
.then(() => undefined)
|
||||
stopPromise = Promise.allSettled([...inFlight])
|
||||
.then((results) => {
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') throw result.reason
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
for (const runtime of runtimes) {
|
||||
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 * as NwcExports from '@/lnurlcash/nwc';
|
||||
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import type { NewBearer } from '@/lnurlcash/types';
|
||||
import { useActivityStore } from './activity';
|
||||
@@ -29,7 +30,11 @@ const note = (secret: string): NewBearer => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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();
|
||||
setActivePinia(createPinia());
|
||||
mocks.startService.mockResolvedValue({
|
||||
@@ -69,7 +74,7 @@ describe('NWC store changeset adapter', () => {
|
||||
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.find(({ id }) => id === existing.id)?.spent).toBe(true);
|
||||
});
|
||||
|
||||
@@ -16,16 +16,58 @@ import {
|
||||
import { setNwcTransportForTests, useNwcStore } from './nwc';
|
||||
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(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
stubLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
setNwcTransportForTests(null);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// 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
|
||||
|
||||
+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 { defineStore } from 'pinia';
|
||||
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
writeNwcEnabled,
|
||||
} from '@/lnurlcash/nwc';
|
||||
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||
import { msatToSats } from '@/lnurlcash/units';
|
||||
import { TrustedMintPostCommitError, useWalletStore } from './wallet';
|
||||
import { useMintsStore } from './mints';
|
||||
@@ -142,11 +144,77 @@ export const useNwcStore = defineStore('nwc', () => {
|
||||
lastError.value = '';
|
||||
try {
|
||||
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(), {
|
||||
// only spendable notes may back an NWC payment
|
||||
getBearers: () => wallet.unspentBearers,
|
||||
getDefaultMint: () => mints.defaultMint,
|
||||
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,
|
||||
transport: transportOverride ?? undefined,
|
||||
onError: (error) => {
|
||||
@@ -193,7 +261,13 @@ export const useNwcStore = defineStore('nwc', () => {
|
||||
running.value = false;
|
||||
const priorStop = stopping;
|
||||
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;
|
||||
stopping = completion.catch(() => undefined);
|
||||
return completion;
|
||||
@@ -232,6 +306,10 @@ export const useNwcStore = defineStore('nwc', () => {
|
||||
|
||||
// ---- settings ----
|
||||
const setEnabled = async (value: boolean): Promise<void> => {
|
||||
if (value === enabled.value) {
|
||||
if (value && !running.value && pendingStarts.size === 0) await start();
|
||||
return;
|
||||
}
|
||||
const ownerId = ownerFromWallet();
|
||||
writeNwcEnabled(ownerId, value);
|
||||
enabled.value = value;
|
||||
|
||||
+36
-2
@@ -67,6 +67,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
let currentMaterial: WalletMaterialV2 | null = null;
|
||||
let lifecycleToken = 0;
|
||||
let acceptingOwnerWork = false;
|
||||
const foregroundFundOperations = new Set<Promise<void>>();
|
||||
|
||||
const lockWarningSecondsLeft = ref<number | null>(null);
|
||||
const runTransition = createWalletTransitionQueue({
|
||||
@@ -105,12 +106,22 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
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> => {
|
||||
acceptingOwnerWork = false;
|
||||
stopOwnerChanges();
|
||||
idleWatch.stop();
|
||||
try {
|
||||
await stopWalletNwcSession();
|
||||
await drainAcceptedOwnerWork();
|
||||
} finally {
|
||||
// even a rejected drain ends the session: 'locked' never holds key
|
||||
// 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
|
||||
// cleared so an in-flight fund-critical changeset can still commit
|
||||
// (its applyChangeset needs the live fence and key)
|
||||
await stopWalletNwcSession();
|
||||
await drainAcceptedOwnerWork();
|
||||
invalidateLifecycle();
|
||||
clearRuntime();
|
||||
if (ownerId === null) await clearUnownedAuthorizations();
|
||||
@@ -323,6 +334,28 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
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({
|
||||
requireKey,
|
||||
requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex),
|
||||
@@ -355,5 +388,6 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
requireLinkingKey,
|
||||
requireWalletMaterial,
|
||||
captureOwnerFence: ownerFence.capture,
|
||||
beginFundOperation,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user