mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
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:
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user