mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: inter-mint transfer op with fee quote and ambiguity-safe outcomes
This commit is contained in:
+199
-1
@@ -25,7 +25,8 @@ import {
|
||||
ensureExactAmount,
|
||||
payWithBearers,
|
||||
prepareMint,
|
||||
receiveBearer
|
||||
receiveBearer,
|
||||
transferBetweenMints
|
||||
} from './ops'
|
||||
|
||||
type Mint = Awaited<ReturnType<typeof createMockMint>>
|
||||
@@ -81,6 +82,26 @@ const settleLastInvoice = async (m: Mint): Promise<string> => {
|
||||
return m.state.invoices.get(paymentHash)!.preimage
|
||||
}
|
||||
|
||||
// waits for a mint to have an invoice at all, then settles it - for flows
|
||||
// that request the invoice deep inside a single awaited call (transfer),
|
||||
// where the test has to play the arriving payment mid-flight
|
||||
const settleWhenRequested = async (m: Mint): Promise<string> => {
|
||||
for (let i = 0; i < 200 && m.state.invoices.size === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
return settleLastInvoice(m)
|
||||
}
|
||||
|
||||
// the mock burns a melted note 20ms after the melt - a transfer can
|
||||
// resolve off the TARGET's settlement faster than that, so source-burn
|
||||
// assertions wait for the mock's own timer instead of racing it
|
||||
const expectBurned = async (m: Mint, k1: string): Promise<void> => {
|
||||
for (let i = 0; i < 200 && m.state.noteState(k1) !== 'burned'; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(m.state.noteState(k1)).toBe('burned')
|
||||
}
|
||||
|
||||
describe('ensureExactAmount', () => {
|
||||
it('returns an already-exact note untouched, burning nothing', async () => {
|
||||
const m = await mint()
|
||||
@@ -406,3 +427,180 @@ describe('payWithBearers', () => {
|
||||
).rejects.toThrow(/not a valid/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transferBetweenMints', () => {
|
||||
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}
|
||||
|
||||
it('moves value to another mint: melt at source, claim + rotate at target', async () => {
|
||||
const source = await mint()
|
||||
const target = await mint({testHooks: true})
|
||||
const k1 = secret('40')
|
||||
const bearer = await makeBearer(source, k1, 21_000)
|
||||
|
||||
const pending = transferBetweenMints(
|
||||
[bearer],
|
||||
21_000,
|
||||
`mint@127.0.0.1:${target.port}`,
|
||||
{poll: fastPoll}
|
||||
)
|
||||
// the transfer is now waiting on the target invoice settling - the
|
||||
// mock mints can't actually pay each other, so the settle hook plays
|
||||
// the melt's payment arriving
|
||||
const preimage = await settleWhenRequested(target)
|
||||
const result = await pending
|
||||
|
||||
expect(result.outcome).toBe('settled')
|
||||
expect(result.invoice).toMatch(/^lnbc/)
|
||||
expect(result.quote).toEqual({
|
||||
requestedMsat: 21_000,
|
||||
grossMsat: 21_000,
|
||||
targetMintFeeMsat: 0,
|
||||
sourceMeltFeeReserveMsat: 0
|
||||
})
|
||||
expect(result.sourceServer).not.toBe(result.targetServer)
|
||||
await expectBurned(source, k1)
|
||||
|
||||
const claimed = result.mintedAtTarget!
|
||||
expect(claimed.rotated).toBe(true)
|
||||
expect(claimed.note.amount).toBe(21_000)
|
||||
expect(claimed.note.verified).toBe(true)
|
||||
// the preimage is the secret the target mint necessarily saw - after
|
||||
// the rotate it is worthless there, and the wallet's fresh secret is
|
||||
// the only live note
|
||||
expect(target.state.noteState(preimage)).toBe('burned')
|
||||
const newK1 = noteK1(claimed.note.url)!
|
||||
expect(newK1).not.toBe(preimage)
|
||||
expect(target.state.noteState(newK1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('refuses an amount no source mint can cover', async () => {
|
||||
const source = await mint()
|
||||
const target = await mint()
|
||||
const k1 = secret('41')
|
||||
const bearer = await makeBearer(source, k1, 5_000)
|
||||
|
||||
await expect(
|
||||
transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`)
|
||||
).rejects.toThrow(/enough/)
|
||||
expect(source.state.noteState(k1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('rejects a transfer onto the mint the notes are already on', async () => {
|
||||
const m = await mint()
|
||||
const k1 = secret('42')
|
||||
const bearer = await makeBearer(m, k1, 21_000)
|
||||
|
||||
await expect(
|
||||
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${m.port}`)
|
||||
).rejects.toThrow(/different target/)
|
||||
expect(m.state.noteState(k1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('moves nothing when the target mint is unreachable', async () => {
|
||||
const source = await mint()
|
||||
// not via the mint() helper - a dead server stays out of afterEach
|
||||
const dead = await createMockMint()
|
||||
const deadAddress = `mint@127.0.0.1:${dead.port}`
|
||||
await dead.close()
|
||||
const k1 = secret('43')
|
||||
// a note LARGER than the transfer amount, so a premature carve would
|
||||
// show up here as a burn
|
||||
const bearer = await makeBearer(source, k1, 50_000)
|
||||
|
||||
await expect(
|
||||
transferBetweenMints([bearer], 21_000, deadAddress)
|
||||
).rejects.toThrow()
|
||||
expect(source.state.noteState(k1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
|
||||
// unconfirmedMutation: the melt's response confirms nothing, so the
|
||||
// melt's outcome is uncertain - the target invoice settling is the
|
||||
// transfer's ground truth
|
||||
const source = await mint({unconfirmedMutation: true})
|
||||
const target = await mint({testHooks: true})
|
||||
const k1 = secret('44')
|
||||
const bearer = await makeBearer(source, k1, 21_000)
|
||||
|
||||
const pending = transferBetweenMints(
|
||||
[bearer],
|
||||
21_000,
|
||||
`mint@127.0.0.1:${target.port}`,
|
||||
{poll: fastPoll}
|
||||
)
|
||||
await settleWhenRequested(target)
|
||||
const result = await pending
|
||||
|
||||
expect(result.outcome).toBe('settled')
|
||||
// the melt had landed despite its lost answer - the source note is
|
||||
// gone, and the target note came out the other end
|
||||
await expectBurned(source, k1)
|
||||
expect(result.mintedAtTarget?.note.amount).toBe(21_000)
|
||||
expect(result.mintedAtTarget?.rotated).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => {
|
||||
// echoWrongK1: the target settles the invoice and reveals the
|
||||
// preimage, but its informational GET then breaks the claim
|
||||
const source = await mint()
|
||||
const target = await mint({testHooks: true, echoWrongK1: true})
|
||||
const k1 = secret('45')
|
||||
const bearer = await makeBearer(source, k1, 21_000)
|
||||
|
||||
const pending = transferBetweenMints(
|
||||
[bearer],
|
||||
21_000,
|
||||
`mint@127.0.0.1:${target.port}`,
|
||||
{poll: fastPoll}
|
||||
)
|
||||
const preimage = await settleWhenRequested(target)
|
||||
const result = await pending
|
||||
|
||||
expect(result.outcome).toBe('settled-claim-failed')
|
||||
await expectBurned(source, k1)
|
||||
// the preimage IS the note secret - surfaced unverified, not lost
|
||||
const note = result.claimMaterial?.note
|
||||
expect(note).toBeDefined()
|
||||
expect(noteK1(note!.url)).toBe(preimage)
|
||||
expect(note!.verified).toBe(false)
|
||||
expect(note!.amount).toBe(21_000)
|
||||
expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`)
|
||||
})
|
||||
|
||||
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
|
||||
const source = await mint()
|
||||
const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000})
|
||||
const k1 = secret('46')
|
||||
// covers the requested net exactly - but not the grossed-up invoice
|
||||
const bearer = await makeBearer(source, k1, 100_000)
|
||||
|
||||
await expect(
|
||||
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`)
|
||||
).rejects.toThrow(/enough/)
|
||||
expect(source.state.noteState(k1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('restores the source note, re-secured, when the melt fails', async () => {
|
||||
const source = await mint({meltAlwaysFails: true})
|
||||
const target = await mint({testHooks: true})
|
||||
const k1 = secret('47')
|
||||
const bearer = await makeBearer(source, k1, 21_000)
|
||||
|
||||
const result = await transferBetweenMints(
|
||||
[bearer],
|
||||
21_000,
|
||||
`mint@127.0.0.1:${target.port}`,
|
||||
{poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}}
|
||||
)
|
||||
expect(result.outcome).toBe('failed-funds-returned')
|
||||
expect(result.mintedAtTarget).toBeUndefined()
|
||||
// the classification rotate re-secured the note (the melt had put its
|
||||
// k1 on the wire): the old secret is burned, the fresh one in the
|
||||
// result is outstanding at the full amount
|
||||
expect(source.state.noteState(k1)).toBe('burned')
|
||||
const returnedK1 = noteK1(result.carve.note.url)!
|
||||
expect(returnedK1).not.toBe(k1)
|
||||
expect(source.state.noteState(returnedK1)).toBe('outstanding')
|
||||
expect(result.carve.note.amount).toBe(21_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
// ops/mint.ts - prepareMint / claimMintedNote (receive over Lightning)
|
||||
// ops/receiveBearer.ts - receiveBearer (receive a note, rotate on receive)
|
||||
// ops/pay.ts - payWithBearers (melt to bolt11 / Lightning Address)
|
||||
// ops/transfer.ts - transferBetweenMints (inter-mint move: melt at
|
||||
// source, mint + claim + rotate at target)
|
||||
// ops/shared.ts - bounded verify polling, UncertainOutcomeError
|
||||
|
||||
export {UncertainOutcomeError} from './ops/shared'
|
||||
@@ -33,3 +35,11 @@ export type {PreparedMint, ClaimedNote} from './ops/mint'
|
||||
export {receiveBearer} from './ops/receiveBearer'
|
||||
export {payWithBearers} from './ops/pay'
|
||||
export type {PayOutcome, PayResult, PayOptions} from './ops/pay'
|
||||
export {transferBetweenMints} from './ops/transfer'
|
||||
export type {
|
||||
TransferClaimMaterial,
|
||||
TransferOptions,
|
||||
TransferOutcome,
|
||||
TransferQuote,
|
||||
TransferResult
|
||||
} from './ops/transfer'
|
||||
|
||||
@@ -145,18 +145,43 @@ export const claimMintedNote = async (
|
||||
'The payment settled but the service did not reveal the preimage.'
|
||||
)
|
||||
}
|
||||
return claimFromPreimage(prepared, preimage, options)
|
||||
}
|
||||
|
||||
// what a claim needs once the preimage is known - PreparedMint satisfies
|
||||
// this, and so does the target side of an inter-mint transfer (see
|
||||
// transfer.ts)
|
||||
export type ClaimTarget = {
|
||||
withdrawLink: string
|
||||
// the net note value asked for - a claim, cross-checked against the
|
||||
// service's authoritative maxWithdrawable
|
||||
expectedNoteValueMsat: number
|
||||
mintPubkey?: string
|
||||
}
|
||||
|
||||
// The claim itself, once the payment's preimage is known: the preimage IS
|
||||
// the note secret. The claim (an informational GET) puts that secret on
|
||||
// the wire, and the mint has known it since it generated the invoice - so
|
||||
// the fresh note is rotated immediately and unconditionally (observer
|
||||
// race: anyone who saw the unpaid invoice knows the payment hash), before
|
||||
// anything else happens with it.
|
||||
export const claimFromPreimage = async (
|
||||
claim: ClaimTarget,
|
||||
preimage: string,
|
||||
options: LnurlcashOptions = {}
|
||||
): Promise<ClaimedNote> => {
|
||||
// declare the invoiced amount (a claim - not yet confirmed) so the note
|
||||
// is self-describing even before the verifying GET below
|
||||
const declaredUrl = buildNoteUrl(
|
||||
prepared.withdrawLink,
|
||||
claim.withdrawLink,
|
||||
preimage,
|
||||
prepared.expectedNoteValueMsat
|
||||
claim.expectedNoteValueMsat
|
||||
)
|
||||
// the service's maxWithdrawable is authoritative - SERVICE's own fee
|
||||
// math might not match this wallet's estimate, and the note is worth
|
||||
// exactly maxWithdrawable regardless
|
||||
const noteInfo = await fetchNoteInfo(declaredUrl, options)
|
||||
const mintPubkey = noteInfo.mintPubkey ?? prepared.mintPubkey
|
||||
const mintPubkey = noteInfo.mintPubkey ?? claim.mintPubkey
|
||||
const base: NewBearer = {
|
||||
url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable),
|
||||
callback: noteInfo.callback,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Transfer between mints: moving value off one mint onto another. The
|
||||
// protocol has no such primitive - a transfer is composed from the two
|
||||
// that exist: this wallet requests an invoice FROM the target mint
|
||||
// (grossed up for its advertised mint fee, so the note that comes out
|
||||
// nets the requested amount), melts source notes to pay it, then claims
|
||||
// the target note from the revealed preimage exactly like any other
|
||||
// minted receive. The target invoice settling is the transfer's ground
|
||||
// truth: it can only settle if the source melt's payment arrived, and its
|
||||
// verify response is what reveals the preimage to claim with. When it
|
||||
// never settles, the source note itself is the oracle - a successful
|
||||
// rotate proves the melt never burned it (funds returned), anything else
|
||||
// stays uncertain.
|
||||
|
||||
import {
|
||||
AmbiguousMutationError,
|
||||
NoteSpentError,
|
||||
PendingNoteError,
|
||||
buildNoteUrl,
|
||||
decodeBolt11AmountMsat,
|
||||
isPreimage,
|
||||
meltNote,
|
||||
noteK1,
|
||||
requireNoteK1,
|
||||
rotateNote,
|
||||
sameInvoice,
|
||||
serverOf,
|
||||
withNewK1
|
||||
} from 'lnurlcash-kit'
|
||||
import type {LnurlcashOptions} from 'lnurlcash-kit'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import type {CarveResult} from './carve'
|
||||
import {ensureExactAmount} from './carve'
|
||||
import type {ClaimedNote} from './mint'
|
||||
import {claimFromPreimage, prepareMint} from './mint'
|
||||
import type {PollOptions} from './shared'
|
||||
import {pollVerifyUntilSettled} from './shared'
|
||||
|
||||
export type TransferOutcome =
|
||||
// the melt settled and the target note was claimed (and rotated)
|
||||
| 'settled'
|
||||
// the melt provably never happened - the source note is restored,
|
||||
// re-secured by the rotate that proved it (k1 had been on the wire)
|
||||
| 'failed-funds-returned'
|
||||
// neither the target invoice nor the source probe confirmed anything -
|
||||
// the source note stays locked spent locally until a refresh reconciles
|
||||
| 'unknown-still-pending'
|
||||
// the carved source note was already spent before the melt even started
|
||||
| 'note-already-spent'
|
||||
// the target invoice settled (the money arrived) but the claim could
|
||||
// not complete - claimMaterial carries everything needed to retry it
|
||||
| 'settled-claim-failed'
|
||||
|
||||
export type TransferQuote = {
|
||||
// the net value the user wants to land on the target mint
|
||||
requestedMsat: number
|
||||
// what the source side must cover - the target invoice, grossed up for
|
||||
// the target's advertised mint fee and rounded to a whole sat
|
||||
grossMsat: number
|
||||
// the target mint's receive fee as estimated by the gross-up (the
|
||||
// service's own fee math is authoritative - the claimed note's amount
|
||||
// is what it actually withheld)
|
||||
targetMintFeeMsat: number
|
||||
// LUD-25 melt has no fee field - the melted note must equal the invoice
|
||||
// exactly, so no source-side reserve is even expressible
|
||||
sourceMeltFeeReserveMsat: number
|
||||
}
|
||||
|
||||
// everything a caller needs to retry (or log) the target claim when the
|
||||
// transfer could not complete it - once the melt has settled this
|
||||
// material IS the money, so it is never dropped
|
||||
export type TransferClaimMaterial = {
|
||||
invoice: string
|
||||
withdrawLink: string
|
||||
expectedNoteValueMsat: number
|
||||
// the preimage note, unverified, once the preimage is known - the
|
||||
// preimage IS the note secret; the caller must track it and retry
|
||||
note?: NewBearer
|
||||
}
|
||||
|
||||
export type TransferResult = {
|
||||
outcome: TransferOutcome
|
||||
// the source-side changeset: consumed inputs and any change note
|
||||
carve: CarveResult
|
||||
quote: TransferQuote
|
||||
// the invoice the source note was melted to pay
|
||||
invoice: string
|
||||
// the target invoice's verify URL - the transfer's ground truth
|
||||
verifyUrl: string
|
||||
sourceServer: string
|
||||
targetServer: string
|
||||
// the fresh target note, on 'settled'
|
||||
mintedAtTarget?: ClaimedNote
|
||||
// present whenever the claim could still complete later
|
||||
claimMaterial?: TransferClaimMaterial
|
||||
// a fresh secret rescued from an ambiguous rotate while classifying the
|
||||
// melt - the caller must track it unverified (same semantics as pay.ts)
|
||||
rescuedNote?: NewBearer
|
||||
}
|
||||
|
||||
export type TransferOptions = {
|
||||
// verify-poll budget - tests shrink this
|
||||
poll?: PollOptions
|
||||
// kit transport overrides (fetch injection, timeouts)
|
||||
kit?: LnurlcashOptions
|
||||
}
|
||||
|
||||
export const transferBetweenMints = async (
|
||||
bearers: Bearer[],
|
||||
amountMsat: number,
|
||||
targetMint: string,
|
||||
{poll = {}, kit = {}}: TransferOptions = {}
|
||||
): Promise<TransferResult> => {
|
||||
const options = kit
|
||||
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
|
||||
throw new Error('Amount must be a positive whole number of msat.')
|
||||
}
|
||||
// resolving the target and requesting its invoice touches only the
|
||||
// TARGET mint - a failure here (unreachable, no minting support, amount
|
||||
// out of range) leaves every source note untouched
|
||||
const prepared = await prepareMint(targetMint, amountMsat, options)
|
||||
if (!prepared.verifyUrl) {
|
||||
throw new Error(
|
||||
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.'
|
||||
)
|
||||
}
|
||||
const verifyUrl = prepared.verifyUrl
|
||||
const targetServer = prepared.server
|
||||
// the source must be a DIFFERENT mint - value "moved" within one mint
|
||||
// goes nowhere (melt pays an invoice; the same mint's invoice just
|
||||
// re-mints into itself, paying fees for nothing)
|
||||
const eligible = bearers.filter(
|
||||
b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url)
|
||||
)
|
||||
const offTarget = eligible.filter(b => serverOf(b.url) !== targetServer)
|
||||
if (eligible.length > 0 && offTarget.length === 0) {
|
||||
throw new Error(
|
||||
'That\'s the mint these notes are already on - pick a different target.'
|
||||
)
|
||||
}
|
||||
const quote: TransferQuote = {
|
||||
requestedMsat: amountMsat,
|
||||
grossMsat: prepared.grossMsat,
|
||||
targetMintFeeMsat: prepared.grossMsat - amountMsat,
|
||||
sourceMeltFeeReserveMsat: 0
|
||||
}
|
||||
// carving burns its inputs server-side, so it happens only once the
|
||||
// target is known good and the invoice exists
|
||||
const carve = await ensureExactAmount(offTarget, prepared.grossMsat, options)
|
||||
const sourceServer = serverOf(carve.note.url)
|
||||
const invoice = prepared.invoice
|
||||
const claimMaterial: TransferClaimMaterial = {
|
||||
invoice,
|
||||
withdrawLink: prepared.withdrawLink,
|
||||
expectedNoteValueMsat: prepared.expectedNoteValueMsat
|
||||
}
|
||||
// from here on the carve's fresh secrets exist only in this result - the
|
||||
// flow never throws again; every outcome carries them
|
||||
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer}
|
||||
const k1 = requireNoteK1(carve.note.url)
|
||||
try {
|
||||
await meltNote(carve.note.callback, k1, invoice, options)
|
||||
} catch (err) {
|
||||
if (err instanceof NoteSpentError) {
|
||||
// this melt names a single note, so this is unambiguous - it was
|
||||
// already gone before the melt even started
|
||||
return {...base, outcome: 'note-already-spent'}
|
||||
}
|
||||
// anything else - a clean refusal, a dropped response, a lost answer -
|
||||
// is resolved below: the target invoice settles only if this melt's
|
||||
// payment arrived, and the source probe tells the rest
|
||||
}
|
||||
try {
|
||||
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
|
||||
// the proof-binding rule from pay.ts, extended for the gross-up: the
|
||||
// verify URL is scoped to this invoice's payment hash, so an exact pr
|
||||
// match binds it; short of that, a proof amount that is neither the
|
||||
// invoiced gross nor the expected net belongs to another payment,
|
||||
// while an undecodable one says nothing either way and is tolerated
|
||||
const proofAmount = decodeBolt11AmountMsat(proof.pr)
|
||||
if (
|
||||
!sameInvoice(proof.pr, invoice) &&
|
||||
proofAmount !== null &&
|
||||
proofAmount !== prepared.grossMsat &&
|
||||
proofAmount !== prepared.expectedNoteValueMsat
|
||||
) {
|
||||
return {...base, outcome: 'unknown-still-pending', claimMaterial}
|
||||
}
|
||||
if (!proof.preimage || !isPreimage(proof.preimage)) {
|
||||
// settled, but the service won't reveal the preimage - the claim
|
||||
// cannot complete automatically
|
||||
return {...base, outcome: 'settled-claim-failed', claimMaterial}
|
||||
}
|
||||
try {
|
||||
const claimed = await claimFromPreimage(prepared, proof.preimage, options)
|
||||
return {...base, outcome: 'settled', mintedAtTarget: claimed}
|
||||
} catch {
|
||||
// the melt settled - the money is now the preimage note at the
|
||||
// target and nowhere else; surface it rather than lose it
|
||||
const note: NewBearer = {
|
||||
url: buildNoteUrl(
|
||||
prepared.withdrawLink,
|
||||
proof.preimage,
|
||||
prepared.expectedNoteValueMsat
|
||||
),
|
||||
callback: '',
|
||||
amount: prepared.expectedNoteValueMsat,
|
||||
verified: false
|
||||
}
|
||||
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey
|
||||
return {
|
||||
...base,
|
||||
outcome: 'settled-claim-failed',
|
||||
claimMaterial: {...claimMaterial, note}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// the target invoice never settled within budget - the source note is
|
||||
// the oracle now: a successful rotate proves the melt never burned it
|
||||
// (and re-secures it, since the melt attempt put k1 on the wire);
|
||||
// pending means the melt is still in flight; spent means the payment
|
||||
// left but never arrived within the budget - the claim material stays
|
||||
// as the way back to the money if the invoice settles later
|
||||
try {
|
||||
const rotated = await rotateNote(carve.note.callback, k1, options)
|
||||
return {
|
||||
...base,
|
||||
outcome: 'failed-funds-returned',
|
||||
carve: {
|
||||
...carve,
|
||||
note: {
|
||||
...carve.note,
|
||||
url: withNewK1(
|
||||
carve.note.url,
|
||||
rotated.k1,
|
||||
carve.note.amount,
|
||||
rotated.signature
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof PendingNoteError || err instanceof NoteSpentError) {
|
||||
return {...base, outcome: 'unknown-still-pending', claimMaterial}
|
||||
}
|
||||
if (err instanceof AmbiguousMutationError) {
|
||||
// the rotate's answer was lost - pay.ts's reasoning: had the note
|
||||
// still been pending the service would have said so, so the funds
|
||||
// ARE back, but whether the rotation landed is unknown. Surface
|
||||
// the possible fresh copy alongside the unchanged note.
|
||||
const rescuedNote: NewBearer = {
|
||||
url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount),
|
||||
callback: carve.note.callback,
|
||||
amount: carve.note.amount,
|
||||
verified: false
|
||||
}
|
||||
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey
|
||||
return {...base, outcome: 'failed-funds-returned', rescuedNote}
|
||||
}
|
||||
return {...base, outcome: 'unknown-still-pending', claimMaterial}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user