mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: await NWC payment bearer commits
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
// Stale-owner fencing end to end: a service accepted work while its owner
|
||||||
|
// was still installed, and a second tab replaced the saved key AFTER the
|
||||||
|
// fence's last safe point - mid-melt, past the boundary where aborting is
|
||||||
|
// no longer possible. What must not happen is any stale-owner WRITE:
|
||||||
|
// no bearer changeset, no budget debit, no success response.
|
||||||
|
|
||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {bytesToHex} from '@noble/hashes/utils.js'
|
||||||
|
|
||||||
|
import {readNwcConnections} from './nwc'
|
||||||
|
import {
|
||||||
|
methodRequest,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol'
|
||||||
|
import {makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||||
|
|
||||||
|
describe('service: post-boundary stale-owner fencing', () => {
|
||||||
|
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => {
|
||||||
|
// Given a running service whose next mint call coincides with a second
|
||||||
|
// tab installing its own wallet (the first fetch is the melt itself:
|
||||||
|
// the exact-match carve performs no mint call of its own)
|
||||||
|
const m = await mint()
|
||||||
|
let swapped = false
|
||||||
|
const swappingFetch: typeof fetch = (input, init) => {
|
||||||
|
if (!swapped) {
|
||||||
|
swapped = true
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_linking_key',
|
||||||
|
JSON.stringify({
|
||||||
|
enc: false,
|
||||||
|
value: bytesToHex(OTHER_LINKING_KEY),
|
||||||
|
ownerId: OTHER_OWNER_ID,
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fetch(input, init)
|
||||||
|
}
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
kit: {fetch: swappingFetch},
|
||||||
|
})
|
||||||
|
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)]
|
||||||
|
const request = methodRequest(walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
})
|
||||||
|
|
||||||
|
// When the pay flows past the irreversible boundary and reaches the
|
||||||
|
// first owner-bound persistence (the conservative budget debit)
|
||||||
|
relay.emit(request)
|
||||||
|
await waitFor(() => state.errors.length > 0)
|
||||||
|
|
||||||
|
// Then the melt genuinely happened (we are past the boundary) ...
|
||||||
|
expect(swapped).toBe(true)
|
||||||
|
expect(m.state.noteState('d7'.repeat(32))).toBe('burned')
|
||||||
|
// ... but nothing under the stale owner moved: no budget debit, no
|
||||||
|
// bearer changeset, no success response (the failure surfaced through
|
||||||
|
// onError instead)
|
||||||
|
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0)
|
||||||
|
expect(state.changesets).toEqual([])
|
||||||
|
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull()
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||||
|
import {bytesToHex, hexToBytes} 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 {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc'
|
||||||
|
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||||
|
import type {NostrFilter} from './nwc/transport'
|
||||||
|
import type {NwcChangeset} from './nwc'
|
||||||
|
import type {Bearer} from './types'
|
||||||
|
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||||
|
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol'
|
||||||
|
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||||
|
describe('service: pay_invoice (continued)', () => {
|
||||||
|
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
|
||||||
|
const m = await mint()
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
budgetMsat: 20_000,
|
||||||
|
})
|
||||||
|
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)]
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
})
|
||||||
|
expect(response.error?.code).toBe('QUOTA_EXCEEDED')
|
||||||
|
// nothing moved: the note is untouched, no spend recorded
|
||||||
|
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding')
|
||||||
|
expect(requiredValue(state.bearers[0]).spent).toBeUndefined()
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets the allowance once the budget period has rolled over', async () => {
|
||||||
|
const m = await mint()
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
budgetMsat: 21_000,
|
||||||
|
periodMs: 60_000,
|
||||||
|
})
|
||||||
|
// simulate a fully spent budget from a period that ended long ago
|
||||||
|
const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0])
|
||||||
|
writeNwcConnections(OWNER_ID, [
|
||||||
|
{...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}},
|
||||||
|
])
|
||||||
|
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)]
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
})
|
||||||
|
expect(response.error).toBeNull()
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
|
||||||
|
const m = await mint()
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({})
|
||||||
|
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)]
|
||||||
|
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
})
|
||||||
|
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE')
|
||||||
|
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding')
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a request amount that mismatches the invoice amount', async () => {
|
||||||
|
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc210n1pjqrstuvwxyz',
|
||||||
|
amount: 5_000,
|
||||||
|
})
|
||||||
|
expect(response.error?.code).toBe('OTHER')
|
||||||
|
expect(response.error?.message).toMatch(/match/i)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an amount-less invoice instead of guessing', async () => {
|
||||||
|
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||||
|
const response = await call(relay, walletServicePubkey, 'pay_invoice', {
|
||||||
|
invoice: 'lnbc1pjqrstuvwxyz',
|
||||||
|
})
|
||||||
|
expect(response.error?.code).toBe('OTHER')
|
||||||
|
expect(response.error?.message).toMatch(/amount/i)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
|
||||||
|
const m = await mint({meltAlwaysFails: true})
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({
|
||||||
|
// a short verify budget: the failed melt is classified by the poll
|
||||||
|
// 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 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(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0)
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,11 +30,13 @@ export type NwcServiceDeps = {
|
|||||||
// the mint make_invoice issues invoices against (the wallet's default
|
// the mint make_invoice issues invoices against (the wallet's default
|
||||||
// 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
|
||||||
applyChangeset: (
|
applyChangeset: (
|
||||||
changeset: NwcChangeset,
|
changeset: NwcChangeset,
|
||||||
connection: NwcConnectionInfo,
|
connection: NwcConnectionInfo,
|
||||||
method: NwcMethod
|
method: NwcMethod,
|
||||||
) => void
|
assertOwner: () => void,
|
||||||
|
) => Promise<void>
|
||||||
transport?: NwcTransport
|
transport?: NwcTransport
|
||||||
// kit transport overrides (fetch injection, timeouts)
|
// kit transport overrides (fetch injection, timeouts)
|
||||||
kit?: LnurlcashOptions
|
kit?: LnurlcashOptions
|
||||||
@@ -73,4 +75,13 @@ export type RequestContext = {
|
|||||||
updateRecord: (record: NwcConnectionRecord) => void
|
updateRecord: (record: NwcConnectionRecord) => void
|
||||||
invoices: Map<string, PendingInvoice>
|
invoices: Map<string, PendingInvoice>
|
||||||
nowSeconds: () => number
|
nowSeconds: () => number
|
||||||
|
assertOwner: () => void
|
||||||
|
// Starts service-owned work only while the service accepts new work.
|
||||||
|
// Accepted tasks become part of stop's drain before key cleanup.
|
||||||
|
startBackground: (work: () => Promise<void>) => boolean
|
||||||
|
// fires when the service stops: long OBSERVATION waits (the invoice
|
||||||
|
// claim poll) must interrupt themselves on it. Work that has already
|
||||||
|
// reached a fund-critical commit must NOT consult it - stop awaits
|
||||||
|
// those tasks through its drain
|
||||||
|
stopSignal: AbortSignal
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-40
@@ -9,7 +9,7 @@ import {
|
|||||||
decodeBolt11AmountMsat,
|
decodeBolt11AmountMsat,
|
||||||
fetchInvoiceVerification,
|
fetchInvoiceVerification,
|
||||||
isBolt11Invoice,
|
isBolt11Invoice,
|
||||||
noteK1
|
noteK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
|
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
@@ -29,12 +29,9 @@ import {errResult, okResult} from './protocol'
|
|||||||
// rotate) is left for the next refresh to reconcile, exactly as the UI
|
// rotate) is left for the next refresh to reconcile, exactly as the UI
|
||||||
// leaves it - the money itself sits in the re-secured note, which IS
|
// leaves it - the money itself sits in the re-secured note, which IS
|
||||||
// tracked.
|
// tracked.
|
||||||
export const payChangeset = (
|
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
|
||||||
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)
|
if (result.carve.change) add.push(result.carve.change)
|
||||||
if (result.outcome === 'failed-funds-returned') {
|
if (result.outcome === 'failed-funds-returned') {
|
||||||
add.push(result.carve.note)
|
add.push(result.carve.note)
|
||||||
@@ -44,9 +41,7 @@ export const payChangeset = (
|
|||||||
// 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 carvedK1 = noteK1(result.carve.note.url)
|
||||||
const existing = carvedK1
|
const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined
|
||||||
? 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)
|
if (result.rescuedNote) add.push(result.rescuedNote)
|
||||||
@@ -55,10 +50,9 @@ export const payChangeset = (
|
|||||||
|
|
||||||
export const handlePayInvoice = async (
|
export const handlePayInvoice = async (
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>,
|
||||||
): Promise<NwcResponse> => {
|
): Promise<NwcResponse> => {
|
||||||
const invoice =
|
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : ''
|
||||||
typeof params.invoice === 'string' ? params.invoice.trim() : ''
|
|
||||||
if (!isBolt11Invoice(invoice)) {
|
if (!isBolt11Invoice(invoice)) {
|
||||||
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
|
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.')
|
||||||
}
|
}
|
||||||
@@ -69,23 +63,17 @@ export const handlePayInvoice = async (
|
|||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'OTHER',
|
'OTHER',
|
||||||
'Could not read this invoice\'s amount - amount-less invoices are not supported.'
|
"Could not read this invoice's amount - amount-less invoices are not supported.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (params.amount !== undefined && params.amount !== amountMsat) {
|
if (params.amount !== undefined && params.amount !== amountMsat) {
|
||||||
return errResult(
|
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.")
|
||||||
'pay_invoice',
|
|
||||||
'OTHER',
|
|
||||||
'The request\'s amount does not match the invoice.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (
|
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
|
||||||
amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())
|
|
||||||
) {
|
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'QUOTA_EXCEEDED',
|
'QUOTA_EXCEEDED',
|
||||||
'This payment exceeds the connection\'s budget.'
|
"This payment exceeds the connection's budget.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const bearers = ctx.deps.getBearers()
|
const bearers = ctx.deps.getBearers()
|
||||||
@@ -93,77 +81,100 @@ export const handlePayInvoice = async (
|
|||||||
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,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UncertainOutcomeError) {
|
if (err instanceof UncertainOutcomeError) {
|
||||||
// the carve's answer was lost and the probe couldn't tell: the
|
// the carve's answer was lost and the probe couldn't tell: the
|
||||||
// possible outputs carry fresh secrets that may be the only money
|
// possible outputs carry fresh secrets that may be the only money
|
||||||
// left - tracked unverified, never dropped
|
// left - tracked unverified, never dropped
|
||||||
ctx.deps.applyChangeset(
|
await ctx.deps.applyChangeset(
|
||||||
{add: err.possibleOutputs, markSpent: []},
|
{add: err.possibleOutputs, markSpent: []},
|
||||||
ctx.connection(),
|
ctx.connection(),
|
||||||
'pay_invoice'
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
)
|
)
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'INTERNAL',
|
'INTERNAL',
|
||||||
'The payment preparation could not be confirmed; possible new notes were stored unverified.'
|
'The payment preparation could not be confirmed; possible new notes were stored unverified.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
|
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
|
||||||
message
|
message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const spendRecorded = (): void => {
|
const spendRecorded = (): void => {
|
||||||
ctx.updateRecord(recordSpend(ctx.connection().record, amountMsat, Date.now()))
|
// This conservative budget debit is persisted separately from bearer
|
||||||
|
// storage. A later bearer commit failure does not roll it back.
|
||||||
|
const connection = ctx.connection().record
|
||||||
|
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()))
|
||||||
}
|
}
|
||||||
switch (result.outcome) {
|
switch (result.outcome) {
|
||||||
case 'settled': {
|
case 'settled': {
|
||||||
spendRecorded()
|
spendRecorded()
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(bearers, result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
)
|
||||||
// the receipt NIP-47 clients expect: the melt's own payment
|
// the receipt NIP-47 clients expect: the melt's own payment
|
||||||
// preimage, re-read from the settle proof. A mint that reveals
|
// preimage, re-read from the settle proof. A mint that reveals
|
||||||
// none yields an empty preimage rather than a fabricated one.
|
// none yields an empty preimage rather than a fabricated one.
|
||||||
let preimage = ''
|
let preimage = ''
|
||||||
if (result.verifyUrl) {
|
if (result.verifyUrl) {
|
||||||
try {
|
try {
|
||||||
const proof = await fetchInvoiceVerification(
|
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {})
|
||||||
result.verifyUrl,
|
|
||||||
ctx.deps.kit ?? {}
|
|
||||||
)
|
|
||||||
preimage = proof.preimage ?? ''
|
preimage = proof.preimage ?? ''
|
||||||
} catch {
|
} catch (error) {
|
||||||
// the settle proof was already polled inside payWithBearers;
|
// the settle proof was already polled inside payWithBearers;
|
||||||
// a failed re-read must not flip the outcome
|
// a failed re-read must not flip the outcome
|
||||||
|
if (!(error instanceof Error)) throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return okResult('pay_invoice', {preimage})
|
return okResult('pay_invoice', {preimage})
|
||||||
}
|
}
|
||||||
case 'failed-funds-returned':
|
case 'failed-funds-returned':
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(bearers, result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
)
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'PAYMENT_FAILED',
|
'PAYMENT_FAILED',
|
||||||
'The payment failed; the funds are back in the wallet.'
|
'The payment failed; the funds are back in the wallet.',
|
||||||
)
|
)
|
||||||
case 'note-already-spent':
|
case 'note-already-spent':
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(bearers, result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
)
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'PAYMENT_FAILED',
|
'PAYMENT_FAILED',
|
||||||
'The note backing this payment was already spent; nothing was paid.'
|
'The note backing this payment was already spent; nothing was paid.',
|
||||||
)
|
)
|
||||||
case 'unknown-still-pending':
|
case 'unknown-still-pending':
|
||||||
spendRecorded()
|
spendRecorded()
|
||||||
ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice')
|
await ctx.deps.applyChangeset(
|
||||||
|
payChangeset(bearers, result),
|
||||||
|
ctx.connection(),
|
||||||
|
'pay_invoice',
|
||||||
|
ctx.assertOwner,
|
||||||
|
)
|
||||||
return errResult(
|
return errResult(
|
||||||
'pay_invoice',
|
'pay_invoice',
|
||||||
'OTHER',
|
'OTHER',
|
||||||
'The payment is still in flight; the note stays locked until it reconciles.'
|
'The payment is still in flight; the note stays locked until it reconciles.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user