Merge pull request #5 from tompro/feat/named-mint-outputs

feat: support LUD-25 named mint outputs (lnurl-mint v0.4.0)
This commit is contained in:
2026-08-31 14:19:59 +02:00
committed by GitHub
24 changed files with 1254 additions and 155 deletions
+8 -8
View File
@@ -23,7 +23,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.1.1",
"lnurlcash-kit": "0.5.0",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -46,7 +46,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "0.1.1",
"lnurlcash-conformance": "0.4.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
@@ -5961,9 +5961,9 @@
}
},
"node_modules/lnurlcash-conformance": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.1.1.tgz",
"integrity": "sha512-12EXkom7x+tpkk4Pi2zanlMbDpA64hh+b5PKTPoKfahiWPG3GQbNusun1NHKpYjNwyevRISTP9487W+ZqEuEZw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.4.0.tgz",
"integrity": "sha512-PsmjMrle5DE+OeTxLdBRD6ah63m9XPpNlPvB9f4FUdCtI6FbW6k5HcinMMt0mTkd9X9iOZHKG9SIqZ5HQJ0BDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5987,9 +5987,9 @@
}
},
"node_modules/lnurlcash-kit": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.1.1.tgz",
"integrity": "sha512-O1rak4hcoKIBZdtbCMfQhCS4hp9HducJeJf8nEYxbPXtFm83EyPBqOAwWHYkzJMFKnyTqjJmvjjzfgL3ePQQOw==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.5.0.tgz",
"integrity": "sha512-ASX+/yduZjV9aLSoUXlP06dWNJ6Bt3IYrG0QoAqtvnC1h8lCs5rWVzw9RgmE0j1sZs16V2RicDlaSvON4JV8Pg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.3.0",
+2 -2
View File
@@ -34,7 +34,7 @@
"@quasar/extras": "^2.0.4",
"@scure/bip32": "^2.3.0",
"@scure/bip39": "^2.3.0",
"lnurlcash-kit": "0.1.1",
"lnurlcash-kit": "0.5.0",
"nostr-tools": "2.24.3",
"pinia": "^4.0.2",
"qrcode.vue": "^3.10.0",
@@ -57,7 +57,7 @@
"eslint": "^10.8.0",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"lnurlcash-conformance": "0.1.1",
"lnurlcash-conformance": "0.4.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"sass": "^1.102.0",
+6
View File
@@ -170,6 +170,12 @@ export const useMoveFundsPage = () => {
);
} else if (transfer.outcome === 'unknown-still-pending') {
await wallet.markSpent(carved.id, ownerFence);
// a named target's claim material carries the note at the wallet's
// own secret - claimable the moment the melt settles, so it is
// tracked unverified rather than lost with the result object
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await activity.log(
'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
+4 -1
View File
@@ -159,7 +159,10 @@ export const useReceiveLightningDialog = (
try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats));
if (!next.verifyUrl) {
// named mints claim through the wallet's own secret and may
// legitimately serve no verify URL; unnamed mints need one (the
// preimage it reveals IS the claim)
if (next.mode === 'unnamed' && !next.verifyUrl) {
formError.value =
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
return;
+12 -2
View File
@@ -6,7 +6,14 @@ lnurlcash-kit directly.
## FUND-CRITICAL INVARIANTS (non-negotiable)
- Rotate on every receive, and immediately after claiming a fresh mint.
- Rotate on every receive, and immediately after claiming a fresh mint
from an UNNAMED mint (one that keys the note by the payment preimage).
A NAMED mint (LUD-25 `comment`/`mintToHash`, required by lnurl-mint
≥ 0.4) credits the note to a wallet-chosen secret whose hash bound the
quote; that secret never rode an invoice, so the claim needs no rotate.
The claim poll itself is the placement proof: a mint that took the hash
but credited the preimage is caught there and rescued through the
quote's verify URL when one is served.
- A note's declared amount is a claim; the service's `maxWithdrawable` is
authoritative.
- A melt's "OK" only means the payment is in flight — the verify URL (or
@@ -61,4 +68,7 @@ test-utils.ts # mock mint harness used by *.test.ts
## TESTS
`npx vitest run` — node environment, `*.test.ts` next to the modules.
Adversarial mock mint from lnurlcash-conformance via test-utils.
Adversarial mock mint from lnurlcash-conformance via `ops.testHarness.ts`
(`conformance-mockmint.d.ts` augments its stale published declarations
with the comment-naming flags). The mock's `commentAllowed` option models
the named-mint generation; default options model the old one.
+30
View File
@@ -0,0 +1,30 @@
// The published lnurlcash-conformance 0.4.0 hand-written declarations
// (mock-mint/index.d.ts) are stale relative to their own index.mjs: the
// comment-naming flags are implemented and documented in the CHANGELOG
// ("The mock mint gains commentAllowed, commentRefusesMalformed and
// verifyOnUnnamedMint") but never made it into the type surface. This
// augmentation restores them so tests can drive the comment-named mint
// mode without `as any`. Report upstream; drop this file once a release
// ships the declarations.
//
// Semantics (from mock-mint/index.mjs):
// - commentAllowed: false (default) means the pay callback ignores the
// `comment` parameter entirely. A number advertises that many
// characters on the payRequest and reads `comment` on the callback: a
// bare 64-hex sha256 binds the minted note to the wallet's secret
// (k1 = secret, preimage opens nothing); anything else falls back to
// preimage keying and verify is withheld for that quote.
// - commentRefusesMalformed: non-compliant; refuses a non-hash comment
// instead of falling back.
// - verifyOnUnnamedMint: non-compliant; serves LUD-21 verify even on the
// no-comment fallback, where the preimage it hands out IS the note.
declare module 'lnurlcash-conformance/mock-mint' {
interface MockMintOptions {
commentAllowed?: number | false
commentRefusesMalformed?: boolean
verifyOnUnnamedMint?: boolean
}
}
export {}
+98 -10
View File
@@ -1,15 +1,21 @@
// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance
// 0.1.1 artifacts. Two contracts the app's mint discovery relies on:
// Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 /
// lnurlcash-conformance 0.4.0 artifacts. Contracts the app's mint flows
// rely on:
// the kit must MAP the mint-address wire field `nodeCapacity` onto the
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so the
// typed field read undefined forever), and a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published
// conformance mock mint emits `lnurlw://` by default, so do NOT assume an
// HTTPS default anywhere in the receive path.
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
// the typed field read undefined forever); a payRequest withdraw link is
// legal in both its HTTPS and LUD-17 `lnurlw://` forms (the conformance
// mock emits either, selected by its withdrawLinkForm option, so do NOT
// assume a default anywhere in the receive path); and the LUD-06 quote
// request must carry the output-naming hash (as BOTH `comment` and `h`)
// exactly when - and only when - the payRequest advertised the capability
// (commentAllowed >= 64 or mintToHash): a new-generation mint
// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must
// see the byte-identical request this wallet always sent.
import {afterEach, describe, expect, it} from 'vitest'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {buildNoteUrl, fetchMintAddress} from 'lnurlcash-kit'
import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit'
import {claimMintedNote, prepareMint} from './ops'
import {mintAddressCacheInfo} from './trustedMints'
@@ -77,6 +83,20 @@ const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof
return impl
}
// fixtureFetch plus a record of every URL it saw, for canaries about the
// exact query the kit puts on the wire
const capturingFetch = (
routes: ReadonlyArray<readonly [string, unknown]>,
seen: string[],
): typeof fetch => {
const impl: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
seen.push(url)
return fixtureFetch(routes)(input, init)
}
return impl
}
describe('mint-address wire contract', () => {
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
@@ -116,9 +136,10 @@ describe('mint-address wire contract', () => {
describe('withdraw-link forms', () => {
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
const m = await mint({testHooks: true})
// conformance 0.4.0 defaults to plain https links; lnurlw:// (the form
// lnurl-mint emits under LUD-17) is an explicit option
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'})
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000)
// published conformance 0.1.1 emits lnurlw:// by default - NOT https
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//)
// and the link is fully usable: settle the invoice, claim the note
@@ -163,3 +184,70 @@ describe('withdraw-link forms', () => {
)
})
})
describe('quote output-naming wire contract', () => {
const payRequestFixture = (extra: Record<string, unknown>) => ({
tag: 'payRequest',
callback: 'https://mint.example/pay',
minSendable: 1_000,
maxSendable: 100_000_000_000,
withdrawLink: 'https://mint.example/note',
metadata: '[]',
...extra,
})
const routesFor = (payRequest: Record<string, unknown>) =>
[
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
['https://mint.example/.well-known/lnurlp/mint', payRequest],
// amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}],
] as const
const callbackUrl = (seen: string[]): URL => {
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'))
if (!hit) throw new Error('the quote request never hit the wire')
return new URL(hit)
}
it('sends no comment/h to a mint that never advertised output naming', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
})
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// an old mint sees the byte-identical request this wallet always sent
const url = callbackUrl(seen)
expect(url.searchParams.get('comment')).toBeNull()
expect(url.searchParams.get('h')).toBeNull()
})
it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
const url = callbackUrl(seen)
// both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
const comment = url.searchParams.get('comment')
const h = url.searchParams.get('h')
expect(comment).toMatch(/^[0-9a-f]{64}$/)
expect(h).toBe(comment)
expect(comment).toBe(hashK1(noteSecret))
})
it('names the output when only the mintToHash spelling is advertised', async () => {
const seen: string[] = []
const prepared = await prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen),
})
expect(prepared.mode).toBe('named')
const noteSecret = prepared.noteSecret
if (!noteSecret) throw new Error('named mode without a note secret')
expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret))
})
})
+124 -1
View File
@@ -11,7 +11,7 @@ import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import {
@@ -124,6 +124,129 @@ describe('service: make_invoice / lookup_invoice', () => {
await stop()
})
it('settles against a NAMED mint: note at the wallet secret, preimage a plain receipt', async () => {
const m = await mint({commentAllowed: 64, testHooks: true})
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000,
})
expect(made.error).toBeNull()
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.error).toBeNull()
expect(settled.result?.state).toBe('settled')
// a named mint's verify preimage is an ordinary payment receipt -
// it keys nothing at the mint - so it is always safe to hand out
const preimage = requiredString(settled.result?.preimage)
expect(preimage).toMatch(/^[0-9a-f]{64}$/)
expect(m.state.noteState(preimage)).toBeNull()
// the note was claimed at the wallet's own secret - unrotated,
// because nothing exposed it
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
expect(minted.amount).toBe(21_000)
expect(minted.verified).toBe(true)
const mintedK1 = requiredValue(noteK1(minted.url))
expect(m.state.noteState(mintedK1)).toBe('outstanding')
// the quote was bound to that secret's hash
expect([...m.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(mintedK1))
await stop()
})
it('rescues a NAMED mint that credited the preimage, and burns it before handing it out', async () => {
// the mintToHashIgnoresH adversary: advertises output naming, binds
// nothing, credits the payment hash - and (non-compliantly) still
// serves verify, so the preimage path can recover the note
const m = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.result?.state).toBe('settled')
// the rescue claimed through the preimage path and rotated off it, so
// the receipt handed to the client is a burned secret
const preimage = requiredString(settled.result?.preimage)
expect(m.state.noteState(preimage)).toBe('burned')
const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-')))
const mintedK1 = requiredValue(noteK1(minted.url))
expect(mintedK1).not.toBe(preimage)
expect(m.state.noteState(mintedK1)).toBe('outstanding')
await stop()
})
it('withholds the preimage when the rescue claim could not rotate', async () => {
// same lying mint, but the rotate's request never lands (the mint
// dies between the claim and the rotate): the preimage may still be
// the note's secret, so it stays a secret even though the invoice
// reads settled
const m = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const rotateFailingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'))
return fetch(input, init)
}
const {relay, walletServicePubkey, state, stop} = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400},
kit: {fetch: rotateFailingFetch},
})
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000})
const paymentHash = made.result?.payment_hash
if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash')
}
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`)
expect(settleRes.ok).toBe(true)
await waitFor(() => state.changesets.some((c) => c.add.length > 0))
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash,
})
expect(settled.result?.state).toBe('settled')
// settled, but NO receipt: an unrotated preimage may still be the note
expect(settled.result?.preimage).toBeUndefined()
// the note is tracked either way (it IS money)
expect(state.bearers.some((b) => b.id.startsWith('added-'))).toBe(true)
await stop()
})
it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
const m = await mint({testHooks: true})
const commit = deferred()
+75 -13
View File
@@ -1,15 +1,18 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair
// shares: make_invoice answers with the invoice immediately while this
// module's settleAndClaim watches settlement in the background, claims
// the minted note (rotating it immediately), and only THEN records the
// settlement the lookup reports.
// the minted note, and only THEN records the settlement the lookup
// reports.
//
// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted
// note's initial secret. NIP-47 clients expect it as the settlement
// receipt, so it is handed out - but only after claimFromPreimage has
// rotated the fresh note, when that secret is burned and worthless. If
// the rotation failed (claimed.rotated === false) the preimage is
// withheld: fund safety over spec comfort.
// THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint the payment
// preimage IS the minted note's initial secret. NIP-47 clients expect it
// as the settlement receipt, so it is handed out - but only after
// claimFromPreimage has rotated the fresh note, when that secret is
// burned and worthless. If the rotation failed (claimed.rotated ===
// false) the preimage is withheld: fund safety over spec comfort. On a
// NAMED mint the note lives at a wallet-chosen secret from the start and
// the preimage opens nothing at all, so it is an ordinary payment receipt
// and always safe to hand out.
import {isPreimage, sameInvoice} from 'lnurlcash-kit'
import {sha256} from '@noble/hashes/sha2.js'
@@ -17,7 +20,7 @@ import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
import type {NewBearer} from '../types'
import type {PreparedMint} from '../ops'
import {claimFromPreimage} from '../ops/mint'
import {claimFromPreimage, claimFromSecret} from '../ops/mint'
import {pollVerifyUntilSettled} from '../ops/shared'
import type {PollOptions} from '../ops/shared'
@@ -61,13 +64,14 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
}
// the background half of make_invoice: watch the invoice, and once it
// settles claim the note (rotating it immediately) and hand the fresh
// bearer to the caller. Settlement is recorded LAST - after the rotate and
// bearer commit - so lookup can only reveal durably tracked funds and an
// already-burned secret.
// settles claim the note (rotating it immediately on an unnamed mint) and
// hand the fresh bearer to the caller. Settlement is recorded LAST - after
// the claim and bearer commit - so lookup can only reveal durably tracked
// funds and, on an unnamed mint, an already-burned secret.
// Throws on any failure; the caller marks the entry failed and reports
// through deps.onError.
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry)
if (!entry.prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
@@ -114,3 +118,61 @@ export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice)
entry.preimage = preimage
}
}
// the named-mint half of settleAndClaim: the note lands at the wallet's
// own secret, so its APPEARANCE there is the settlement observation and no
// rotate follows. When the mint also serves the quote's verify, that
// answer's preimage is kept as the NIP-47 receipt (an ordinary payment
// proof on a named mint); a named mint without verify yields a settled
// lookup with no preimage - correct, just receipt-less.
const settleAndClaimNamed = async (
ctx: RequestContext,
entry: PendingInvoice,
): Promise<void> => {
const noteSecret = entry.prepared.noteSecret
if (!noteSecret) {
throw new Error('This mint was prepared in named mode without a note secret.')
}
const prepared = entry.prepared
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL
let preimage: string | null = null
if (prepared.verifyUrl) {
// the interruptible observation half: watch settlement on verify,
// then claim by secret below (fund-critical, uninterruptible)
const result = await pollVerifyUntilSettled(
prepared.verifyUrl,
{...claimPoll, signal: ctx.stopSignal},
ctx.deps.kit ?? {},
)
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.")
}
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage
}
// without a verify URL the claim poll IS the observation half, so it
// stays interruptible; after a verify-observed settlement the note is
// already there and this returns on the first check
const claimed = await claimFromSecret(
{...prepared, noteSecret},
prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal},
{
...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner,
},
)
const add: NewBearer[] = [claimed.note]
if (claimed.possibleCopy) add.push(claimed.possibleCopy)
await ctx.deps.applyChangeset(
{add, markSpent: []},
ctx.connection(),
'make_invoice',
ctx.assertOwner,
)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
// the rotate guard still matters here: the rescue path (a mint that
// took the hash but credited the PREIMAGE) claims through
// claimFromPreimage, and if its rotate failed the preimage is still a
// LIVE note secret - withheld exactly like the unnamed path
if (preimage && claimed.rotated) entry.preimage = preimage
}
+121
View File
@@ -0,0 +1,121 @@
import {describe, expect, it} from 'vitest'
import {hashK1, noteK1} from 'lnurlcash-kit'
import {claimMintedNote, prepareMint} from './ops'
import {requiredValue} from './test-utils'
import type {Mint} from './ops.testHarness'
import {mint, settleLastInvoice} from './ops.testHarness'
const HEX32 = /^[0-9a-f]{64}$/
// the invoice whose quote the wallet bound to its own output hash
const boundInvoice = (instance: Mint) =>
[...instance.state.invoices.values()].find((invoice) => invoice.boundTo)
describe('mint -> claim, named output (comment/mintToHash)', () => {
it('names the quote with the hash of a wallet-chosen secret', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
const noteSecret = requiredValue(prepared.noteSecret)
expect(noteSecret).toMatch(HEX32)
// the mint bound the quote to sha256 of the wallet's secret - the kit
// sends it as both comment and h, the mock read one of them
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret))
})
it('claims a named output at the wallet secret, without a rotate', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const noteSecret = requiredValue(prepared.noteSecret)
const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(claimed.note.amount).toBe(21_000)
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`)
expect(noteK1(claimed.note.url)).toBe(noteSecret)
// no rotate happened: the note still stands at the wallet's own
// secret, and the payment preimage keys nothing at all
expect(instance.state.noteState(noteSecret)).toBe('outstanding')
expect(instance.state.noteState(preimage)).toBeNull()
})
it('names the output through the mintToHash spelling too', async () => {
const instance = await mint({mintToHash: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('named')
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret)))
const preimage = await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 50,
maxWaitMs: 5_000,
})
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret)
expect(instance.state.noteState(preimage)).toBeNull()
})
it('keeps an unnamed mint on the preimage path', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
expect(prepared.mode).toBe('unnamed')
expect(prepared.noteSecret).toBeUndefined()
// the quote was NOT bound - nothing named an output
expect(boundInvoice(instance)).toBeUndefined()
})
it('times out cleanly when a named mint invoice is never paid', async () => {
const instance = await mint({commentAllowed: 64, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}),
).rejects.toThrow(/not confirmed/i)
})
it('rescues through the preimage when the mint credited it anyway and serves verify', async () => {
// the mintToHashIgnoresH adversary: claims the capability, binds
// nothing, credits the payment hash - the preimage is still the money
const instance = await mint({
commentAllowed: 64,
mintToHashIgnoresH: true,
verifyOnUnnamedMint: true,
testHooks: true,
})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
expect(instance.state.noteState(preimage)).toBe('outstanding')
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 300,
})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
// claimed through the preimage path and rotated off it, because that
// preimage rode the invoice
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('fails loudly when the mint credits the preimage and serves no verify', async () => {
const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
// verify is withheld on the unnamed fallback - there the preimage IS
// the note, so a compliant mint never serves it
expect(prepared.verifyUrl).toBeNull()
const preimage = await settleLastInvoice(instance)
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}),
).rejects.toThrow(/not confirmed/i)
// the money exists at the preimage but is unreachable without verify -
// the wallet must NOT report a success here
expect(instance.state.noteState(preimage)).toBe('outstanding')
})
})
+25 -8
View File
@@ -2,7 +2,6 @@ import {describe, expect, it} from 'vitest'
import {
NoteSpentError,
PendingNoteError,
buildNoteUrl,
fetchNoteInfo,
meltNote,
noteK1,
@@ -39,14 +38,32 @@ describe('mint -> claim -> rotate', () => {
const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000)
expect(prepared.grossMsat).toBeGreaterThan(100_000)
const preimage = await settleLastInvoice(instance)
const info = await fetchNoteInfo(
buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat),
)
expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000)
expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat)
await settleLastInvoice(instance)
const claimed = await claimMintedNote(prepared, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
})
// the service's maxWithdrawable is authoritative: the fee is withheld
// from the gross, so the note nets between the asked value and the gross
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000)
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat)
expect(claimed.rotated).toBe(true)
})
it('refuses a verify response naming a different invoice', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
await settleLastInvoice(instance)
// a verify answer that names any invoice but the requested one must
// stop the claim - the preimage it carries belongs to another payment
const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}`
await expect(
claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}),
claimMintedNote({...prepared, invoice: other}, {
intervalMs: 10,
intervalCapMs: 20,
maxWaitMs: 5_000,
}),
).rejects.toThrow(/different invoice/)
})
+90
View File
@@ -0,0 +1,90 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import {ensureExactAmount, receiveBearer} from './ops'
import {claimFromPreimage, prepareMint} from './ops/mint'
import {requiredValue} from './test-utils'
import {
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
settleLastInvoice,
} from './ops.testHarness'
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical
// repeat as an already-spent input, which is the only answer the wallet
// ever sees. Every mutation that carries fresh secrets must probe the
// would-be outputs before believing such a refusal: landed means the
// carried secrets are the only money left.
describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => {
const instance = await mint()
const k1 = secret('60')
const bearer = await makeBearer(instance, k1, 50_000)
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()})
// the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned')
expect(result.consumed).toHaveLength(1)
const partK1 = requiredValue(noteK1(result.note.url))
expect(partK1).not.toBe(k1)
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(partK1)).toBe('outstanding')
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(instance.state.noteState(changeK1)).toBe('outstanding')
})
it('rescues the combined note when the merge answer is a retry refusal', async () => {
const instance = await mint()
const a = await makeBearer(instance, secret('61'), 20_000)
const b = await makeBearer(instance, secret('62'), 5_000)
const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()})
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(secret('61'))).toBe('burned')
expect(instance.state.noteState(secret('62'))).toBe('burned')
const mergedK1 = requiredValue(noteK1(result.note.url))
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(mergedK1)).toBe('outstanding')
})
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint()
const senderK1 = secret('63')
instance.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(),
})
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(instance.state.noteState(senderK1)).toBe('burned')
const k1 = requiredValue(noteK1(received.note.url))
expect(k1).not.toBe(senderK1)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('still fails a genuine refusal - a note spent before the split', async () => {
const instance = await mint()
const k1 = secret('64')
const bearer = await makeBearer(instance, k1, 50_000)
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
).rejects.toThrow(/spent/i)
})
})
+2
View File
@@ -1,4 +1,6 @@
import './ops.carve.cases'
import './ops.mint-named.cases'
import './ops.mint-receive.cases'
import './ops.pay.cases'
import './ops.retry-refusal.cases'
import './ops.transfer.cases'
+19
View File
@@ -69,3 +69,22 @@ export const expectBurned = async (instance: Mint, k1: string): Promise<void> =>
}
expect(instance.state.noteState(k1)).toBe('burned')
}
// a fetch wrapper that answers every /w/cb request with the response to
// its SECOND, byte-identical send - the HTTP-stack GET retry a real client
// is subject to: the mint executes the first attempt and (with the mock's
// default retriedMutation: 'refuse', which is also lnurl-mint's behavior)
// refuses the repeat as an already-spent input, which is all the caller
// ever sees
export const retryingCbFetch = (): typeof fetch => {
const impl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) {
const first = await fetch(input, init)
await first.arrayBuffer()
return fetch(input, init)
}
return fetch(input, init)
}
return impl
}
+80 -1
View File
@@ -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')
})
})
+7 -5
View File
@@ -6,9 +6,11 @@
//
// Fund-critical invariants enforced across the flows (see the project plan):
// - rotate on every receive, and immediately after claiming a fresh mint
// (observer race: anyone who saw the unpaid invoice knows the payment
// hash, and the mint necessarily saw the preimage - the preimage IS the
// note secret)
// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice
// knows the payment hash, and the mint necessarily saw the preimage -
// the preimage IS the note secret). A NAMED mint (LUD-25 comment /
// mintToHash) credits the note to a wallet-chosen secret instead; that
// secret never rode an invoice, so no rotate follows its claim
// - a note's declared amount is a claim; the service's maxWithdrawable is
// authoritative
// - a melt's "OK" only means the payment is in flight; its verify URL (or
@@ -23,8 +25,8 @@
// 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
// source, mint + claim at target)
// ops/shared.ts - bounded polling, UncertainOutcomeError
export {UncertainOutcomeError} from './ops/shared'
export type {PollOptions} from './ops/shared'
+66 -3
View File
@@ -5,6 +5,7 @@
import {
AmbiguousMutationError,
mergeNotes,
newSecretsOf,
noteK1,
probeBurnedNote,
requireNoteK1,
@@ -15,7 +16,7 @@ import {
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, UncertainOutcomeError} from './shared'
import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared'
// the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input
@@ -151,7 +152,7 @@ export const ensureExactAmount = async (
changeSignature = parts.changeSignature
partVerified = true
} catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err
if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options)
@@ -183,6 +184,46 @@ export const ensureExactAmount = async (
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
} else {
// A classified refusal can still be a LANDED split: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// The kit attaches the fresh output secrets to every refusal - probe
// one output before deciding they are worthless.
const carried = newSecretsOf(err)
if (carried.length !== 2) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err // never landed - a plain refusal
if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume
// nothing, stop here
throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, carried[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, carried[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
// 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left
partK1 = carried[0]
changeK1 = carried[1]
rescued = true
}
}
const note: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat, partSignature),
@@ -261,7 +302,7 @@ const mergeAmbiguitySafe = async (
const merged = await mergeNotes(base.callback, k1s, options)
return {k1: merged.k1, signature: merged.signature, rescued: false}
} catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err
if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') {
@@ -281,4 +322,26 @@ const mergeAmbiguitySafe = async (
// 'gone': the burn landed - the carried secret is the only money left
return {k1: err.newSecrets[0], rescued: true}
}
// same retry-refusal rescue as the split path: the refusal may name a
// merge that already landed - probe the would-be combined note
const carried = newSecretsOf(err)
if (carried.length !== 1) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, carried[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
return {k1: carried[0], rescued: true}
}
}
+208 -26
View File
@@ -1,18 +1,41 @@
// Minting: receiving over Lightning. prepareMint resolves a mint and
// requests the invoice; paying it brings the note into existence;
// claimMintedNote watches the payment and converts the revealed preimage
// into a rotated, wallet-owned bearer note.
// claimMintedNote watches the payment and converts it into a wallet-owned
// bearer note.
//
// Two mint generations exist on the wire, and this module speaks both:
//
// - UNNAMED (old): the minted note's k1 IS the payment preimage. The
// claim takes the preimage from the LUD-21 verify answer and rotates
// immediately - every routing node and anyone who saw the invoice knew
// that secret.
// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the
// wallet names the note at quote time with comment = sha256(secret) of
// a secret it chose, and the note lands at THAT secret. The preimage
// opens nothing, so no observer race exists and no rotate follows the
// claim; the claim poll itself is the placement proof (see
// claimFromSecret).
//
// The mode is decided per mint from the payRequest's capability
// advertisement (commentAllowed >= 64 or mintToHash), never assumed.
import {
AmbiguousMutationError,
buildNoteUrl,
claimMintedNote as fetchMintClaim,
defaultRandomSecret,
fetchInvoiceVerification,
fetchMintAddress,
fetchNoteInfo,
fetchPayRequest,
grossUpForMintFee,
hashK1,
isPreimage,
lightningAddressUsername,
mintAddressUrl,
namesMintOutput,
newSecretsOf,
NoteSpentError,
probeBurnedNote,
requestInvoice,
resolveMintInput,
@@ -21,16 +44,26 @@ import {
serverOf,
withNewK1,
} from 'lnurlcash-kit'
import type {MintAddressInfo} from 'lnurlcash-kit'
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit'
import type {NewBearer} from '../types'
import {ceilMsatToSat} from '../units'
import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared'
export type PreparedMint = {
invoice: string
verifyUrl: string | null
// how this mint keys the note the invoice pays for: 'named' mints credit
// a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints
// credit the payment preimage
mode: 'named' | 'unnamed'
// named mode only: the wallet-chosen secret the minted note lands at.
// Drawn before the invoice is requested (its hash binds the quote) and
// carried in memory only - never persisted, never logged. Losing it
// after payment loses the note, but nothing here was ever recoverable
// after a crash: the invoice and verify URL die in the same memory
noteSecret?: string
// the net note value asked for - the claim cross-checks the service's
// authoritative maxWithdrawable against it
expectedNoteValueMsat: number
@@ -85,10 +118,25 @@ export const prepareMint = async (
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
throw new Error("Amount is outside this mint's sendable range.")
}
const invoice = await requestInvoice(info.callback, grossMsat, options)
// a mint that names its outputs takes comment = sha256(secret) with the
// quote; one that doesn't gets the byte-identical request this wallet
// always sent, and its note stays keyed by the payment preimage
const named = namesMintOutput(info)
let noteSecret: string | undefined
let h: string | undefined
if (named) {
noteSecret = defaultRandomSecret()
h = hashK1(noteSecret)
}
const invoice = await requestInvoice(
info.callback,
grossMsat,
h ? {...options, h} : options,
)
const prepared: PreparedMint = {
invoice: invoice.pr,
verifyUrl: invoice.verify ?? null,
mode: named ? 'named' : 'unnamed',
expectedNoteValueMsat: amountMsat,
grossMsat,
mintUrl: payUrl,
@@ -97,15 +145,18 @@ export const prepareMint = async (
username: lightningAddressUsername(payUrl),
nodeInfo,
}
if (noteSecret) prepared.noteSecret = noteSecret
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
return prepared
}
export type ClaimedNote = {
note: NewBearer
// false when the rotate after claim failed - the note is tracked either
// way (it IS money), but the preimage was transmitted and the mint
// necessarily knows it, so an unrotated note must be treated as exposed
// false when the note may be exposed to someone besides this wallet and
// the mint: an unnamed mint's fresh note whose rotate failed (its k1 was
// the payment preimage, transmitted and mint-known). A named mint's
// note always reports true - its secret never rode an invoice, so there
// is nothing to rotate away from
rotated: boolean
// set when the rotate's answer was lost and the probe couldn't tell: the
// possible rotated copy, to track unverified alongside `note`
@@ -113,18 +164,126 @@ export type ClaimedNote = {
rotationError?: string
}
// Polls the mint invoice's LUD-21 verify URL until the payment settles,
// then claims the note: the payment preimage IS the note secret. The claim
// itself (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.
// Polls a named mint output's informational GET until the note is there:
// 'minted' resolves the wait ('spent' resolves it too - surfaced for the
// caller to reject, never retried); 'unminted' and 'pending' keep it
// going. The poll IS the placement proof for a named output: the service
// only answers 'minted' for the secret it actually credited.
export const pollMintClaim = async (
withdrawLink: string,
noteSecret: string,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<MintClaim> =>
pollUntil(
async (fetchOptions) => {
const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions)
return claim.state === 'minted' || claim.state === 'spent' ? claim : null
},
'Minted note not confirmed',
poll,
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
}
// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's
// verify URL and invoice as rescue anchors for a mint that took the hash
// but credited the preimage anyway. PreparedMint satisfies this.
export type NamedClaimTarget = ClaimTarget & {
noteSecret: string
verifyUrl?: string | null
invoice?: string
}
// The named-mode claim: the note was credited to the wallet's own secret,
// which rode to the mint as a HASH inside the invoice request - the
// payment preimage opens nothing, so the claim needs no preimage and no
// rotate follows it. The rescue covers the mint that claimed the
// capability but credited the preimage (the kit's mintToHashIgnoresH
// adversary: the wallet was told no rotate is needed while the preimage
// is still the money): when that mint still serves the quote's verify, one
// check recovers the preimage and the note is claimed through the old
// path, WITH the rotate that path exists for. When it serves none there
// is nothing to rescue with - the error stays loud.
export const claimFromSecret = async (
target: NamedClaimTarget,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
assertFundOwner(options)
let claim: MintClaim
try {
claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options)
} catch (err) {
if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) {
try {
const result = await fetchInvoiceVerification(target.verifyUrl, options)
if (
result.settled &&
sameInvoice(result.pr, target.invoice) &&
result.preimage &&
isPreimage(result.preimage)
) {
return claimFromPreimage(target, result.preimage, options)
}
} catch {
// the rescue is best-effort - the original error stands
}
}
throw err
}
if (claim.state === 'spent') {
// a fresh wallet-chosen secret cannot already be spent - the service
// is lying about the note it just credited
throw new Error('The mint reports the freshly minted note as already spent.')
}
if (claim.k1 !== target.noteSecret) {
// belt and suspenders: the kit's claimMintedNote returns the queried
// secret by construction and fetchNoteInfo rejects a mismatched echo,
// so this cannot trip through the kit - if it ever does, building a
// note from the answer would track the wrong secret
throw new Error('The mint answered the claim for a different note secret.')
}
if (claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the minted note details.')
}
const note: NewBearer = {
url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat),
callback: claim.callback,
amount: claim.amountMsat,
verified: true,
}
if (target.mintPubkey) note.mintPubkey = target.mintPubkey
return {note, rotated: true}
}
// Polls until the minted note is claimable, then claims it. Unnamed mints:
// watch the LUD-21 verify URL for settlement, take the preimage (which IS
// the note secret there), and claim through claimFromPreimage with its
// mandatory rotate. Named mints: watch the note itself appear at the
// wallet's own secret (see claimFromSecret).
export const claimMintedNote = async (
prepared: PreparedMint,
poll: PollOptions = {},
options: FundOperationOptions = {},
): Promise<ClaimedNote> => {
if (prepared.mode === 'named') {
const noteSecret = prepared.noteSecret
if (!noteSecret) {
throw new Error('This mint was prepared in named mode without a note secret.')
}
return claimFromSecret({...prepared, noteSecret}, poll, options)
}
if (!prepared.verifyUrl) {
throw new Error(
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
@@ -144,17 +303,6 @@ export const claimMintedNote = async (
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
@@ -214,9 +362,43 @@ export const claimFromPreimage = async (
} else {
rotationError = err.message
}
} else {
// A classified refusal can still be a LANDED rotate: the callback is
// a GET and HTTP stacks retry GETs, so the service may have executed
// the first attempt and refused this one as an already-spent input.
// "Already spent" then PROVES the preimage note is burned - the
// carried fresh secret is the only money left, so probe it before
// believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1) {
const outcome = await probeMutationOutput(declaredUrl, carried[0], options)
if (outcome === 'live') {
// the rotate landed and this answer was its retried twin
url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
possibleCopy = {
url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
// the preimage note itself may be burned - it cannot stay verified
base.verified = false
rotationError = `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`
} else if (err instanceof NoteSpentError) {
// a genuine already-spent refusal with no live output: the note
// the service just described is burned - it cannot stay verified
base.verified = false
rotationError = err.message
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
}
}
const claimed: ClaimedNote = {note: {...base, url}, rotated}
if (possibleCopy) claimed.possibleCopy = possibleCopy
+37 -2
View File
@@ -9,6 +9,7 @@ import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
newSecretsOf,
probeBurnedNote,
withNewK1,
} from 'lnurlcash-kit'
@@ -16,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared'
import {assertFundOwner} from './shared'
import {assertFundOwner, probeMutationOutput} from './shared'
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note,
@@ -32,9 +33,43 @@ export const receiveBearer = async (
}
try {
assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note)
const rotatedUrl = await secureReceivedNote(note, options)
return {note: {...note, url: rotatedUrl}, rotated: true}
} catch (err) {
// A classified rotate refusal can still be a LANDED rotate: the
// callback is a GET and HTTP stacks retry GETs, so the service may
// have executed the first attempt and refused this one as an
// already-spent input. The refusal then proves the sender's copy is
// burned and the carried fresh secret is the only money left - probe
// it before believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
const outcome = await probeMutationOutput(note.url, carried[0], options)
if (outcome === 'live') {
return {
note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
rotated: true,
}
}
if (outcome === 'unknown') {
const possibleCopy: NewBearer = {
url: withNewK1(note.url, carried[0], note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
}
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
return {
// the sender's copy may be burned - it cannot stay verified
note: {...note, verified: false},
rotated: false,
possibleCopy,
rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`,
}
}
// 'absent': the refusal is genuine - fall through to the distinct
// handling below
}
// a definitive service state (dead/unknown/locked mid-melt) is not a
// rotation failure to warn about - it tells the holder what this note
// actually is, so it propagates distinctly
+61 -13
View File
@@ -2,8 +2,9 @@
// every flow that waits on a payment uses, and the uncertainty type a lost
// mutation answer surfaces as.
import {fetchInvoiceVerification} from 'lnurlcash-kit'
import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit'
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit'
import type {NewBearer} from '../types'
// a mutation's answer was lost AND the probe could not tell whether it
@@ -36,6 +37,32 @@ export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.()
}
// what probing a would-be mutation output can tell
export type OutputProbe = 'live' | 'absent' | 'unknown'
// A refused mutation can still have LANDED: the redeem callback is a GET
// and HTTP stacks retry GETs, so the service may have executed the first
// attempt and refused this one as an already-spent input (LUD-25 says a
// byte-identical retry SHOULD get the original success replayed; real
// mints refuse instead). The kit attaches the fresh output secrets to
// every service refusal (newSecretsOf) - so before believing a refusal,
// probe one would-be output: 'live' proves the mutation landed and the
// carried secrets are the only money left, 'absent' proves the refusal is
// genuine, 'unknown' keeps it genuinely ambiguous.
export const probeMutationOutput = async (
noteUrl: string,
secret: string,
options: LnurlcashOptions = {},
): Promise<OutputProbe> => {
try {
await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options)
return 'live'
} catch (err) {
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent'
return 'unknown'
}
}
export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number
@@ -76,16 +103,17 @@ const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
signal.addEventListener('abort', onAbort, {once: true})
})
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal -
// the next round tries again. Returns the settled VerifyResult; throws on
// budget exhaustion, or PollAbortedError when the caller's signal fires
// (a hung fetch is interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = async (
verifyUrl: string,
// polls `check` until it yields a value, with backoff, inside a total
// time budget. A single failed check isn't fatal - the next round tries
// again. Returns the first value; throws on budget exhaustion, or
// PollAbortedError when the caller's signal fires (a hung fetch is
// interrupted too: the signal is bound into the request).
export const pollUntil = async <T>(
check: (options: LnurlcashOptions) => Promise<T | null>,
exhausted: string,
poll: PollOptions,
options: LnurlcashOptions,
): Promise<VerifyResult> => {
): Promise<T> => {
const {intervalMs, intervalCapMs, maxWaitMs, signal} = {
...DEFAULT_POLL,
...poll,
@@ -105,8 +133,8 @@ export const pollVerifyUntilSettled = async (
while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError()
try {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
if (result.settled) return result
const result = await check(fetchOptions)
if (result !== null) return result
lastError = null
} catch (err) {
// the signal's own AbortError lands here on an interrupted fetch
@@ -118,7 +146,27 @@ export const pollVerifyUntilSettled = async (
delay = Math.min(delay * 2, intervalCapMs)
}
if (lastError instanceof Error) {
throw new Error(`Payment not confirmed: ${lastError.message}`)
throw new Error(`${exhausted}: ${lastError.message}`)
}
throw new Error('Payment not confirmed within the time budget.')
throw new Error(`${exhausted} within the time budget.`)
}
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal -
// the next round tries again. Returns the settled VerifyResult; throws on
// budget exhaustion, or PollAbortedError when the caller's signal fires
// (a hung fetch is interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = (
verifyUrl: string,
poll: PollOptions,
options: LnurlcashOptions,
): Promise<VerifyResult> =>
pollUntil(
async (fetchOptions) => {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
return result.settled ? result : null
},
'Payment not confirmed',
poll,
options,
)
+124 -12
View File
@@ -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,81 @@ 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.k1 !== noteSecret ||
claim.amountMsat === null ||
!claim.callback
) {
// 'spent' on a fresh wallet-chosen secret, a claim naming a
// secret other than the one polled (impossible through the kit,
// which returns the queried secret and rejects mismatched
// echoes - asserted anyway: building from it would track the
// wrong note), 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
+10 -4
View File
@@ -10,6 +10,7 @@ import {
NoteUnknownError,
PendingNoteError,
} from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from './types'
// shared by Scan and Paste: resolve whatever came in to a note URL, ask the
@@ -65,16 +66,21 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise<Ne
// k1 on the wire) still knows the old secret - a rotate burns it and mints
// a fresh one only this wallet knows. Returns the updated note URL. Throws
// when the service refuses (e.g. a plain LUD-03 withdraw link that doesn't
// speak lnurlcash) - the caller should warn, not fail the receive.
export const secureReceivedNote = async (note: {
// speak lnurlcash) - the caller should warn, not fail the receive. The
// error carries the fresh secret (kit's newSecretsOf) so the caller can
// rescue a rotate that landed despite its refusal.
export const secureReceivedNote = async (
note: {
url: string
callback: string
amount: number
}): Promise<string> => {
},
options: LnurlcashOptions = {},
): Promise<string> => {
const k1 = noteK1(note.url)
if (!k1 || !note.callback) {
throw new Error('Note has no callback to rotate against yet.')
}
const result = await rotateNote(note.callback, k1)
const result = await rotateNote(note.callback, k1, options)
return withNewK1(note.url, result.k1, note.amount, result.signature)
}
+2 -2
View File
@@ -213,11 +213,11 @@ describe('security policy characterization', () => {
})
describe('PUBLIC_MINTS', () => {
it('is the curated quick-start list, ported verbatim from lnurl-wallet', () => {
it('is the curated quick-start list, ported from lnurl-wallet', () => {
expect(PUBLIC_MINTS).toEqual([
'@mint.600.wtf',
'@lnurl.21mint.me',
'@mint.forgesworn.dev',
'@moneyer.dev',
'@lnurl.21linz.at',
'@minty.exe.xyz',
])
+3 -2
View File
@@ -107,11 +107,12 @@ export const mintAddressCacheInfo = (
// whether this wallet has ever used it). The bare "@domain" form rather
// than spelling out "mint@domain" - still resolves to the exact same
// address, just how these mints tend to actually display their own.
// Ported verbatim from lnurl-wallet/src/trustedMints.ts.
// Ported from lnurl-wallet/src/trustedMints.ts; diverges where the public
// mint landscape did (@mint.forgesworn.dev shut down, @moneyer.dev added).
export const PUBLIC_MINTS = [
'@mint.600.wtf',
'@lnurl.21mint.me',
'@mint.forgesworn.dev',
'@moneyer.dev',
'@lnurl.21linz.at',
'@minty.exe.xyz',
]