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-named.cases'
|
||||||
import './ops.mint-receive.cases'
|
import './ops.mint-receive.cases'
|
||||||
import './ops.pay.cases'
|
import './ops.pay.cases'
|
||||||
|
import './ops.retry-refusal.cases'
|
||||||
import './ops.transfer.cases'
|
import './ops.transfer.cases'
|
||||||
|
|||||||
+102
-39
@@ -5,6 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
AmbiguousMutationError,
|
AmbiguousMutationError,
|
||||||
mergeNotes,
|
mergeNotes,
|
||||||
|
newSecretsOf,
|
||||||
noteK1,
|
noteK1,
|
||||||
probeBurnedNote,
|
probeBurnedNote,
|
||||||
requireNoteK1,
|
requireNoteK1,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from '../types'
|
import type {Bearer, NewBearer} from '../types'
|
||||||
import type {FundOperationOptions} from './shared'
|
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
|
// 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
|
||||||
@@ -151,38 +152,78 @@ export const ensureExactAmount = async (
|
|||||||
changeSignature = parts.changeSignature
|
changeSignature = parts.changeSignature
|
||||||
partVerified = true
|
partVerified = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof AmbiguousMutationError)) throw err
|
if (err instanceof AmbiguousMutationError) {
|
||||||
// the split request may have landed despite the failure - probe one
|
// the split request may have landed despite the failure - probe one
|
||||||
// input before deciding what the carried secrets are worth
|
// input before deciding what the carried secrets are worth
|
||||||
const outcome = await probeBurnedNote(base.url, options)
|
const outcome = await probeBurnedNote(base.url, options)
|
||||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
if (outcome === 'live') throw err // nothing burned - a plain failure
|
||||||
if (outcome === 'unknown') {
|
if (outcome === 'unknown') {
|
||||||
// can't tell: surface both possible outputs unverified WITHOUT
|
// can't tell: surface both possible outputs unverified WITHOUT
|
||||||
// consuming the inputs, and stop here rather than spend from limbo
|
// consuming the inputs, and stop here rather than spend from limbo
|
||||||
throw new UncertainOutcomeError(
|
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.',
|
'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),
|
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
|
||||||
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
|
||||||
|
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 = {
|
const note: NewBearer = {
|
||||||
url: withNewK1(base.url, partK1, amountMsat, partSignature),
|
url: withNewK1(base.url, partK1, amountMsat, partSignature),
|
||||||
@@ -261,15 +302,38 @@ const mergeAmbiguitySafe = async (
|
|||||||
const merged = await mergeNotes(base.callback, k1s, options)
|
const merged = await mergeNotes(base.callback, k1s, options)
|
||||||
return {k1: merged.k1, signature: merged.signature, rescued: false}
|
return {k1: merged.k1, signature: merged.signature, rescued: false}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof AmbiguousMutationError)) throw err
|
if (err instanceof AmbiguousMutationError) {
|
||||||
const outcome = await probeBurnedNote(base.url, options)
|
const outcome = await probeBurnedNote(base.url, options)
|
||||||
if (outcome === 'live') throw err // nothing burned - a plain failure
|
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') {
|
if (outcome === 'unknown') {
|
||||||
throw new UncertainOutcomeError(
|
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,
|
callback: base.callback,
|
||||||
amount: total,
|
amount: total,
|
||||||
verified: false,
|
verified: false,
|
||||||
@@ -278,7 +342,6 @@ const mergeAmbiguitySafe = async (
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 'gone': the burn landed - the carried secret is the only money left
|
return {k1: carried[0], rescued: true}
|
||||||
return {k1: err.newSecrets[0], rescued: true}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
lightningAddressUsername,
|
lightningAddressUsername,
|
||||||
mintAddressUrl,
|
mintAddressUrl,
|
||||||
namesMintOutput,
|
namesMintOutput,
|
||||||
|
newSecretsOf,
|
||||||
|
NoteSpentError,
|
||||||
probeBurnedNote,
|
probeBurnedNote,
|
||||||
requestInvoice,
|
requestInvoice,
|
||||||
resolveMintInput,
|
resolveMintInput,
|
||||||
@@ -47,7 +49,7 @@ import type {NewBearer} from '../types'
|
|||||||
import {ceilMsatToSat} from '../units'
|
import {ceilMsatToSat} from '../units'
|
||||||
import type {PollOptions} from './shared'
|
import type {PollOptions} from './shared'
|
||||||
import type {FundOperationOptions} 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 = {
|
export type PreparedMint = {
|
||||||
invoice: string
|
invoice: string
|
||||||
@@ -361,7 +363,41 @@ export const claimFromPreimage = async (
|
|||||||
rotationError = err.message
|
rotationError = err.message
|
||||||
}
|
}
|
||||||
} else {
|
} 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}
|
const claimed: ClaimedNote = {note: {...base, url}, rotated}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
NoteSpentError,
|
NoteSpentError,
|
||||||
NoteUnknownError,
|
NoteUnknownError,
|
||||||
PendingNoteError,
|
PendingNoteError,
|
||||||
|
newSecretsOf,
|
||||||
probeBurnedNote,
|
probeBurnedNote,
|
||||||
withNewK1,
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
@@ -16,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
|
|||||||
import {receiveNote, secureReceivedNote} from '../receive'
|
import {receiveNote, secureReceivedNote} from '../receive'
|
||||||
import type {ClaimedNote} from './mint'
|
import type {ClaimedNote} from './mint'
|
||||||
import type {FundOperationOptions} from './shared'
|
import type {FundOperationOptions} from './shared'
|
||||||
import {assertFundOwner} from './shared'
|
import {assertFundOwner, probeMutationOutput} from './shared'
|
||||||
|
|
||||||
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
|
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
|
||||||
// definitive and propagate; an unreachable service still yields the note,
|
// definitive and propagate; an unreachable service still yields the note,
|
||||||
@@ -32,9 +33,43 @@ export const receiveBearer = async (
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
assertFundOwner(options)
|
assertFundOwner(options)
|
||||||
const rotatedUrl = await secureReceivedNote(note)
|
const rotatedUrl = await secureReceivedNote(note, options)
|
||||||
return {note: {...note, url: rotatedUrl}, rotated: true}
|
return {note: {...note, url: rotatedUrl}, rotated: true}
|
||||||
} catch (err) {
|
} 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
|
// a definitive service state (dead/unknown/locked mid-melt) is not a
|
||||||
// rotation failure to warn about - it tells the holder what this note
|
// rotation failure to warn about - it tells the holder what this note
|
||||||
// actually is, so it propagates distinctly
|
// actually is, so it propagates distinctly
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
NoteUnknownError,
|
NoteUnknownError,
|
||||||
PendingNoteError,
|
PendingNoteError,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
|
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||||
import type {Bearer, NewBearer} from './types'
|
import type {Bearer, NewBearer} from './types'
|
||||||
|
|
||||||
// shared by Scan and Paste: resolve whatever came in to a note URL, ask the
|
// 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
|
// 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
|
// 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
|
// 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.
|
// speak lnurlcash) - the caller should warn, not fail the receive. The
|
||||||
export const secureReceivedNote = async (note: {
|
// error carries the fresh secret (kit's newSecretsOf) so the caller can
|
||||||
url: string
|
// rescue a rotate that landed despite its refusal.
|
||||||
callback: string
|
export const secureReceivedNote = async (
|
||||||
amount: number
|
note: {
|
||||||
}): Promise<string> => {
|
url: string
|
||||||
|
callback: string
|
||||||
|
amount: number
|
||||||
|
},
|
||||||
|
options: LnurlcashOptions = {},
|
||||||
|
): Promise<string> => {
|
||||||
const k1 = noteK1(note.url)
|
const k1 = noteK1(note.url)
|
||||||
if (!k1 || !note.callback) {
|
if (!k1 || !note.callback) {
|
||||||
throw new Error('Note has no callback to rotate against yet.')
|
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)
|
return withNewK1(note.url, result.k1, note.amount, result.signature)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user