From 67b2122e52399b85e4829a0b3b61224252ae8ebf Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:22:56 +0200 Subject: [PATCH] feat(lnurlcash): transfer to mints with wallet-named outputs A named target needs no verify URL: the note appearing at the wallet's own secret is the settlement proof, and it is claimed without a rotate. The claim material carries the secret - and the unverified note at it - from the start, so a melt settling after the poll budget is never a lost note; the source-note oracle classification is unchanged. --- src/lnurlcash/ops.transfer.cases.ts | 81 +++++++++++++++++- src/lnurlcash/ops/transfer.ts | 128 +++++++++++++++++++++++++--- 2 files changed, 196 insertions(+), 13 deletions(-) diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 6cedf05..131f1b4 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' -import {noteK1} from 'lnurlcash-kit' +import {hashK1, noteK1} from 'lnurlcash-kit' import {transferBetweenMints} from './ops' import {requiredValue} from './test-utils' @@ -133,4 +133,83 @@ describe('transferBetweenMints', () => { expect(source.state.noteState(returnedK1)).toBe('outstanding') expect(result.carve.note.amount).toBe(21_000) }) + + it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => { + const source = await mint() + const target = await mint({commentAllowed: 64, testHooks: true}) + const k1 = secret('48') + 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') + await expectBurned(source, k1) + const claimed = requiredValue(result.mintedAtTarget) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.verified).toBe(true) + const newK1 = requiredValue(noteK1(claimed.note.url)) + // the note stands at the wallet's own secret - the quote was bound to + // its hash, and the payment preimage keys nothing at the target + expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1)) + expect(target.state.noteState(newK1)).toBe('outstanding') + expect(target.state.noteState(preimage)).toBeNull() + }) + + it('keeps the named claim material when the target never credits the note', async () => { + const source = await mint({meltNeverSettles: true}) + const target = await mint({commentAllowed: 64, testHooks: true}) + const k1 = secret('49') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + // the target invoice exists (the quote is bound) but the source melt + // never lands, so nothing is ever credited at the wallet's secret + while (target.state.invoices.size === 0) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + const result = await pending + expect(result.outcome).toBe('unknown-still-pending') + expect(result.mintedAtTarget).toBeUndefined() + // the wallet's secret is the way back to the money if the melt's + // payment arrives later - and the note at that secret is tracked + // unverified, so a late settlement is never a lost note + const noteSecret = requiredValue(result.claimMaterial?.noteSecret) + expect(noteSecret).toMatch(/^[0-9a-f]{64}$/) + const material = requiredValue(result.claimMaterial?.note) + expect(material.verified).toBe(false) + expect(noteK1(material.url)).toBe(noteSecret) + }) + + it('rescues a named transfer through the preimage when the target credited it anyway', async () => { + const source = await mint() + // the mintToHashIgnoresH adversary: advertises naming, credits the + // payment hash - and (non-compliantly) still serves verify + const target = await mint({ + commentAllowed: 64, + mintToHashIgnoresH: true, + verifyOnUnnamedMint: true, + testHooks: true, + }) + const k1 = secret('50') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, + }) + const preimage = await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + await expectBurned(source, k1) + const claimed = requiredValue(result.mintedAtTarget) + expect(claimed.rotated).toBe(true) + // claimed through the preimage path and rotated off it, because that + // preimage rode the invoice + expect(target.state.noteState(preimage)).toBe('burned') + const newK1 = requiredValue(noteK1(claimed.note.url)) + expect(newK1).not.toBe(preimage) + expect(target.state.noteState(newK1)).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index db49837..a72abc6 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -3,13 +3,24 @@ // 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. +// the target note 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. How settling is observed and claimed +// depends on the target's minting mode (see mint.ts): +// +// - UNNAMED target: the verify URL is the observation - its settled +// response reveals the preimage, which IS the note's secret, and the +// claim rotates off it immediately. +// - NAMED target: the note itself is the observation - the wallet named +// the output with its own secret at quote time, so the informational +// GET reporting 'minted' IS the settlement proof, and the note is +// already at a secret nobody else has seen. No verify URL is needed +// (but one is used to rescue the mint that took the hash and credited +// the preimage anyway). +// +// 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, @@ -17,6 +28,7 @@ import { PendingNoteError, buildNoteUrl, decodeBolt11AmountMsat, + fetchInvoiceVerification, isPreimage, meltNote, noteK1, @@ -30,8 +42,8 @@ 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 {ClaimedNote, PreparedMint} from './mint' +import {claimFromPreimage, pollMintClaim, prepareMint} from './mint' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' import {assertFundOwner, pollVerifyUntilSettled} from './shared' @@ -76,6 +88,10 @@ export type TransferClaimMaterial = { // the preimage note, unverified, once the preimage is known - the // preimage IS the note secret; the caller must track it and retry note?: NewBearer + // named target only: 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 = { @@ -85,8 +101,9 @@ export type TransferResult = { 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 + // the target invoice's verify URL - an unnamed target's ground truth + // (a named target may not serve one at all; the note itself is observed) + verifyUrl: string | null sourceServer: string targetServer: string // the fresh target note, on 'settled' @@ -106,6 +123,19 @@ export type TransferOptions = { assertOwner?: () => 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 +} + export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, @@ -120,7 +150,7 @@ export const transferBetweenMints = async ( // 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) { + if (prepared.mode === 'unnamed' && !prepared.verifyUrl) { throw new Error( 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', ) @@ -153,6 +183,13 @@ export const transferBetweenMints = async ( withdrawLink: prepared.withdrawLink, expectedNoteValueMsat: prepared.expectedNoteValueMsat, } + if (prepared.noteSecret) { + claimMaterial.noteSecret = prepared.noteSecret + // a named target's note is claimable from the wallet's secret alone, + // the moment the invoice settles - track it unverified from the start, + // so a melt that settles after the budget is never a lost note + claimMaterial.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} @@ -171,6 +208,73 @@ export const transferBetweenMints = async ( // payment arrived, and the source probe tells the rest } try { + if (prepared.mode === 'named') { + const noteSecret = prepared.noteSecret + if (!noteSecret) throw new Error('The target mint was prepared without a note secret.') + // the note appearing at the wallet's own secret IS the settlement + // proof - the target only credits it once the melt's payment landed + try { + const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options) + if (claim.state !== 'minted' || claim.amountMsat === null || !claim.callback) { + // 'spent' on a fresh wallet-chosen secret, or an incomplete + // answer: the target misbehaved - the way back to the note is + // already in the claim material + return {...base, outcome: 'settled-claim-failed', claimMaterial} + } + const note: NewBearer = { + url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat), + callback: claim.callback, + amount: claim.amountMsat, + verified: true, + } + if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey + // no rotate: the secret never rode an invoice (see mint.ts) + return {...base, outcome: 'settled', mintedAtTarget: {note, rotated: true}} + } catch (err) { + // the note never appeared at the wallet's secret within budget. + // Rescue through the quote's verify when the target serves one: + // a mint that took the hash but credited the PREIMAGE (the + // mintToHashIgnoresH adversary) is claimable through the old path + if (verifyUrl) { + try { + const proof = await fetchInvoiceVerification(verifyUrl, options) + if (proof.settled && sameInvoice(proof.pr, invoice) && proof.preimage) { + if (isPreimage(proof.preimage)) { + try { + const claimed = await claimFromPreimage(prepared, proof.preimage, options) + return {...base, outcome: 'settled', mintedAtTarget: claimed} + } catch { + 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 { + // best-effort rescue - fall through to the source-note oracle + } + } + throw err + } + } + if (!verifyUrl) { + throw new Error( + 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', + ) + } 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