mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
// 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}
|
|
}
|
|
}
|
|
}
|