// 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 exactly like any other minted receive. The target note // appearing at the wallet's pre-staged secret is the transfer's ground // truth: it can only appear if the source melt's payment arrived. // // When settlement never confirms, 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, NoteUnknownError, PendingNoteError, buildNoteUrl, fetchNoteInfo, meltNote, newSecretsOf, noteK1, requireNoteK1, rotateNote, serverOf, withNewK1, } from 'lnurlcash-kit'; import type { LnurlcashOptions } from 'lnurlcash-kit'; import type { Bearer, NewBearer } from '../types'; import type { OutputSecretAllocator } from './allocation'; import { requireOutputSecrets } from './allocation'; import type { CarveResult } from './carve'; import { ensureExactAmount } from './carve'; import type { ClaimedNote, PreparedMint, PrepareMintOptions } from './mint'; import { MintedNoteSpentError, claimFromSecret, prepareMint } from './mint'; import type { PollOptions } from './shared'; import type { FundOperationOptions, MintSignatureKeys } from './shared'; import { assertFundOwner, withMutationSafety } 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; note: NewBearer; // the wallet-chosen secret the target note lands at. // The note exists at this secret from the moment the invoice settles - // the caller can rebuild the note URL from it and retry the claim noteSecret: string; }; 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 optional verify URL (used for a payment receipt; // the staged note itself is the settlement observation) verifyUrl: string | null; sourceServer: string; targetServer: string; // the fresh target note, on 'settled' mintedAtTarget?: ClaimedNote; // on failed-funds-returned, when the source-probe rotate succeeded: the // returned funds re-secured at a fresh secret. Kept OUT of `carve` // (which always describes the carve as committed by the onCarve // checkpoint) so an early-committed carve and a post-wait rotation can // both be persisted - the carved note the rotate burned must be marked // spent, this note added rotatedNote?: NewBearer; // 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 class TransferMeltCheckpointRequiredError extends Error { override readonly name = 'TransferMeltCheckpointRequiredError'; constructor() { super('A source melt requires a durable onMeltReady checkpoint.'); } } export type TransferOptions = { // verify-poll budget - tests shrink this poll?: PollOptions; // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions; assertOwner?: () => void; // carve checkpoint - committed before the melt and the target // settlement wait, so an abort mid-wait can never strand the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve']; // persists the target note before its hash reaches the invoice callback persistOutput: PrepareMintOptions['persistOutput']; // the caller's durable allocation path: the staged target note's secret, // the source carve's outputs, and the source recovery secret. Required: // the wallet's reserved BIP-32 indices are the only source a restart can // never reuse or lose. allocateOutputSecrets?: OutputSecretAllocator; // trusted signing keys the source carve's landed outputs verify against mintSignatureKeys?: MintSignatureKeys; // Last durable checkpoint before the source melt can become irreversible. onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise; }; // the claim retry material for a named target: the note at the wallet's // own secret, unverified until a claim succeeds const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer => { const note: NewBearer = { url: buildNoteUrl(prepared.withdrawLink, noteSecret, prepared.expectedNoteValueMsat), callback: '', amount: prepared.expectedNoteValueMsat, verified: false, }; if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey; return note; }; const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => { const eligible = bearers.filter( (bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url), ); const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer); if (eligible.length > 0 && candidates.length === 0) { throw new Error("That's the mint these notes are already on - pick a different target."); } const totals = new Map(); for (const bearer of candidates) { const server = serverOf(bearer.url); totals.set(server, (totals.get(server) ?? 0) + bearer.amount); } if (![...totals.values()].some((total) => total >= grossMsat)) { throw new Error('No mint holds enough verified, unspent balance to cover that amount.'); } return candidates; }; export type PendingTransferSourceRecovery = | { readonly state: 'pending' } | { readonly state: 'spent' } | { readonly state: 'returned'; readonly note: NewBearer }; export const recoverPendingTransferSource = async ( staged: Bearer, source: Bearer, options: FundOperationOptions = {}, ): Promise => { // a recovery only ever PROBES the melt outcome and rotates - it never // replays the melt itself; the forced policy pins signature/retry safety const mutationOptions = withMutationSafety(options); const recoverySecret = staged.pendingMint?.sourceRecoverySecret; if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.'); const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount); assertFundOwner(options); try { const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions); const note: NewBearer = { url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable), callback: recovered.callback, amount: recovered.maxWithdrawable, verified: true, }; if (source.mintPubkey) note.mintPubkey = source.mintPubkey; return { state: 'returned', note }; } catch (error) { if (error instanceof NoteSpentError) return { state: 'spent' }; if (!(error instanceof NoteUnknownError)) throw error; } assertFundOwner(options); try { const rotated = await rotateNote(source.callback, requireNoteK1(source.url), { ...mutationOptions, randomSecret: () => recoverySecret, }); const note: NewBearer = { url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature), callback: source.callback, amount: source.amount, verified: true, }; if (source.mintPubkey) note.mintPubkey = source.mintPubkey; return { state: 'returned', note }; } catch (error) { if ( error instanceof PendingNoteError || error instanceof NoteSpentError || error instanceof AmbiguousMutationError || newSecretsOf(error).includes(recoverySecret) ) { return { state: 'pending' }; } throw error; } }; export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, { poll = {}, kit = {}, assertOwner, onCarve, persistOutput, allocateOutputSecrets, mintSignatureKeys, onMeltReady, }: TransferOptions, ): Promise => { const options: FundOperationOptions = withMutationSafety({ ...kit, ...(assertOwner ? { assertOwner } : {}), ...(onCarve ? { onCarve } : {}), ...(mintSignatureKeys ? { mintSignatureKeys } : {}), }); 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, persistOutput, ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}), beforePersist: ({ grossMsat, server }) => { transferSources(bearers, server, grossMsat); }, }); 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 offTarget = transferSources(bearers, targetServer, prepared.grossMsat); 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, ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}), }); const sourceServer = serverOf(carve.note.url); const invoice = prepared.invoice; const claimMaterial: TransferClaimMaterial = { invoice, withdrawLink: prepared.withdrawLink, expectedNoteValueMsat: prepared.expectedNoteValueMsat, noteSecret: prepared.noteSecret, note: namedClaimNote(prepared, prepared.noteSecret), }; // 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); if (!onMeltReady) throw new TransferMeltCheckpointRequiredError(); const [sourceRecoverySecret] = await requireOutputSecrets( allocateOutputSecrets, serverOf(carve.note.url), 1, ); if (carve.consumed.length === 0) assertFundOwner(options); await onMeltReady(carve, sourceRecoverySecret); assertFundOwner(options); try { await meltNote(carve.note.callback, k1, invoice, options); } catch (error) { if (!(error instanceof Error)) throw error; if (error instanceof NoteSpentError) { 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 { try { const claimed = await claimFromSecret(prepared, poll, options); return { ...base, outcome: 'settled', mintedAtTarget: claimed }; } catch (error) { if (error instanceof MintedNoteSpentError) { return { ...base, outcome: 'settled-claim-failed', claimMaterial }; } throw new Error('The target note could not be confirmed at its staged secret.', { cause: error, }); } } 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, randomSecret: () => sourceRecoverySecret, }); // the rotate succeeded: the melt provably never landed, and the // funds are back - re-secured, since the melt attempt put k1 on the // wire. Reported separately from `carve` (see rotatedNote): the // carve may already be committed, and its note is what this rotate // burned return { ...base, outcome: 'failed-funds-returned', rotatedNote: { url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature), callback: carve.note.callback, amount: carve.note.amount, verified: true, ...(carve.note.mintPubkey ? { mintPubkey: carve.note.mintPubkey } : {}), }, }; } 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. Keep the transfer journal until // recovery proves whether the original or recovery secret is live. 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: 'unknown-still-pending', claimMaterial, rescuedNote }; } return { ...base, outcome: 'unknown-still-pending', claimMaterial }; } } };