mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: fence invoice payment operations
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {noteK1} from 'lnurlcash-kit'
|
||||||
|
|
||||||
|
import {payWithBearers} from './ops'
|
||||||
|
import {requiredValue} from './test-utils'
|
||||||
|
import {makeBearer, mint, secret} from './ops.testHarness'
|
||||||
|
|
||||||
|
describe('payWithBearers', () => {
|
||||||
|
it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const bearer = await makeBearer(instance, secret('30'), 21_000)
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
|
||||||
|
})
|
||||||
|
expect(result.outcome).toBe('settled')
|
||||||
|
expect(instance.state.noteState(secret('30'))).toBe('burned')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pays a Lightning Address by requesting an invoice first', async () => {
|
||||||
|
const payer = await mint()
|
||||||
|
const payee = await mint()
|
||||||
|
const bearer = await makeBearer(payer, secret('31'), 21_000)
|
||||||
|
const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, {
|
||||||
|
amountMsat: 21_000,
|
||||||
|
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
|
||||||
|
})
|
||||||
|
expect(result.outcome).toBe('settled')
|
||||||
|
expect(result.invoice).toMatch(/^lnbc/)
|
||||||
|
expect(payer.state.noteState(secret('31'))).toBe('burned')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carves the exact amount out of a larger note before melting', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const bearer = await makeBearer(instance, secret('32'), 50_000)
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000},
|
||||||
|
})
|
||||||
|
expect(result.outcome).toBe('settled')
|
||||||
|
expect(instance.state.noteState(secret('32'))).toBe('burned')
|
||||||
|
expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id])
|
||||||
|
expect(result.carve.change?.amount).toBe(29_000)
|
||||||
|
const change = requiredValue(result.carve.change)
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
|
||||||
|
const instance = await mint({meltAlwaysFails: true})
|
||||||
|
const bearer = await makeBearer(instance, secret('33'), 21_000)
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
|
||||||
|
})
|
||||||
|
expect(result.outcome).toBe('failed-funds-returned')
|
||||||
|
expect(instance.state.noteState(secret('33'))).toBe('burned')
|
||||||
|
const returnedK1 = requiredValue(noteK1(result.carve.note.url))
|
||||||
|
expect(instance.state.noteState(returnedK1)).toBe('outstanding')
|
||||||
|
expect(result.carve.note.amount).toBe(21_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies a never-settling melt as unknown-still-pending', async () => {
|
||||||
|
const instance = await mint({meltNeverSettles: true})
|
||||||
|
const bearer = await makeBearer(instance, secret('34'), 21_000)
|
||||||
|
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
|
||||||
|
poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300},
|
||||||
|
})
|
||||||
|
expect(result.outcome).toBe('unknown-still-pending')
|
||||||
|
expect(instance.state.noteState(secret('34'))).toBe('pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an amountless or unreadable invoice instead of guessing', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const bearer = await makeBearer(instance, secret('35'), 21_000)
|
||||||
|
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/)
|
||||||
|
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
+15
-16
@@ -16,14 +16,15 @@ import {
|
|||||||
resolveLnurlInput,
|
resolveLnurlInput,
|
||||||
rotateNote,
|
rotateNote,
|
||||||
sameInvoice,
|
sameInvoice,
|
||||||
withNewK1
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit'
|
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
import type {CarveResult} from './carve'
|
import type {CarveResult} from './carve'
|
||||||
import {ensureExactAmount} from './carve'
|
import {ensureExactAmount} from './carve'
|
||||||
import type {PollOptions} from './shared'
|
import type {PollOptions} from './shared'
|
||||||
import {pollVerifyUntilSettled} from './shared'
|
import type {FundOperationOptions} from './shared'
|
||||||
|
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
|
||||||
|
|
||||||
export type PayOutcome =
|
export type PayOutcome =
|
||||||
| 'settled'
|
| 'settled'
|
||||||
@@ -54,6 +55,7 @@ export type PayOptions = {
|
|||||||
poll?: PollOptions
|
poll?: PollOptions
|
||||||
// kit transport overrides (fetch injection, timeouts)
|
// kit transport overrides (fetch injection, timeouts)
|
||||||
kit?: LnurlcashOptions
|
kit?: LnurlcashOptions
|
||||||
|
assertOwner?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// A melt's resolved promise only means the payment is in flight; the
|
// A melt's resolved promise only means the payment is in flight; the
|
||||||
@@ -69,9 +71,9 @@ export type PayOptions = {
|
|||||||
export const payWithBearers = async (
|
export const payWithBearers = async (
|
||||||
bearers: Bearer[],
|
bearers: Bearer[],
|
||||||
input: string,
|
input: string,
|
||||||
{amountMsat, poll = {}, kit = {}}: PayOptions = {}
|
{amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {},
|
||||||
): Promise<PayResult> => {
|
): Promise<PayResult> => {
|
||||||
const options = kit
|
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
|
||||||
let invoice: string
|
let invoice: string
|
||||||
let amount: number
|
let amount: number
|
||||||
const trimmed = input.trim()
|
const trimmed = input.trim()
|
||||||
@@ -79,7 +81,7 @@ export const payWithBearers = async (
|
|||||||
const decoded = decodeBolt11AmountMsat(trimmed)
|
const decoded = decodeBolt11AmountMsat(trimmed)
|
||||||
if (decoded === null || decoded <= 0) {
|
if (decoded === null || decoded <= 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'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.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
invoice = trimmed
|
invoice = trimmed
|
||||||
@@ -97,7 +99,7 @@ export const payWithBearers = async (
|
|||||||
}
|
}
|
||||||
const info = await fetchPayRequest(url, options)
|
const info = await fetchPayRequest(url, options)
|
||||||
if (amountMsat < info.minSendable || amountMsat > info.maxSendable) {
|
if (amountMsat < info.minSendable || amountMsat > info.maxSendable) {
|
||||||
throw new Error('Amount is outside the payee\'s sendable range.')
|
throw new Error("Amount is outside the payee's sendable range.")
|
||||||
}
|
}
|
||||||
const result = await requestInvoice(info.callback, amountMsat, options)
|
const result = await requestInvoice(info.callback, amountMsat, options)
|
||||||
invoice = result.pr
|
invoice = result.pr
|
||||||
@@ -106,6 +108,7 @@ export const payWithBearers = async (
|
|||||||
|
|
||||||
const carve = await ensureExactAmount(bearers, amount, options)
|
const carve = await ensureExactAmount(bearers, amount, options)
|
||||||
const k1 = requireNoteK1(carve.note.url)
|
const k1 = requireNoteK1(carve.note.url)
|
||||||
|
if (carve.consumed.length === 0) assertFundOwner(options)
|
||||||
let melt: MeltResult
|
let melt: MeltResult
|
||||||
try {
|
try {
|
||||||
melt = await meltNote(carve.note.callback, k1, invoice, options)
|
melt = await meltNote(carve.note.callback, k1, invoice, options)
|
||||||
@@ -136,11 +139,7 @@ export const payWithBearers = async (
|
|||||||
// either way (a service regenerating synthetic prs in proofs) and is
|
// either way (a service regenerating synthetic prs in proofs) and is
|
||||||
// tolerated.
|
// tolerated.
|
||||||
const proofAmount = decodeBolt11AmountMsat(proof.pr)
|
const proofAmount = decodeBolt11AmountMsat(proof.pr)
|
||||||
if (
|
if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) {
|
||||||
!sameInvoice(proof.pr, invoice) &&
|
|
||||||
proofAmount !== null &&
|
|
||||||
proofAmount !== amount
|
|
||||||
) {
|
|
||||||
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
|
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
|
||||||
}
|
}
|
||||||
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
|
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl}
|
||||||
@@ -158,12 +157,12 @@ export const payWithBearers = async (
|
|||||||
...carve,
|
...carve,
|
||||||
note: {
|
note: {
|
||||||
...carve.note,
|
...carve.note,
|
||||||
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature)
|
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
invoice,
|
invoice,
|
||||||
amountMsat: amount,
|
amountMsat: amount,
|
||||||
verifyUrl
|
verifyUrl,
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof PendingNoteError) {
|
if (err instanceof PendingNoteError) {
|
||||||
@@ -183,7 +182,7 @@ export const payWithBearers = async (
|
|||||||
url: withNewK1(carve.note.url, err.newSecrets[0], amount),
|
url: withNewK1(carve.note.url, err.newSecrets[0], amount),
|
||||||
callback: carve.note.callback,
|
callback: carve.note.callback,
|
||||||
amount,
|
amount,
|
||||||
verified: false
|
verified: false,
|
||||||
}
|
}
|
||||||
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
|
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
|
||||||
return {
|
return {
|
||||||
@@ -192,7 +191,7 @@ export const payWithBearers = async (
|
|||||||
invoice,
|
invoice,
|
||||||
amountMsat: amount,
|
amountMsat: amount,
|
||||||
verifyUrl,
|
verifyUrl,
|
||||||
rescuedNote
|
rescuedNote,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
|
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}
|
||||||
|
|||||||
Reference in New Issue
Block a user