From 142734b903e932a36db2d0e62a4c66ceb9ee9fd6 Mon Sep 17 00:00:00 2001 From: protom Date: Wed, 19 Aug 2026 22:27:14 +0200 Subject: [PATCH] feat: inter-mint transfer op with fee quote and ambiguity-safe outcomes --- src/lnurlcash/ops.test.ts | 200 +++++++++++++++++++++++++- src/lnurlcash/ops.ts | 10 ++ src/lnurlcash/ops/mint.ts | 31 +++- src/lnurlcash/ops/transfer.ts | 262 ++++++++++++++++++++++++++++++++++ 4 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 src/lnurlcash/ops/transfer.ts diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts index bb68b16..d7ec823 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -25,7 +25,8 @@ import { ensureExactAmount, payWithBearers, prepareMint, - receiveBearer + receiveBearer, + transferBetweenMints } from './ops' type Mint = Awaited> @@ -81,6 +82,26 @@ const settleLastInvoice = async (m: Mint): Promise => { 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 => { + 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 => { + 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) + }) +}) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index 891b357..b0d800a 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -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' diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 7731d1d..27a802d 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -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 => { // 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, diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts new file mode 100644 index 0000000..793976e --- /dev/null +++ b/src/lnurlcash/ops/transfer.ts @@ -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 => { + 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} + } + } +}