mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
380 lines
15 KiB
TypeScript
380 lines
15 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 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<void>;
|
|
};
|
|
|
|
// 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<string, number>();
|
|
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<PendingTransferSourceRecovery> => {
|
|
// 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<TransferResult> => {
|
|
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 };
|
|
}
|
|
}
|
|
};
|