mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(lnurlcash): rescue landed mutations from retry refusals
A split, merge or rotate whose answer is 'Invalid or already spent k1.' may be the refusal of a retried request that already LANDED - believing it strands the fresh outputs server-side and desyncs the wallet (the input is burned at the mint but still spendable locally, so every later operation fails). All three flows now probe a would-be output when a refusal carries fresh secrets: live outputs are adopted (or surfaced unverified when the probe can't tell), a genuine refusal still fails.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {noteK1} from 'lnurlcash-kit'
|
||||
|
||||
import {ensureExactAmount, receiveBearer} from './ops'
|
||||
import {claimFromPreimage, prepareMint} from './ops/mint'
|
||||
import {requiredValue} from './test-utils'
|
||||
import {
|
||||
makeBearer,
|
||||
mint,
|
||||
noteUrl,
|
||||
retryingCbFetch,
|
||||
secret,
|
||||
settleLastInvoice,
|
||||
} from './ops.testHarness'
|
||||
|
||||
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
|
||||
// can execute the first attempt of a mutation and refuse the byte-identical
|
||||
// repeat as an already-spent input, which is the only answer the wallet
|
||||
// ever sees. Every mutation that carries fresh secrets must probe the
|
||||
// would-be outputs before believing such a refusal: landed means the
|
||||
// carried secrets are the only money left.
|
||||
describe('mutation retry refusals', () => {
|
||||
it('rescues the outputs when the split answer is a retry refusal', async () => {
|
||||
const instance = await mint()
|
||||
const k1 = secret('60')
|
||||
const bearer = await makeBearer(instance, k1, 50_000)
|
||||
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()})
|
||||
// the split landed on the first (unseen) attempt: the input is burned
|
||||
// and both outputs are live under the secrets the wallet carried
|
||||
expect(instance.state.noteState(k1)).toBe('burned')
|
||||
expect(result.consumed).toHaveLength(1)
|
||||
const partK1 = requiredValue(noteK1(result.note.url))
|
||||
expect(partK1).not.toBe(k1)
|
||||
expect(result.note.amount).toBe(25_000)
|
||||
expect(instance.state.noteState(partK1)).toBe('outstanding')
|
||||
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
|
||||
expect(instance.state.noteState(changeK1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('rescues the combined note when the merge answer is a retry refusal', async () => {
|
||||
const instance = await mint()
|
||||
const a = await makeBearer(instance, secret('61'), 20_000)
|
||||
const b = await makeBearer(instance, secret('62'), 5_000)
|
||||
const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()})
|
||||
expect(result.consumed).toHaveLength(2)
|
||||
expect(instance.state.noteState(secret('61'))).toBe('burned')
|
||||
expect(instance.state.noteState(secret('62'))).toBe('burned')
|
||||
const mergedK1 = requiredValue(noteK1(result.note.url))
|
||||
expect(result.note.amount).toBe(25_000)
|
||||
expect(instance.state.noteState(mergedK1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
|
||||
const instance = await mint({testHooks: true})
|
||||
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
|
||||
const preimage = await settleLastInvoice(instance)
|
||||
const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
|
||||
expect(claimed.rotated).toBe(true)
|
||||
expect(claimed.note.verified).toBe(true)
|
||||
expect(instance.state.noteState(preimage)).toBe('burned')
|
||||
const k1 = requiredValue(noteK1(claimed.note.url))
|
||||
expect(k1).not.toBe(preimage)
|
||||
expect(instance.state.noteState(k1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
|
||||
const instance = await mint()
|
||||
const senderK1 = secret('63')
|
||||
instance.state.creditNote(senderK1, 21_000)
|
||||
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
|
||||
fetch: retryingCbFetch(),
|
||||
})
|
||||
expect(received.rotated).toBe(true)
|
||||
expect(received.note.amount).toBe(21_000)
|
||||
expect(instance.state.noteState(senderK1)).toBe('burned')
|
||||
const k1 = requiredValue(noteK1(received.note.url))
|
||||
expect(k1).not.toBe(senderK1)
|
||||
expect(instance.state.noteState(k1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('still fails a genuine refusal - a note spent before the split', async () => {
|
||||
const instance = await mint()
|
||||
const k1 = secret('64')
|
||||
const bearer = await makeBearer(instance, k1, 50_000)
|
||||
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
|
||||
await expect(
|
||||
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
|
||||
).rejects.toThrow(/spent/i)
|
||||
})
|
||||
})
|
||||
@@ -2,4 +2,5 @@ import './ops.carve.cases'
|
||||
import './ops.mint-named.cases'
|
||||
import './ops.mint-receive.cases'
|
||||
import './ops.pay.cases'
|
||||
import './ops.retry-refusal.cases'
|
||||
import './ops.transfer.cases'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {
|
||||
AmbiguousMutationError,
|
||||
mergeNotes,
|
||||
newSecretsOf,
|
||||
noteK1,
|
||||
probeBurnedNote,
|
||||
requireNoteK1,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import type {FundOperationOptions} from './shared'
|
||||
import {assertFundOwner, UncertainOutcomeError} from './shared'
|
||||
import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared'
|
||||
|
||||
// the changeset stores apply after a mutation: `note`/`change` BEFORE
|
||||
// `consumed` - the mint call already burned every consumed input
|
||||
@@ -151,7 +152,7 @@ export const ensureExactAmount = async (
|
||||
changeSignature = parts.changeSignature
|
||||
partVerified = true
|
||||
} catch (err) {
|
||||
if (!(err instanceof AmbiguousMutationError)) throw err
|
||||
if (err instanceof AmbiguousMutationError) {
|
||||
// the split request may have landed despite the failure - probe one
|
||||
// input before deciding what the carried secrets are worth
|
||||
const outcome = await probeBurnedNote(base.url, options)
|
||||
@@ -183,6 +184,46 @@ export const ensureExactAmount = async (
|
||||
partK1 = err.newSecrets[0]
|
||||
changeK1 = err.newSecrets[1]
|
||||
rescued = true
|
||||
} else {
|
||||
// A classified refusal can still be a LANDED split: the callback is
|
||||
// a GET and HTTP stacks retry GETs, so the service may have executed
|
||||
// the first attempt and refused this one as an already-spent input.
|
||||
// The kit attaches the fresh output secrets to every refusal - probe
|
||||
// one output before deciding they are worthless.
|
||||
const carried = newSecretsOf(err)
|
||||
if (carried.length !== 2) throw err
|
||||
const outcome = await probeMutationOutput(base.url, carried[0], options)
|
||||
if (outcome === 'absent') throw err // never landed - a plain refusal
|
||||
if (outcome === 'unknown') {
|
||||
// can't tell whether the refusal named a retry - same limbo as
|
||||
// the ambiguous case above: track the possible outputs, consume
|
||||
// nothing, stop here
|
||||
throw new UncertainOutcomeError(
|
||||
'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
|
||||
[
|
||||
{
|
||||
url: withNewK1(base.url, carried[0], amountMsat),
|
||||
callback: base.callback,
|
||||
amount: amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
{
|
||||
url: withNewK1(base.url, carried[1], total - amountMsat),
|
||||
callback: base.callback,
|
||||
amount: total - amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
// 'live': the split landed and this answer was its retried twin -
|
||||
// the carried secrets are the only money left
|
||||
partK1 = carried[0]
|
||||
changeK1 = carried[1]
|
||||
rescued = true
|
||||
}
|
||||
}
|
||||
const note: NewBearer = {
|
||||
url: withNewK1(base.url, partK1, amountMsat, partSignature),
|
||||
@@ -261,7 +302,7 @@ const mergeAmbiguitySafe = async (
|
||||
const merged = await mergeNotes(base.callback, k1s, options)
|
||||
return {k1: merged.k1, signature: merged.signature, rescued: false}
|
||||
} catch (err) {
|
||||
if (!(err instanceof AmbiguousMutationError)) throw err
|
||||
if (err instanceof AmbiguousMutationError) {
|
||||
const outcome = await probeBurnedNote(base.url, options)
|
||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||
if (outcome === 'unknown') {
|
||||
@@ -281,4 +322,26 @@ const mergeAmbiguitySafe = async (
|
||||
// 'gone': the burn landed - the carried secret is the only money left
|
||||
return {k1: err.newSecrets[0], rescued: true}
|
||||
}
|
||||
// same retry-refusal rescue as the split path: the refusal may name a
|
||||
// merge that already landed - probe the would-be combined note
|
||||
const carried = newSecretsOf(err)
|
||||
if (carried.length !== 1) throw err
|
||||
const outcome = await probeMutationOutput(base.url, carried[0], options)
|
||||
if (outcome === 'absent') throw err
|
||||
if (outcome === 'unknown') {
|
||||
throw new UncertainOutcomeError(
|
||||
'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
|
||||
[
|
||||
{
|
||||
url: withNewK1(base.url, carried[0], total),
|
||||
callback: base.callback,
|
||||
amount: total,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
return {k1: carried[0], rescued: true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
lightningAddressUsername,
|
||||
mintAddressUrl,
|
||||
namesMintOutput,
|
||||
newSecretsOf,
|
||||
NoteSpentError,
|
||||
probeBurnedNote,
|
||||
requestInvoice,
|
||||
resolveMintInput,
|
||||
@@ -47,7 +49,7 @@ import type {NewBearer} from '../types'
|
||||
import {ceilMsatToSat} from '../units'
|
||||
import type {PollOptions} from './shared'
|
||||
import type {FundOperationOptions} from './shared'
|
||||
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled} from './shared'
|
||||
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared'
|
||||
|
||||
export type PreparedMint = {
|
||||
invoice: string
|
||||
@@ -360,9 +362,43 @@ export const claimFromPreimage = async (
|
||||
} else {
|
||||
rotationError = err.message
|
||||
}
|
||||
} else {
|
||||
// A classified refusal can still be a LANDED rotate: the callback is
|
||||
// a GET and HTTP stacks retry GETs, so the service may have executed
|
||||
// the first attempt and refused this one as an already-spent input.
|
||||
// "Already spent" then PROVES the preimage note is burned - the
|
||||
// carried fresh secret is the only money left, so probe it before
|
||||
// believing the refusal.
|
||||
const carried = newSecretsOf(err)
|
||||
if (carried.length === 1) {
|
||||
const outcome = await probeMutationOutput(declaredUrl, carried[0], options)
|
||||
if (outcome === 'live') {
|
||||
// the rotate landed and this answer was its retried twin
|
||||
url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable)
|
||||
rotated = true
|
||||
} else if (outcome === 'unknown') {
|
||||
possibleCopy = {
|
||||
url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable),
|
||||
callback: noteInfo.callback,
|
||||
amount: noteInfo.maxWithdrawable,
|
||||
verified: false,
|
||||
}
|
||||
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
|
||||
// the preimage note itself may be burned - it cannot stay verified
|
||||
base.verified = false
|
||||
rotationError = `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`
|
||||
} else if (err instanceof NoteSpentError) {
|
||||
// a genuine already-spent refusal with no live output: the note
|
||||
// the service just described is burned - it cannot stay verified
|
||||
base.verified = false
|
||||
rotationError = err.message
|
||||
} else {
|
||||
rotationError = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
} else {
|
||||
rotationError = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
const claimed: ClaimedNote = {note: {...base, url}, rotated}
|
||||
if (possibleCopy) claimed.possibleCopy = possibleCopy
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
NoteSpentError,
|
||||
NoteUnknownError,
|
||||
PendingNoteError,
|
||||
newSecretsOf,
|
||||
probeBurnedNote,
|
||||
withNewK1,
|
||||
} from 'lnurlcash-kit'
|
||||
@@ -16,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
|
||||
import {receiveNote, secureReceivedNote} from '../receive'
|
||||
import type {ClaimedNote} from './mint'
|
||||
import type {FundOperationOptions} from './shared'
|
||||
import {assertFundOwner} from './shared'
|
||||
import {assertFundOwner, probeMutationOutput} from './shared'
|
||||
|
||||
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
|
||||
// definitive and propagate; an unreachable service still yields the note,
|
||||
@@ -32,9 +33,43 @@ export const receiveBearer = async (
|
||||
}
|
||||
try {
|
||||
assertFundOwner(options)
|
||||
const rotatedUrl = await secureReceivedNote(note)
|
||||
const rotatedUrl = await secureReceivedNote(note, options)
|
||||
return {note: {...note, url: rotatedUrl}, rotated: true}
|
||||
} catch (err) {
|
||||
// A classified rotate refusal can still be a LANDED rotate: the
|
||||
// callback is a GET and HTTP stacks retry GETs, so the service may
|
||||
// have executed the first attempt and refused this one as an
|
||||
// already-spent input. The refusal then proves the sender's copy is
|
||||
// burned and the carried fresh secret is the only money left - probe
|
||||
// it before believing the refusal.
|
||||
const carried = newSecretsOf(err)
|
||||
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
|
||||
const outcome = await probeMutationOutput(note.url, carried[0], options)
|
||||
if (outcome === 'live') {
|
||||
return {
|
||||
note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
|
||||
rotated: true,
|
||||
}
|
||||
}
|
||||
if (outcome === 'unknown') {
|
||||
const possibleCopy: NewBearer = {
|
||||
url: withNewK1(note.url, carried[0], note.amount),
|
||||
callback: note.callback,
|
||||
amount: note.amount,
|
||||
verified: false,
|
||||
}
|
||||
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
|
||||
return {
|
||||
// the sender's copy may be burned - it cannot stay verified
|
||||
note: {...note, verified: false},
|
||||
rotated: false,
|
||||
possibleCopy,
|
||||
rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`,
|
||||
}
|
||||
}
|
||||
// 'absent': the refusal is genuine - fall through to the distinct
|
||||
// handling below
|
||||
}
|
||||
// a definitive service state (dead/unknown/locked mid-melt) is not a
|
||||
// rotation failure to warn about - it tells the holder what this note
|
||||
// actually is, so it propagates distinctly
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
NoteUnknownError,
|
||||
PendingNoteError,
|
||||
} from 'lnurlcash-kit'
|
||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from './types'
|
||||
|
||||
// shared by Scan and Paste: resolve whatever came in to a note URL, ask the
|
||||
@@ -65,16 +66,21 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise<Ne
|
||||
// k1 on the wire) still knows the old secret - a rotate burns it and mints
|
||||
// a fresh one only this wallet knows. Returns the updated note URL. Throws
|
||||
// when the service refuses (e.g. a plain LUD-03 withdraw link that doesn't
|
||||
// speak lnurlcash) - the caller should warn, not fail the receive.
|
||||
export const secureReceivedNote = async (note: {
|
||||
// speak lnurlcash) - the caller should warn, not fail the receive. The
|
||||
// error carries the fresh secret (kit's newSecretsOf) so the caller can
|
||||
// rescue a rotate that landed despite its refusal.
|
||||
export const secureReceivedNote = async (
|
||||
note: {
|
||||
url: string
|
||||
callback: string
|
||||
amount: number
|
||||
}): Promise<string> => {
|
||||
},
|
||||
options: LnurlcashOptions = {},
|
||||
): Promise<string> => {
|
||||
const k1 = noteK1(note.url)
|
||||
if (!k1 || !note.callback) {
|
||||
throw new Error('Note has no callback to rotate against yet.')
|
||||
}
|
||||
const result = await rotateNote(note.callback, k1)
|
||||
const result = await rotateNote(note.callback, k1, options)
|
||||
return withNewK1(note.url, result.k1, note.amount, result.signature)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user