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:
+102
-39
@@ -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,38 +152,78 @@ export const ensureExactAmount = async (
|
||||
changeSignature = parts.changeSignature
|
||||
partVerified = true
|
||||
} catch (err) {
|
||||
if (!(err instanceof AmbiguousMutationError)) throw err
|
||||
// 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)
|
||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||
if (outcome === 'unknown') {
|
||||
// can't tell: surface both possible outputs unverified WITHOUT
|
||||
// consuming the inputs, and stop here rather than spend from limbo
|
||||
throw new UncertainOutcomeError(
|
||||
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
|
||||
[
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
|
||||
callback: base.callback,
|
||||
amount: amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
|
||||
callback: base.callback,
|
||||
amount: total - amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
],
|
||||
)
|
||||
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)
|
||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||
if (outcome === 'unknown') {
|
||||
// can't tell: surface both possible outputs unverified WITHOUT
|
||||
// consuming the inputs, and stop here rather than spend from limbo
|
||||
throw new UncertainOutcomeError(
|
||||
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
|
||||
[
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
|
||||
callback: base.callback,
|
||||
amount: amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
|
||||
callback: base.callback,
|
||||
amount: total - amountMsat,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
// 'gone': the burn landed - the carried secrets are the only money
|
||||
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
|
||||
}
|
||||
// 'gone': the burn landed - the carried secrets are the only money
|
||||
partK1 = err.newSecrets[0]
|
||||
changeK1 = err.newSecrets[1]
|
||||
rescued = true
|
||||
}
|
||||
const note: NewBearer = {
|
||||
url: withNewK1(base.url, partK1, amountMsat, partSignature),
|
||||
@@ -261,15 +302,38 @@ 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
|
||||
const outcome = await probeBurnedNote(base.url, options)
|
||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||
if (err instanceof AmbiguousMutationError) {
|
||||
const outcome = await probeBurnedNote(base.url, options)
|
||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||
if (outcome === 'unknown') {
|
||||
throw new UncertainOutcomeError(
|
||||
'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
|
||||
[
|
||||
{
|
||||
url: withNewK1(base.url, err.newSecrets[0], total),
|
||||
callback: base.callback,
|
||||
amount: total,
|
||||
verified: false,
|
||||
mintPubkey: base.mintPubkey,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
// '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 may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
|
||||
'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, err.newSecrets[0], total),
|
||||
url: withNewK1(base.url, carried[0], total),
|
||||
callback: base.callback,
|
||||
amount: total,
|
||||
verified: false,
|
||||
@@ -278,7 +342,6 @@ const mergeAmbiguitySafe = async (
|
||||
],
|
||||
)
|
||||
}
|
||||
// 'gone': the burn landed - the carried secret is the only money left
|
||||
return {k1: err.newSecrets[0], rescued: true}
|
||||
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
|
||||
@@ -361,7 +363,41 @@ export const claimFromPreimage = async (
|
||||
rotationError = err.message
|
||||
}
|
||||
} else {
|
||||
rotationError = err instanceof Error ? err.message : String(err)
|
||||
// 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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user