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.
This commit is contained in:
2026-08-31 12:22:56 +02:00
parent 58268b2921
commit 67b2122e52
2 changed files with 196 additions and 13 deletions
+80 -1
View File
@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest' import {describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {noteK1} from 'lnurlcash-kit' import {hashK1, noteK1} from 'lnurlcash-kit'
import {transferBetweenMints} from './ops' import {transferBetweenMints} from './ops'
import {requiredValue} from './test-utils' import {requiredValue} from './test-utils'
@@ -133,4 +133,83 @@ describe('transferBetweenMints', () => {
expect(source.state.noteState(returnedK1)).toBe('outstanding') expect(source.state.noteState(returnedK1)).toBe('outstanding')
expect(result.carve.note.amount).toBe(21_000) 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')
})
}) })
+116 -12
View File
@@ -3,13 +3,24 @@
// that exist: this wallet requests an invoice FROM the target mint // that exist: this wallet requests an invoice FROM the target mint
// (grossed up for its advertised mint fee, so the note that comes out // (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 // nets the requested amount), melts source notes to pay it, then claims
// the target note from the revealed preimage exactly like any other // the target note exactly like any other minted receive. The target
// minted receive. The target invoice settling is the transfer's ground // invoice settling is the transfer's ground truth: it can only settle if
// truth: it can only settle if the source melt's payment arrived, and its // the source melt's payment arrived. How settling is observed and claimed
// verify response is what reveals the preimage to claim with. When it // depends on the target's minting mode (see mint.ts):
// never settles, the source note itself is the oracle - a successful //
// rotate proves the melt never burned it (funds returned), anything else // - UNNAMED target: the verify URL is the observation - its settled
// stays uncertain. // 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 { import {
AmbiguousMutationError, AmbiguousMutationError,
@@ -17,6 +28,7 @@ import {
PendingNoteError, PendingNoteError,
buildNoteUrl, buildNoteUrl,
decodeBolt11AmountMsat, decodeBolt11AmountMsat,
fetchInvoiceVerification,
isPreimage, isPreimage,
meltNote, meltNote,
noteK1, noteK1,
@@ -30,8 +42,8 @@ import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import type {CarveResult} from './carve' import type {CarveResult} from './carve'
import {ensureExactAmount} from './carve' import {ensureExactAmount} from './carve'
import type {ClaimedNote} from './mint' import type {ClaimedNote, PreparedMint} from './mint'
import {claimFromPreimage, prepareMint} from './mint' import {claimFromPreimage, pollMintClaim, prepareMint} from './mint'
import type {PollOptions} from './shared' import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared' import {assertFundOwner, pollVerifyUntilSettled} from './shared'
@@ -76,6 +88,10 @@ export type TransferClaimMaterial = {
// the preimage note, unverified, once the preimage is known - the // the preimage note, unverified, once the preimage is known - the
// preimage IS the note secret; the caller must track it and retry // preimage IS the note secret; the caller must track it and retry
note?: NewBearer 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 = { export type TransferResult = {
@@ -85,8 +101,9 @@ export type TransferResult = {
quote: TransferQuote quote: TransferQuote
// the invoice the source note was melted to pay // the invoice the source note was melted to pay
invoice: string invoice: string
// the target invoice's verify URL - the transfer's ground truth // the target invoice's verify URL - an unnamed target's ground truth
verifyUrl: string // (a named target may not serve one at all; the note itself is observed)
verifyUrl: string | null
sourceServer: string sourceServer: string
targetServer: string targetServer: string
// the fresh target note, on 'settled' // the fresh target note, on 'settled'
@@ -106,6 +123,19 @@ export type TransferOptions = {
assertOwner?: () => void 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 ( export const transferBetweenMints = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
@@ -120,7 +150,7 @@ export const transferBetweenMints = async (
// TARGET mint - a failure here (unreachable, no minting support, amount // TARGET mint - a failure here (unreachable, no minting support, amount
// out of range) leaves every source note untouched // out of range) leaves every source note untouched
const prepared = await prepareMint(targetMint, amountMsat, options) const prepared = await prepareMint(targetMint, amountMsat, options)
if (!prepared.verifyUrl) { if (prepared.mode === 'unnamed' && !prepared.verifyUrl) {
throw new Error( throw new Error(
'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', '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, withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat, 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 // from here on the carve's fresh secrets exist only in this result - the
// flow never throws again; every outcome carries them // flow never throws again; every outcome carries them
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} 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 // payment arrived, and the source probe tells the rest
} }
try { 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) const proof = await pollVerifyUntilSettled(verifyUrl, poll, options)
// the proof-binding rule from pay.ts, extended for the gross-up: the // 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 // verify URL is scoped to this invoice's payment hash, so an exact pr