Repository navigation
feat: support LUD-25 named mint outputs (lnurl-mint v0.4.0) - #5
Conversation
… 0.4.0 The kit release line adds the LUD-25 output-naming protocol (comment/h on the pay callback, claim polling, merge batching); the mock mint now defaults to plain https withdraw links and serves the originally issued invoice on verify, which the two adapted tests pinned the old shape of.
pollVerifyUntilSettled keeps its exact behavior and messages, reimplemented on a generic pollUntil so flows that watch something other than a LUD-21 verify (a named mint output appearing) share the same backoff, budget and abort semantics.
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.
A named target needs no verify URL: the note appearing at the wallet's own secret is the settlement proof, and it is claimed without a rotate. The claim material carries the secret - and the unverified note at it - from the start, so a melt settling after the poll budget is never a lost note; the source-note oracle classification is unchanged.
An unknown-still-pending transfer to a named mint carries the note at the wallet's own secret in its claim material - persist it unverified like the settled-claim-failed path already does, instead of dropping it with the result object.
The named-mint branch claims by the wallet's secret and treats the verify preimage as an ordinary payment receipt - but keeps the rotated guard before handing it out, because the rescue path (a mint that credited the preimage despite the binding) claims through claimFromPreimage, where a failed rotate leaves that preimage a live note secret. possibleCopy is committed exactly like the unnamed path.
Named mints claim through the wallet's own secret, so a missing LUD-21 verify URL no longer blocks receiving; unnamed mints still require it - there the preimage it reveals IS the claim.
Reword the rotate-after-claim invariant for named outputs and fix the test-harness pointer (ops.testHarness.ts, not test-utils.ts).
There was a problem hiding this comment.
🟡 Changes recommended
Named-mode claim handling does not currently verify the mint’s returned k1 matches the wallet-requested secret, which can cause incorrect note construction and potential fund loss if a service misbehaves.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Updates the LNURLcash protocol engine and app flows to support LUD-25 “named mint outputs” required by lnurl-mint v0.4.0, while keeping backwards compatibility with unnamed (preimage-keyed) mints and adding adversarial/rescue handling.
Changes:
- Add dual-mode mint/claim logic (named-by-wallet-secret vs unnamed-by-preimage) and generalized polling utilities.
- Extend inter-mint transfer and NWC invoice settlement to support named mints (including rescue via verify/preimage where available).
- Upgrade lnurlcash-kit/conformance pins and add/adjust unit tests + wire-contract canaries for the new behavior.
File summaries
| File | Description |
|---|---|
| src/lnurlcash/ops/mint.ts | Implements named vs unnamed minting, adds named-claim polling and rescue logic. |
| src/lnurlcash/ops/transfer.ts | Adds named-target transfer claiming via secret (and verify-based rescue). |
| src/lnurlcash/ops/shared.ts | Introduces generic pollUntil and refactors verify polling onto it. |
| src/lnurlcash/ops.ts | Updates fund-safety invariants/docs for named vs unnamed rotate behavior. |
| src/lnurlcash/ops.transfer.cases.ts | Adds transfer coverage for named targets and rescue adversary. |
| src/lnurlcash/ops.test.ts | Includes the new named-mint test suite. |
| src/lnurlcash/ops.mint-receive.cases.ts | Adjusts claim-based assertions and adds invoice-mismatch verify test. |
| src/lnurlcash/ops.mint-named.cases.ts | New test suite for named minting, claim, and rescue/failure modes. |
| src/lnurlcash/nwc/invoices.ts | Adds named-mode settle+claim flow and receipt (preimage) handling rules. |
| src/lnurlcash/nwc.invoice-a.cases.ts | Adds NWC tests for named mint settle/receipt and rescue/rotate guard. |
| src/lnurlcash/mintContract.test.ts | Updates wire-contract assertions; adds canaries for comment/h output naming. |
| src/lnurlcash/conformance-mockmint.d.ts | Adds local typing augmentation for conformance mock mint options. |
| src/lnurlcash/AGENTS.md | Updates fund-critical invariants documentation for named outputs. |
| src/composables/useReceiveLightningDialog.ts | Allows named mints without verify URLs; still requires verify for unnamed. |
| src/composables/useMoveFundsPage.ts | Persists named-target claim material note unverified for late settlement recovery. |
| package.json | Bumps pinned lnurlcash-kit and lnurlcash-conformance versions. |
| package-lock.json | Updates lockfile for the new pinned dependency versions. |
Review details
- Files reviewed: 15/17 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| 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), |
There was a problem hiding this comment.
Addressed in 6bb07ff with an explicit claim.k1 === noteSecret assert before any note is built. For the record: the kit already guarantees this twice over — claimMintedNote returns the queried secret by construction (never the service's echo), and fetchNoteInfo throws ProtocolError when the withdrawRequest echoes a different k1 than queried (the conformance suite's echoWrongK1 case). So the new assert cannot trip through the kit as published; it is defense-in-depth so a future kit regression fails loudly instead of tracking a wrong secret.
| // proof - the target only credits it once the melt's payment landed | ||
| try { | ||
| const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options) | ||
| if (claim.state !== 'minted' || claim.amountMsat === null || !claim.callback) { |
There was a problem hiding this comment.
Addressed in 6bb07ff with an explicit claim.k1 === noteSecret assert before any note is built. For the record: the kit already guarantees this twice over — claimMintedNote returns the queried secret by construction (never the service's echo), and fetchNoteInfo throws ProtocolError when the withdrawRequest echoes a different k1 than queried (the conformance suite's echoWrongK1 case). So the new assert cannot trip through the kit as published; it is defense-in-depth so a future kit regression fails loudly instead of tracking a wrong secret.
Review feedback: bind the claim answer to the secret that was polled before building a note from it. The kit already guarantees this - claimMintedNote returns the queried secret by construction and fetchNoteInfo rejects a mismatched k1 echo - so the assert cannot trip through the kit (and is therefore not separately testable); it exists so a future kit regression fails loudly instead of tracking a wrong secret.
@mint.forgesworn.dev is shutting down; @moneyer.dev takes its place in the curated quick-start list.
…efusal The redeem callback is a GET and HTTP stacks retry GETs, so a mint can execute the first attempt and refuse the repeat as an already-spent input (LUD-25 says SHOULD replay; real mints refuse). The kit attaches the fresh output secrets to every refusal - probeMutationOutput tells a landed mutation from a genuine refusal. The harness wrapper reproduces the retry against the mock.
A split, merge or rotate whose answer is 'Invalid or already spent k1.' may be the refusal of a retried request that already LANDED - believing it strands the fresh outputs server-side and desyncs the wallet (the input is burned at the mint but still spendable locally, so every later operation fails). All three flows now probe a would-be output when a refusal carries fresh secrets: live outputs are adopted (or surfaced unverified when the probe can't tell), a genuine refusal still fails.
Why
lnurl-mint v0.4.0 (live on lnurl.21linz.at) made the LUD-25 output-naming change mandatory: a mint quote without
comment = hex(sha256(secret))is refused with no invoice issued, and the minted note is keyed by the wallet-chosen secret instead of the payment preimage. The wallet's kit 0.1.1 could not send that parameter, so minting against the upgraded mint was fully broken.What
ops/mint.ts): the mint's generation is detected per mint from the payRequest advertisement (commentAllowed >= 64ormintToHash). Named mints get a quote bound to a wallet-drawn secret (sent as bothcommentandh), and the claim polls the note itself — no preimage, no verify URL, and no rotate, since the secret never rode an invoice. Unnamed mints get the byte-identical request the wallet always sent (backwards compatible, pinned by new wire canaries inmintContract.test.ts).mintToHashIgnoresHadversary) is caught by the claim poll and recovered through the quote's verify URL, with the rotate that path exists for. If it serves no verify, the failure is loud rather than silent.ops/transfer.ts): named targets need no verify URL — the note's appearance at the wallet's secret is the settlement proof. The claim material carries the secret plus the unverified note from the start, so a melt settling after the poll budget is never lost; the source-note oracle classification is unchanged.nwc/invoices.ts): named mints claim by secret and hand out the verify preimage as an ordinary receipt — but therotatedguard stays, because the rescue path claims through the preimage flow where a failed rotate leaves that preimage live.Fund-safety notes
noteSecretlives in memory only, never persisted or logged — same ephemeral lifetime the invoice and verify URL always had.conformance-mockmint.d.tsaugments the conformance package's published 0.4.0 typings, which are stale relative to their own runtime (commentAllowed/commentRefusesMalformed/verifyOnUnnamedMintare implemented but undeclared) — worth reporting upstream.Verification
npx vitest run: 329/329 (16 new tests: named mint/claim/rescue/adversary, named transfers, named NWC, wire canaries)npx vue-tsc --noEmit: clean;eslint: clean