mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
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:
Generated
+8
-8
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user