mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(lnurlcash): claim mint outputs named by a wallet-chosen secret
lnurl-mint >= 0.4 refuses a mint quote without comment=sha256(secret) and keys the note by that secret instead of the payment preimage. prepareMint detects the capability per mint (commentAllowed >= 64 or mintToHash), binds the quote to a wallet-drawn secret, and claimMintedNote polls the note itself - no preimage, no rotate, since the secret never rode an invoice. Mints that never advertised the capability get the byte-identical old request and stay on the preimage path; a mint that took the hash but credited the preimage is caught by the claim poll and rescued through the quote's verify URL. The d.ts augments the conformance package's stale published declarations for its comment-naming flags.
This commit is contained in:
+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
|
// Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 /
|
||||||
// 0.1.1 artifacts. Two contracts the app's mint discovery relies on:
|
// 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
|
// 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
|
// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so
|
||||||
// typed field read undefined forever), and a payRequest withdraw link is
|
// the typed field read undefined forever); a payRequest withdraw link is
|
||||||
// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published
|
// legal in both its HTTPS and LUD-17 `lnurlw://` forms (the conformance
|
||||||
// conformance mock mint emits `lnurlw://` by default, so do NOT assume an
|
// mock emits either, selected by its withdrawLinkForm option, so do NOT
|
||||||
// HTTPS default anywhere in the receive path.
|
// 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 {afterEach, describe, expect, it} from 'vitest'
|
||||||
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
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 {claimMintedNote, prepareMint} from './ops'
|
||||||
import {mintAddressCacheInfo} from './trustedMints'
|
import {mintAddressCacheInfo} from './trustedMints'
|
||||||
@@ -77,6 +83,20 @@ const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof
|
|||||||
return impl
|
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', () => {
|
describe('mint-address wire contract', () => {
|
||||||
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
|
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
|
||||||
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
|
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
|
||||||
@@ -164,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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import './ops.carve.cases'
|
import './ops.carve.cases'
|
||||||
|
import './ops.mint-named.cases'
|
||||||
import './ops.mint-receive.cases'
|
import './ops.mint-receive.cases'
|
||||||
import './ops.pay.cases'
|
import './ops.pay.cases'
|
||||||
import './ops.transfer.cases'
|
import './ops.transfer.cases'
|
||||||
|
|||||||
+165
-26
@@ -1,18 +1,39 @@
|
|||||||
// Minting: receiving over Lightning. prepareMint resolves a mint and
|
// Minting: receiving over Lightning. prepareMint resolves a mint and
|
||||||
// requests the invoice; paying it brings the note into existence;
|
// requests the invoice; paying it brings the note into existence;
|
||||||
// claimMintedNote watches the payment and converts the revealed preimage
|
// claimMintedNote watches the payment and converts it into a wallet-owned
|
||||||
// into a rotated, wallet-owned bearer note.
|
// 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 {
|
import {
|
||||||
AmbiguousMutationError,
|
AmbiguousMutationError,
|
||||||
buildNoteUrl,
|
buildNoteUrl,
|
||||||
|
claimMintedNote as fetchMintClaim,
|
||||||
|
defaultRandomSecret,
|
||||||
|
fetchInvoiceVerification,
|
||||||
fetchMintAddress,
|
fetchMintAddress,
|
||||||
fetchNoteInfo,
|
fetchNoteInfo,
|
||||||
fetchPayRequest,
|
fetchPayRequest,
|
||||||
grossUpForMintFee,
|
grossUpForMintFee,
|
||||||
|
hashK1,
|
||||||
isPreimage,
|
isPreimage,
|
||||||
lightningAddressUsername,
|
lightningAddressUsername,
|
||||||
mintAddressUrl,
|
mintAddressUrl,
|
||||||
|
namesMintOutput,
|
||||||
probeBurnedNote,
|
probeBurnedNote,
|
||||||
requestInvoice,
|
requestInvoice,
|
||||||
resolveMintInput,
|
resolveMintInput,
|
||||||
@@ -21,16 +42,26 @@ import {
|
|||||||
serverOf,
|
serverOf,
|
||||||
withNewK1,
|
withNewK1,
|
||||||
} from 'lnurlcash-kit'
|
} from 'lnurlcash-kit'
|
||||||
import type {MintAddressInfo} from 'lnurlcash-kit'
|
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit'
|
||||||
import type {NewBearer} from '../types'
|
import type {NewBearer} from '../types'
|
||||||
import {ceilMsatToSat} from '../units'
|
import {ceilMsatToSat} from '../units'
|
||||||
import type {PollOptions} from './shared'
|
import type {PollOptions} from './shared'
|
||||||
import type {FundOperationOptions} from './shared'
|
import type {FundOperationOptions} from './shared'
|
||||||
import {assertFundOwner, pollVerifyUntilSettled} from './shared'
|
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled} from './shared'
|
||||||
|
|
||||||
export type PreparedMint = {
|
export type PreparedMint = {
|
||||||
invoice: string
|
invoice: string
|
||||||
verifyUrl: string | null
|
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
|
// the net note value asked for - the claim cross-checks the service's
|
||||||
// authoritative maxWithdrawable against it
|
// authoritative maxWithdrawable against it
|
||||||
expectedNoteValueMsat: number
|
expectedNoteValueMsat: number
|
||||||
@@ -85,10 +116,25 @@ export const prepareMint = async (
|
|||||||
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
|
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
|
||||||
throw new Error("Amount is outside this mint's sendable range.")
|
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 = {
|
const prepared: PreparedMint = {
|
||||||
invoice: invoice.pr,
|
invoice: invoice.pr,
|
||||||
verifyUrl: invoice.verify ?? null,
|
verifyUrl: invoice.verify ?? null,
|
||||||
|
mode: named ? 'named' : 'unnamed',
|
||||||
expectedNoteValueMsat: amountMsat,
|
expectedNoteValueMsat: amountMsat,
|
||||||
grossMsat,
|
grossMsat,
|
||||||
mintUrl: payUrl,
|
mintUrl: payUrl,
|
||||||
@@ -97,15 +143,18 @@ export const prepareMint = async (
|
|||||||
username: lightningAddressUsername(payUrl),
|
username: lightningAddressUsername(payUrl),
|
||||||
nodeInfo,
|
nodeInfo,
|
||||||
}
|
}
|
||||||
|
if (noteSecret) prepared.noteSecret = noteSecret
|
||||||
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
|
if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey
|
||||||
return prepared
|
return prepared
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClaimedNote = {
|
export type ClaimedNote = {
|
||||||
note: NewBearer
|
note: NewBearer
|
||||||
// false when the rotate after claim failed - the note is tracked either
|
// false when the note may be exposed to someone besides this wallet and
|
||||||
// way (it IS money), but the preimage was transmitted and the mint
|
// the mint: an unnamed mint's fresh note whose rotate failed (its k1 was
|
||||||
// necessarily knows it, so an unrotated note must be treated as exposed
|
// 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
|
rotated: boolean
|
||||||
// set when the rotate's answer was lost and the probe couldn't tell: the
|
// set when the rotate's answer was lost and the probe couldn't tell: the
|
||||||
// possible rotated copy, to track unverified alongside `note`
|
// possible rotated copy, to track unverified alongside `note`
|
||||||
@@ -113,18 +162,119 @@ export type ClaimedNote = {
|
|||||||
rotationError?: string
|
rotationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polls the mint invoice's LUD-21 verify URL until the payment settles,
|
// Polls a named mint output's informational GET until the note is there:
|
||||||
// then claims the note: the payment preimage IS the note secret. The claim
|
// 'minted' resolves the wait ('spent' resolves it too - surfaced for the
|
||||||
// itself (an informational GET) puts that secret on the wire, and the mint
|
// caller to reject, never retried); 'unminted' and 'pending' keep it
|
||||||
// has known it since it generated the invoice - so the fresh note is
|
// going. The poll IS the placement proof for a named output: the service
|
||||||
// rotated immediately and unconditionally (observer race: anyone who saw
|
// only answers 'minted' for the secret it actually credited.
|
||||||
// the unpaid invoice knows the payment hash), before anything else happens
|
export const pollMintClaim = async (
|
||||||
// with it.
|
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.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 (
|
export const claimMintedNote = async (
|
||||||
prepared: PreparedMint,
|
prepared: PreparedMint,
|
||||||
poll: PollOptions = {},
|
poll: PollOptions = {},
|
||||||
options: FundOperationOptions = {},
|
options: FundOperationOptions = {},
|
||||||
): Promise<ClaimedNote> => {
|
): 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) {
|
if (!prepared.verifyUrl) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
|
'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.',
|
||||||
@@ -144,17 +294,6 @@ export const claimMintedNote = async (
|
|||||||
return claimFromPreimage(prepared, preimage, options)
|
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 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 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
|
// the wire, and the mint has known it since it generated the invoice - so
|
||||||
|
|||||||
Reference in New Issue
Block a user