mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: fence carve operations before mutation
This commit is contained in:
@@ -0,0 +1,122 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||||
|
|
||||||
|
import type {Bearer} from './types'
|
||||||
|
import {UncertainOutcomeError, ensureExactAmount} from './ops'
|
||||||
|
import {requiredValue} from './test-utils'
|
||||||
|
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
|
||||||
|
|
||||||
|
describe('ensureExactAmount', () => {
|
||||||
|
it('returns an already-exact note untouched, burning nothing', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const k1 = secret('01')
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000)
|
||||||
|
const result = await ensureExactAmount([bearer], 21_000)
|
||||||
|
expect(noteK1(result.note.url)).toBe(k1)
|
||||||
|
expect(result.consumed).toEqual([])
|
||||||
|
expect(result.change).toBeUndefined()
|
||||||
|
expect(instance.state.noteState(k1)).toBe('outstanding')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('split path: carves an exact note off a larger one, with change', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const k1 = secret('02')
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000)
|
||||||
|
const result = await ensureExactAmount([bearer], 5_000)
|
||||||
|
expect(result.note.amount).toBe(5_000)
|
||||||
|
expect(result.note.verified).toBe(true)
|
||||||
|
expect(result.change?.amount).toBe(16_000)
|
||||||
|
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id])
|
||||||
|
expect(instance.state.noteState(k1)).toBe('burned')
|
||||||
|
const partK1 = requiredValue(noteK1(result.note.url))
|
||||||
|
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merge path: combines notes summing exactly to the target', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const first = await makeBearer(instance, secret('03'), 3_000)
|
||||||
|
const second = await makeBearer(instance, secret('04'), 4_000)
|
||||||
|
const result = await ensureExactAmount([first, second], 7_000)
|
||||||
|
expect(result.note.amount).toBe(7_000)
|
||||||
|
expect(result.change).toBeUndefined()
|
||||||
|
expect(result.consumed).toHaveLength(2)
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned')
|
||||||
|
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned')
|
||||||
|
const mergedK1 = requiredValue(noteK1(result.note.url))
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merge+split path: splits the target off several notes in one request', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const first = await makeBearer(instance, secret('05'), 3_000)
|
||||||
|
const second = await makeBearer(instance, secret('06'), 4_000)
|
||||||
|
const result = await ensureExactAmount([first, second], 5_000)
|
||||||
|
expect(result.note.amount).toBe(5_000)
|
||||||
|
expect(result.change?.amount).toBe(2_000)
|
||||||
|
expect(result.consumed).toHaveLength(2)
|
||||||
|
const partK1 = requiredValue(noteK1(result.note.url))
|
||||||
|
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes spent and unverified notes from selection', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const spentBearer = await makeBearer(instance, secret('07'), 50_000)
|
||||||
|
const unverified: Bearer = {
|
||||||
|
...(await makeBearer(instance, secret('08'), 50_000)),
|
||||||
|
callback: '',
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000),
|
||||||
|
).rejects.toThrow(/enough/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses an amount no mint can cover', async () => {
|
||||||
|
const instance = await mint()
|
||||||
|
const bearer = await makeBearer(instance, secret('09'), 5_000)
|
||||||
|
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => {
|
||||||
|
const instance = await mint({dropAfterMutation: true})
|
||||||
|
const k1 = secret('10')
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000)
|
||||||
|
const result = await ensureExactAmount([bearer], 5_000)
|
||||||
|
const partK1 = requiredValue(noteK1(result.note.url))
|
||||||
|
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
|
||||||
|
expect(partK1).not.toBe(k1)
|
||||||
|
expect(instance.state.noteState(k1)).toBe('burned')
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
|
||||||
|
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
|
||||||
|
const instance = await mint({dropAfterMutation: true})
|
||||||
|
const k1 = secret('11')
|
||||||
|
const bearer = await makeBearer(instance, k1, 21_000)
|
||||||
|
const probeKillingFetch: typeof fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||||
|
if (url.includes('/w/cb')) return fetch(input, init)
|
||||||
|
return Promise.reject(new Error('probe unreachable'))
|
||||||
|
}
|
||||||
|
const failure = await ensureExactAmount([bearer], 5_000, {
|
||||||
|
fetch: probeKillingFetch,
|
||||||
|
}).catch((error: unknown) => error)
|
||||||
|
expect(failure).toBeInstanceOf(UncertainOutcomeError)
|
||||||
|
if (!(failure instanceof UncertainOutcomeError)) throw failure
|
||||||
|
expect(failure.possibleOutputs).toHaveLength(2)
|
||||||
|
const first = requiredValue(failure.possibleOutputs[0])
|
||||||
|
const second = requiredValue(failure.possibleOutputs[1])
|
||||||
|
expect(first.amount).toBe(5_000)
|
||||||
|
expect(second.amount).toBe(16_000)
|
||||||
|
expect(
|
||||||
|
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable,
|
||||||
|
).toBe(5_000)
|
||||||
|
expect(
|
||||||
|
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
|
||||||
|
).toBe(16_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
+32
-47
@@ -11,11 +11,11 @@ import {
|
|||||||
serverOf,
|
serverOf,
|
||||||
settleNote,
|
settleNote,
|
||||||
splitNote,
|
splitNote,
|
||||||
withNewK1
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
import {UncertainOutcomeError} from './shared'
|
import type {FundOperationOptions} from './shared'
|
||||||
|
import {assertFundOwner, UncertainOutcomeError} from './shared'
|
||||||
|
|
||||||
// the changeset stores apply after a mutation: `note`/`change` BEFORE
|
// the changeset stores apply after a mutation: `note`/`change` BEFORE
|
||||||
// `consumed` - the mint call already burned every consumed input
|
// `consumed` - the mint call already burned every consumed input
|
||||||
@@ -49,13 +49,13 @@ export type CarveResult = {
|
|||||||
export const ensureExactAmount = async (
|
export const ensureExactAmount = async (
|
||||||
bearers: Bearer[],
|
bearers: Bearer[],
|
||||||
amountMsat: number,
|
amountMsat: number,
|
||||||
options: LnurlcashOptions = {}
|
options: FundOperationOptions = {},
|
||||||
): Promise<CarveResult> => {
|
): Promise<CarveResult> => {
|
||||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||||
throw new Error('Amount must be a positive whole number of msat.')
|
throw new Error('Amount must be a positive whole number of msat.')
|
||||||
}
|
}
|
||||||
const eligible = bearers.filter(
|
const eligible = bearers.filter(
|
||||||
b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url)
|
(b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
|
||||||
)
|
)
|
||||||
// per-server greedy pick: smallest notes first until the target is
|
// per-server greedy pick: smallest notes first until the target is
|
||||||
// covered (an exact single-note match short-circuits - no mutation at
|
// covered (an exact single-note match short-circuits - no mutation at
|
||||||
@@ -68,19 +68,17 @@ export const ensureExactAmount = async (
|
|||||||
let pick: Bearer[] | null = null
|
let pick: Bearer[] | null = null
|
||||||
for (const group of byServer.values()) {
|
for (const group of byServer.values()) {
|
||||||
const sorted = [...group].sort((a, b) => a.amount - b.amount)
|
const sorted = [...group].sort((a, b) => a.amount - b.amount)
|
||||||
const exact = sorted.find(b => b.amount === amountMsat)
|
const exact = sorted.find((b) => b.amount === amountMsat)
|
||||||
const candidate = exact ? [exact] : accumulate(sorted, amountMsat)
|
const candidate = exact ? [exact] : accumulate(sorted, amountMsat)
|
||||||
if (!candidate) continue
|
if (!candidate) continue
|
||||||
if (!pick || better(candidate, pick, amountMsat)) pick = candidate
|
if (!pick || better(candidate, pick, amountMsat)) pick = candidate
|
||||||
}
|
}
|
||||||
if (!pick) {
|
if (!pick) {
|
||||||
throw new Error(
|
throw new Error('No mint holds enough verified, unspent balance to cover that amount.')
|
||||||
'No mint holds enough verified, unspent balance to cover that amount.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const base = pick[0]
|
const base = pick[0]
|
||||||
const total = pick.reduce((sum, b) => sum + b.amount, 0)
|
const total = pick.reduce((sum, b) => sum + b.amount, 0)
|
||||||
const k1s = pick.map(b => requireNoteK1(b.url))
|
const k1s = pick.map((b) => requireNoteK1(b.url))
|
||||||
|
|
||||||
if (pick.length === 1 && total === amountMsat) {
|
if (pick.length === 1 && total === amountMsat) {
|
||||||
// already exact - hand over the note itself, untouched
|
// already exact - hand over the note itself, untouched
|
||||||
@@ -90,9 +88,9 @@ export const ensureExactAmount = async (
|
|||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
amount: base.amount,
|
amount: base.amount,
|
||||||
verified: base.verified,
|
verified: base.verified,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
},
|
},
|
||||||
consumed: []
|
consumed: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,13 +98,14 @@ export const ensureExactAmount = async (
|
|||||||
// merge path: many notes, exact sum - merge into one, then settle it
|
// merge path: many notes, exact sum - merge into one, then settle it
|
||||||
// (true value + fresh secret; a failed settle leaves an unverified
|
// (true value + fresh secret; a failed settle leaves an unverified
|
||||||
// note a refresh can repair, not a lost secret)
|
// note a refresh can repair, not a lost secret)
|
||||||
|
assertFundOwner(options)
|
||||||
const merged = await mergeAmbiguitySafe(base, k1s, total, options)
|
const merged = await mergeAmbiguitySafe(base, k1s, total, options)
|
||||||
const unverified: NewBearer = {
|
const unverified: NewBearer = {
|
||||||
url: withNewK1(base.url, merged.k1, total, merged.signature),
|
url: withNewK1(base.url, merged.k1, total, merged.signature),
|
||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
amount: total,
|
amount: total,
|
||||||
verified: false,
|
verified: false,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
}
|
}
|
||||||
// a merge whose answer was lost leaves the service in an unknown
|
// a merge whose answer was lost leaves the service in an unknown
|
||||||
// state from here - settling fires another mutation (the rotate
|
// state from here - settling fires another mutation (the rotate
|
||||||
@@ -115,27 +114,16 @@ export const ensureExactAmount = async (
|
|||||||
// refresh repair.
|
// refresh repair.
|
||||||
if (merged.rescued) return {note: unverified, consumed: pick}
|
if (merged.rescued) return {note: unverified, consumed: pick}
|
||||||
try {
|
try {
|
||||||
const settled = await settleNote(
|
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
|
||||||
base.url,
|
|
||||||
merged.k1,
|
|
||||||
total,
|
|
||||||
merged.signature,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
note: {
|
note: {
|
||||||
url: withNewK1(
|
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
|
||||||
base.url,
|
|
||||||
settled.k1,
|
|
||||||
settled.amountMsat,
|
|
||||||
settled.signature
|
|
||||||
),
|
|
||||||
callback: settled.callback,
|
callback: settled.callback,
|
||||||
amount: settled.amountMsat,
|
amount: settled.amountMsat,
|
||||||
verified: true,
|
verified: true,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
},
|
},
|
||||||
consumed: pick
|
consumed: pick,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {note: unverified, consumed: pick}
|
return {note: unverified, consumed: pick}
|
||||||
@@ -154,6 +142,7 @@ export const ensureExactAmount = async (
|
|||||||
// state, so the change is NOT settled (that would fire another mutation
|
// state, so the change is NOT settled (that would fire another mutation
|
||||||
// at it, whose own ambiguous failure would strand the rescued secret)
|
// at it, whose own ambiguous failure would strand the rescued secret)
|
||||||
let rescued = false
|
let rescued = false
|
||||||
|
assertFundOwner(options)
|
||||||
try {
|
try {
|
||||||
const parts = await splitNote(base.callback, k1s, amountMsat, options)
|
const parts = await splitNote(base.callback, k1s, amountMsat, options)
|
||||||
partK1 = parts.k1
|
partK1 = parts.k1
|
||||||
@@ -178,16 +167,16 @@ export const ensureExactAmount = async (
|
|||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
amount: amountMsat,
|
amount: amountMsat,
|
||||||
verified: false,
|
verified: false,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
|
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
|
||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
amount: total - amountMsat,
|
amount: total - amountMsat,
|
||||||
verified: false,
|
verified: false,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 'gone': the burn landed - the carried secrets are the only money
|
// 'gone': the burn landed - the carried secrets are the only money
|
||||||
@@ -200,7 +189,7 @@ export const ensureExactAmount = async (
|
|||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
amount: amountMsat,
|
amount: amountMsat,
|
||||||
verified: partVerified,
|
verified: partVerified,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
}
|
}
|
||||||
// settleNote: the change may be worth less than total - amount if this
|
// settleNote: the change may be worth less than total - amount if this
|
||||||
// mint charges split fees (LUD-25 deducts them from change, never the
|
// mint charges split fees (LUD-25 deducts them from change, never the
|
||||||
@@ -211,7 +200,7 @@ export const ensureExactAmount = async (
|
|||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
amount: total - amountMsat,
|
amount: total - amountMsat,
|
||||||
verified: false,
|
verified: false,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
}
|
}
|
||||||
if (!rescued) {
|
if (!rescued) {
|
||||||
try {
|
try {
|
||||||
@@ -220,22 +209,18 @@ export const ensureExactAmount = async (
|
|||||||
changeK1,
|
changeK1,
|
||||||
total - amountMsat,
|
total - amountMsat,
|
||||||
changeSignature,
|
changeSignature,
|
||||||
options
|
options,
|
||||||
)
|
)
|
||||||
change = {
|
change = {
|
||||||
url: withNewK1(
|
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
|
||||||
base.url,
|
|
||||||
settled.k1,
|
|
||||||
settled.amountMsat,
|
|
||||||
settled.signature
|
|
||||||
),
|
|
||||||
callback: settled.callback,
|
callback: settled.callback,
|
||||||
amount: settled.amountMsat,
|
amount: settled.amountMsat,
|
||||||
verified: true,
|
verified: true,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// settle is best-effort - the unverified change above is still tracked
|
// settle is best-effort - the unverified change above is still tracked
|
||||||
|
if (!(error instanceof Error)) throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {note, change, consumed: pick}
|
return {note, change, consumed: pick}
|
||||||
@@ -270,7 +255,7 @@ const mergeAmbiguitySafe = async (
|
|||||||
base: Bearer,
|
base: Bearer,
|
||||||
k1s: string[],
|
k1s: string[],
|
||||||
total: number,
|
total: number,
|
||||||
options: LnurlcashOptions
|
options: FundOperationOptions,
|
||||||
): Promise<{k1: string; signature?: string; rescued: boolean}> => {
|
): Promise<{k1: string; signature?: string; rescued: boolean}> => {
|
||||||
try {
|
try {
|
||||||
const merged = await mergeNotes(base.callback, k1s, options)
|
const merged = await mergeNotes(base.callback, k1s, options)
|
||||||
@@ -288,9 +273,9 @@ const mergeAmbiguitySafe = async (
|
|||||||
callback: base.callback,
|
callback: base.callback,
|
||||||
amount: total,
|
amount: total,
|
||||||
verified: false,
|
verified: false,
|
||||||
mintPubkey: base.mintPubkey
|
mintPubkey: base.mintPubkey,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 'gone': the burn landed - the carried secret is the only money left
|
// 'gone': the burn landed - the carried secret is the only money left
|
||||||
|
|||||||
Reference in New Issue
Block a user