lnurlcash-conformance 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
4
4
  may add or tighten checks that a previously-passing mint now fails; pin an
5
5
  exact version if you gate CI on the grade.
6
6
 
7
+ ## 0.4.0 - 2026-08-26
8
+
9
+ **The mint-output naming check now reads the spelling LUD-25 actually
10
+ specifies.** The draft names the output with a LUD-12 `comment = hex(h)`,
11
+ advertised as a `commentAllowed` of at least 64; `mintToHash` is the
12
+ parameter form one mint shipped before the comment form was written. The
13
+ suite knew only the latter, so a mint naming notes exactly as the draft
14
+ describes graded as "not offered - a minted note's k1 is the invoice
15
+ preimage". That is the worst kind of wrong answer a conformance suite can
16
+ give: it tells a wallet author the safe mint is the unsafe one.
17
+
18
+ Both spellings are now read from the payRequest and the mint address
19
+ document, probed with a fresh hash each (a mint that binds output ids
20
+ uniquely would rightly refuse the second spelling for naming an output the
21
+ first just took), and reported as `named by comment` / `named by h`.
22
+
23
+ The two are deliberately not probed identically, because the draft's rules
24
+ for them are opposites:
25
+
26
+ - `h` is a parameter invented for naming, so a malformed one MUST be
27
+ refused before an invoice exists. Unchanged.
28
+ - `comment` is plain LUD-12 free text any wallet may send for unrelated
29
+ reasons, so a comment that is not a bare hash MUST fall back to crediting
30
+ `k1=P` rather than be refused, and the mint MUST NOT serve LUD-21 `verify`
31
+ on that fallback - there the preimage is not proof of payment, it is the
32
+ note. Both are now checked, the second as a failure.
33
+
34
+ Only `mintToHash` is expected in both documents. `commentAllowed` is a
35
+ payRequest field; the mint address document is a withdrawRequest, where a
36
+ LUD-12 comment has nowhere to go, so its absence there is no longer
37
+ reported as a disagreement.
38
+
39
+ **A rate-limited grade no longer accuses the mint.** Every mint quote
40
+ issues a real invoice on a real node, so a grader firing a dozen looks like
41
+ the abuse a limiter exists to stop - and probing two spellings nearly
42
+ doubled the count. HTTP 429 was indistinguishable from a spec refusal, so
43
+ the mint was reported as violating whatever check happened to be running
44
+ when the bucket ran dry. 429 is now waited out (honouring `Retry-After`)
45
+ and retried, and reported as an incomplete grade rather than a verdict if
46
+ it persists.
47
+
48
+ The mock mint gains `commentAllowed`, `commentRefusesMalformed` and
49
+ `verifyOnUnnamedMint` to cover all of this.
50
+
51
+ ## 0.3.0 - 2026-08-24
52
+
53
+ - Add an optional bound-mint settlement receipt for sealed signers. A
54
+ receipt-capable quote commits to `mint: {h, amount}` before payment; its
55
+ settled LUD-21 response repeats the invoice, output and exact net value and
56
+ adds the ordinary LUD-25 note signature. Absence remains compatible with
57
+ the current preimage-and-rotate flow.
58
+ - `vectors/mint-to-hash.json` now carries the valid quote, unsettled and
59
+ settled shapes plus wrong-output, wrong-amount, premature-signature and
60
+ invalid-signature cases. `docs/BOUND-MINT-RECEIPTS.md` contains candidate
61
+ normative LUD-25 text and the compatibility matrix.
62
+ - The mock mint gains `mintReceipt`; when enabled it publishes the
63
+ verification key before payment, commits the quote and signs only after
64
+ settlement. Self-check and self-grade verify the complete lifecycle.
65
+
7
66
  ## 0.2.3 - 2026-08-22
8
67
 
9
68
  - **New check: `keeps signatures off the informational endpoint`.** LUD-25
package/README.md CHANGED
@@ -60,6 +60,7 @@ for (const c of cases) {
60
60
  | `payment-request.json` | `lnurlcashreq1`: one holder asking another for value |
61
61
  | `settle-for-value.json` | the decision table a server works through to take a note as payment |
62
62
  | `retried-mutation.json` | what makes a repeated mutation a retry rather than a double-spend |
63
+ | `mint-to-hash.json` | wallet-chosen mint outputs and optional bound LUD-21 receipts |
63
64
  | `lifecycle.json` | behavioural requirements, as scenarios to drive |
64
65
  | `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
65
66
 
@@ -67,6 +68,9 @@ Regenerate with `npm run generate`; check them with `npm test`, which
67
68
  verifies every digest recomputes, every declared signature really does
68
69
  verify, and every fee expectation follows from the formula.
69
70
 
71
+ The upstreamable wire text and compatibility matrix for the optional receipt
72
+ are in [`docs/BOUND-MINT-RECEIPTS.md`](docs/BOUND-MINT-RECEIPTS.md).
73
+
70
74
  ## The mock mint
71
75
 
72
76
  ```bash
@@ -123,6 +127,7 @@ answered:
123
127
  | `--signWithPreviousKey` | issues every note under that old key while still advertising the new one: the mid-rotation state a mint passes through when the advertisement moves before the signer |
124
128
  | `--retriedMutation=replay` | answers a byte-identical repeat of a mutation with the original success instead of `already spent`. The default, `refuse`, is what this mock has always done |
125
129
  | `--mintToHash` | takes an optional `h` on the pay callback and credits the minted note there, so the payment preimage is not the money. Off by default, and then `h` is not read at all |
130
+ | `--mintReceipt` | with `--mintToHash`, adds the optional quote commitment and signed LUD-21 settlement receipt |
126
131
  | `--mintToHashAdvertisedOn=quote` | narrows which of the three places claim it (`payRequest`, `mintAddress`, `quote`); all three by default. Changes only what is claimed, never what the mint does |
127
132
 
128
133
  As a library, for your own test suite:
package/llms.txt CHANGED
@@ -54,6 +54,8 @@ payRequest, the same on the mint address document, and the same echoed on
54
54
  the pay callback's own response when THAT quote was bound.
55
55
  mintToHashAdvertisedOn=payRequest,mintAddress,quote narrows which of the
56
56
  three claim it (all three by default; it changes only what is claimed).
57
+ mintReceipt=true adds mint:{h,amount} to an honestly bound quote and adds
58
+ sig only to its settled LUD-21 response; it requires mintToHash.
57
59
  Misbehaviours, each needing mintToHash as well: mintToHashAcceptsMalformedH,
58
60
  mintToHashAcceptsUsedH, mintToHashIgnoresH.
59
61
 
@@ -133,6 +135,14 @@ withdraw callback gives, so no oracle appears. A WALLET MUST persist its
133
135
  secret BEFORE asking for the invoice. Purely additive: without h, nothing
134
136
  changes. Table in mint-to-hash.json.
135
137
 
138
+ For a sealed signer, mint-to-hash may add a bound settlement receipt. The
139
+ quote carries mint:{h,amount}, where amount is exact net msat and no sig is
140
+ allowed. Settled LUD-21 repeats the same h/amount and adds the ordinary
141
+ LUD-25 signature; the wallet matches pr/h/amount and verifies the signature
142
+ before PENDING -> CONFIRMED. Absence of mint is compatible and falls back to
143
+ the legacy preimage/import/rotate flow. Full proposed wire text and matrix:
144
+ docs/BOUND-MINT-RECEIPTS.md.
145
+
136
146
  ## The retried mutation
137
147
 
138
148
  Every mutation is a GET, and HTTP stacks retry a GET on a dropped
@@ -157,6 +157,12 @@ export interface MockMintOptions {
157
157
  * two. A wallet still sends lowercase.
158
158
  */
159
159
  mintToHash?: boolean
160
+ /**
161
+ * Emit the optional mint:{h,amount} quote commitment and add the normal
162
+ * note signature on settled LUD-21 verification. Requires mintToHash,
163
+ * verify and signatures. Off by default for baseline compatibility.
164
+ */
165
+ mintReceipt?: boolean
160
166
  /**
161
167
  * non-compliant, and only reachable with mintToHash on: issue an
162
168
  * invoice for an `h` that is not 64 lowercase hex, so a wallet pays for
@@ -220,6 +226,8 @@ export interface MockMintState {
220
226
  settled: boolean
221
227
  /** the output id this quote was bound to, when the wallet named one */
222
228
  boundTo?: string
229
+ /** the exact invoice returned on the quote, for LUD-21 binding */
230
+ pr?: string
223
231
  }
224
232
  >
225
233
  pubkey: string
@@ -187,6 +187,10 @@ const DEFAULTS = {
187
187
  // which is the one that matters at the moment money moves: the other
188
188
  // two can be cached or stale.
189
189
  mintToHash: false,
190
+ // Add the optional bound LUD-21 receipt to an honestly bound quote and
191
+ // its verify response. Requires mintToHash, verify and signatures; off
192
+ // by default so the baseline mock remains the current LUD-25 wire.
193
+ mintReceipt: false,
190
194
  // non-compliant, and only reachable with mintToHash on: issue an
191
195
  // invoice for an `h` that is not 64 lowercase hex, so a wallet pays for
192
196
  // a quote this mint was always going to refuse
@@ -207,7 +211,30 @@ const DEFAULTS = {
207
211
  // 'payRequest,mintAddress' is the one that binds without confirming at
208
212
  // the moment money moves. Read only when mintToHash is on. Accepts an
209
213
  // array or a comma-separated string, so the CLI can pass one.
210
- mintToHashAdvertisedOn: undefined
214
+ mintToHashAdvertisedOn: undefined,
215
+
216
+ // The SAME capability in the spelling LUD-25 actually specifies:
217
+ // `comment = hex(sha256(secret))` (LUD-12), advertised as a
218
+ // `commentAllowed` of at least 64. A number advertises that many
219
+ // characters and reads `comment` as the output name; false says
220
+ // nothing and reads nothing. Independent of mintToHash - a mint may
221
+ // ship either, both, or neither.
222
+ //
223
+ // The two spellings are not symmetric, and that is the whole reason
224
+ // both exist here. `h` is a parameter invented for naming, so a
225
+ // malformed one MUST be refused before an invoice exists. `comment` is
226
+ // plain LUD-12 free text any wallet may send for unrelated reasons, so
227
+ // LUD-25 requires the opposite: fall back to crediting k1=P, never
228
+ // refuse - and MUST NOT serve verify on that fallback, where P is not
229
+ // proof of payment but the note itself.
230
+ commentAllowed: false,
231
+ // non-compliant, commentAllowed on: refuse a comment that is not a
232
+ // bare 32-byte hex hash instead of falling back, so an ordinary LUD-12
233
+ // wallet cannot pay this mint at all
234
+ commentRefusesMalformed: false,
235
+ // non-compliant, commentAllowed on: serve LUD-21 verify even on the
236
+ // no-comment fallback, where the preimage it hands out IS the note
237
+ verifyOnUnnamedMint: false
211
238
  }
212
239
 
213
240
  export const createMockMint = async (options = {}) => {
@@ -461,7 +488,19 @@ export const createMockMint = async (options = {}) => {
461
488
  // one a wallet should decide from. Spread in last and only when
462
489
  // the option is on, so a mock started with no options answers
463
490
  // exactly what it always answered.
464
- ...(mintToHashPlaces.has('payRequest') ? {mintToHash: true} : {})
491
+ ...(mintToHashPlaces.has('payRequest') ? {mintToHash: true} : {}),
492
+ // LUD-25: "A mint payLink intending to support this SHOULD
493
+ // advertise a commentAllowed of at least 64". A payRequest field
494
+ // only - the mint address document is a withdrawRequest, where a
495
+ // LUD-12 comment has nowhere to go.
496
+ ...(opts.commentAllowed ? {commentAllowed: opts.commentAllowed} : {}),
497
+ // A receipt verifier needs the signing key before payment. The
498
+ // baseline mock remains byte-for-byte unchanged when receipts are
499
+ // off; a real node-key signer can alternatively be recovered from
500
+ // the BOLT-11 invoice itself.
501
+ ...(opts.mintReceipt && opts.verify && opts.signatures
502
+ ? {mintPubkey: pubkey}
503
+ : {})
465
504
  })
466
505
  }
467
506
 
@@ -626,20 +665,67 @@ export const createMockMint = async (options = {}) => {
626
665
  }
627
666
  }
628
667
 
668
+ // The LUD-25 spelling. Read after `h` so a mock offering both lets
669
+ // an explicit comment name the output, and deliberately NOT an
670
+ // else-branch: a mint may ship either spelling or both.
671
+ let namedByComment = false
672
+ if (opts.commentAllowed) {
673
+ const sent = q.get('comment')
674
+ if (sent !== null && sent !== '') {
675
+ const wellFormed = /^[0-9a-f]{64}$/i.test(sent)
676
+ if (wellFormed) {
677
+ const h = sent.toLowerCase()
678
+ // Same collision rule the `h` spelling gets: an id already
679
+ // spoken for must never be minted over, whichever parameter
680
+ // named it.
681
+ if (outputIdInUse(h) && !opts.mintToHashAcceptsUsedH) {
682
+ return fail('Invalid or already spent k1.')
683
+ }
684
+ if (!opts.mintToHashIgnoresH) {
685
+ boundTo = h
686
+ namedByComment = true
687
+ }
688
+ } else if (opts.commentRefusesMalformed) {
689
+ // The non-compliant branch. LUD-25 says a comment that is not
690
+ // a bare hash MUST fall back, because plain LUD-12 comments
691
+ // are free text: refusing one turns an ordinary "thanks!"
692
+ // into a mint that cannot be paid.
693
+ return fail('Invalid comment.')
694
+ }
695
+ }
696
+ }
697
+
629
698
  const preimage = bytesToHex(randomBytes(32))
630
699
  const paymentHash = noteId(preimage)
631
- const invoice = {amountMsat: net, preimage, settled: false}
700
+ const pr = fakeInvoice(amount, preimage)
701
+ const invoice = {amountMsat: net, preimage, settled: false, pr}
632
702
  if (boundTo) {
633
703
  invoice.boundTo = boundTo
634
704
  boundOutputs.set(boundTo, paymentHash)
635
705
  }
636
706
  invoices.set(paymentHash, invoice)
637
- const body = {pr: fakeInvoice(amount, preimage), disposable: false}
638
- if (opts.verify) body.verify = `${origin}/verify/${paymentHash}`
707
+ const body = {pr, disposable: false}
708
+ // LUD-25 gates verify on whether the note was named: in the
709
+ // no-comment fallback the preimage verify would hand out IS the
710
+ // note's entire bearer secret, so a mint offering the comment
711
+ // spelling must withhold verify from quotes that used it. A mock
712
+ // that never heard of the comment spelling keeps answering exactly
713
+ // as it always did.
714
+ const verifySafe = !opts.commentAllowed || namedByComment || opts.verifyOnUnnamedMint
715
+ if (opts.verify && verifySafe) body.verify = `${origin}/verify/${paymentHash}`
639
716
  // Appended last, and only when the quote really was bound, so a
640
717
  // mock that was never told about any of this answers byte for byte
641
718
  // what it always answered.
642
719
  if (echoBound) body.mintToHash = true
720
+ if (
721
+ echoBound &&
722
+ boundTo &&
723
+ opts.mintReceipt &&
724
+ opts.verify &&
725
+ opts.signatures
726
+ ) {
727
+ body.mint = {h: boundTo, amount: net}
728
+ }
643
729
  return send(body)
644
730
  }
645
731
 
@@ -652,7 +738,7 @@ export const createMockMint = async (options = {}) => {
652
738
  }
653
739
  const invoice = invoices.get(verifyMatch[1].toLowerCase())
654
740
  if (!invoice) return fail('Unknown payment hash.')
655
- return send({
741
+ const body = {
656
742
  status: 'OK',
657
743
  settled: invoice.settled,
658
744
  // the preimage IS the bearer secret here - a real SERVICE should
@@ -661,8 +747,18 @@ export const createMockMint = async (options = {}) => {
661
747
  // bound with its own h: that note is credited elsewhere, so the
662
748
  // preimage is an ordinary payment proof and leaks nothing.
663
749
  preimage: invoice.settled || opts.verifyLeaksEarly ? invoice.preimage : null,
664
- pr: fakeInvoice(invoice.amountMsat, invoice.preimage)
665
- })
750
+ pr: invoice.pr ?? fakeInvoice(invoice.amountMsat, invoice.preimage)
751
+ }
752
+ if (invoice.boundTo && opts.mintReceipt && opts.signatures) {
753
+ body.mint = {
754
+ h: invoice.boundTo,
755
+ amount: invoice.amountMsat,
756
+ ...(invoice.settled
757
+ ? {sig: sign(invoice.boundTo, invoice.amountMsat)}
758
+ : {})
759
+ }
760
+ }
761
+ return send(body)
666
762
  }
667
763
 
668
764
  // ---- LUD-03 informational GET ----
@@ -776,7 +872,8 @@ export const createMockMint = async (options = {}) => {
776
872
  invoices.set(paymentHash, {
777
873
  amountMsat: note.amountMsat,
778
874
  preimage: meltPreimage,
779
- settled: false
875
+ settled: false,
876
+ pr
780
877
  })
781
878
  }
782
879
  if (!opts.meltNeverSettles) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-conformance",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Language-neutral conformance vectors, an adversarial mock mint, and a grader for LNURLcash (LUD-25) implementations",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",
package/runner/index.mjs CHANGED
@@ -89,7 +89,17 @@ const isNpub = value => {
89
89
  }
90
90
  }
91
91
 
92
- const get = async (url, timeoutMs = 15_000) => {
92
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
93
+
94
+ // A mint may rate limit, and the good ones do: every mint quote issues a
95
+ // real invoice on a real node, so a grader firing a dozen of them looks
96
+ // exactly like the abuse the limiter exists to stop. An HTTP 429 is the
97
+ // grade failing to complete, NOT a verdict on the mint, so it is waited
98
+ // out (honouring Retry-After) and retried. Without this the mint is
99
+ // accused of whatever the check happened to be probing when the bucket
100
+ // ran dry - a conforming mint graded as broken, which is worse than no
101
+ // grade at all.
102
+ const get = async (url, timeoutMs = 15_000, retriesLeft = 2) => {
93
103
  const res = await fetch(url.toString(), {signal: AbortSignal.timeout(timeoutMs)})
94
104
  const text = await res.text()
95
105
  let body
@@ -98,6 +108,17 @@ const get = async (url, timeoutMs = 15_000) => {
98
108
  } catch {
99
109
  throw new Error(`response was not JSON: ${text.slice(0, 120)}`)
100
110
  }
111
+ if (res.status === 429) {
112
+ if (retriesLeft > 0) {
113
+ const after = Number(res.headers.get('retry-after'))
114
+ const waitMs = Math.min(Number.isFinite(after) && after > 0 ? after * 1000 : 10_000, 30_000)
115
+ await sleep(waitMs + 250)
116
+ return get(url, timeoutMs, retriesLeft - 1)
117
+ }
118
+ throw soft(
119
+ `the mint rate limited this grade (HTTP 429${body?.reason ? `: ${body.reason}` : ''}) - space the run out and grade again; nothing here is a verdict on the mint`
120
+ )
121
+ }
101
122
  return body
102
123
  }
103
124
 
@@ -449,119 +470,186 @@ export const gradeMint = async (payUrl, report) => {
449
470
  return detail
450
471
  })
451
472
 
452
- // Naming the note you are buying. Optional, outside LUD-25, and soft in
453
- // the direction that matters: a mint that says nothing anywhere and
454
- // ignores `h` mints exactly what the draft describes, which is every
455
- // mint today and not a defect. What is graded is a mint that CLAIMS the
473
+ // Naming the note you are buying. No longer outside LUD-25: the draft
474
+ // spells it `comment = hex(sha256(secret))` (Protecting a freshly minted
475
+ // note from a preimage race), advertised as a `commentAllowed` of at
476
+ // least 64 - enough for a hex-encoded 32-byte hash. `mintToHash` is the
477
+ // parameter form one mint shipped before the comment form was written.
478
+ // Both are read here: a suite that knows only one spelling reports a
479
+ // mint that names notes perfectly well in the other as not offering it
480
+ // at all, which is worse than silence - it tells a wallet author the
481
+ // safe mint is the unsafe one.
482
+ //
483
+ // Soft in the direction that matters: a mint that says nothing anywhere
484
+ // and names nothing mints exactly what the draft's fallback describes,
485
+ // which is not a defect. What is graded is a mint that CLAIMS the
456
486
  // capability, because a wallet then stops rotating on sight and trusts
457
487
  // the mint to bind the note.
458
488
  //
459
- // The claim lives in three places and they say different things. The
460
- // payRequest's `mintToHash: true` means "I accept an h on my pay
461
- // callback", and since every mint publishes a payRequest while the mint
462
- // address document is experimental, that is the one to decide from. The
463
- // mint address document repeats it, as corroboration. The pay
464
- // callback's own response echoes it when THAT quote was bound, which is
465
- // the one that matters at the moment money moves: the other two can be
466
- // cached or stale. Anything that is not exactly the boolean true is no.
467
- await report.check('accepts an output hash on the mint quote (mintToHash, optional)', async () => {
489
+ // The claim lives in three places. The payRequest is the one to decide
490
+ // from, since every mint publishes one while the mint address document
491
+ // is experimental; the mint address repeats it as corroboration; and the
492
+ // pay callback's own response echoes `mintToHash` when THAT quote was
493
+ // bound. LUD-25 defines no echo for the comment spelling, so a missing
494
+ // echo is only held against a mint claiming `mintToHash`.
495
+ //
496
+ // The two spellings are NOT symmetric, and that is why this cannot be
497
+ // one probe with two parameter names:
498
+ //
499
+ // `h` is a parameter invented for exactly this purpose, so a malformed
500
+ // one is a wallet error and MUST be refused before an invoice exists.
501
+ //
502
+ // `comment` is plain LUD-12 free text that any wallet may send for
503
+ // unrelated reasons, so LUD-25 requires the opposite (line 80 of the
504
+ // draft): a comment that is not a bare hex-encoded 32-byte hash MUST
505
+ // fall back to crediting the note as k1=P, never be refused - and on
506
+ // that fallback the mint MUST NOT serve LUD-21 verify, because there
507
+ // P is not proof of payment, it is the entire note.
508
+ await report.check('accepts a named output on the mint quote (LUD-25 comment / mintToHash, optional)', async () => {
468
509
  const amount = Math.max(pay.minSendable, 1000)
469
- const quoteAt = async (h, msat = amount) => {
510
+
511
+ // LUD-25: "A mint payLink intending to support this SHOULD advertise a
512
+ // commentAllowed of at least 64". Anything shorter cannot carry the
513
+ // hash at all, so it is not this capability whatever else it is.
514
+ const spellingsOf = source => {
515
+ const found = []
516
+ if (source?.mintToHash === true) found.push('h')
517
+ if (Number.isFinite(source?.commentAllowed) && source.commentAllowed >= 64) found.push('comment')
518
+ return found
519
+ }
520
+ const quoteAt = async (spelling, value, msat = amount) => {
470
521
  const url = new URL(pay.callback)
471
522
  url.searchParams.set('amount', String(msat))
472
- url.searchParams.set('h', h)
523
+ url.searchParams.set(spelling, value)
473
524
  return get(url)
474
525
  }
526
+ const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
527
+
528
+ const advertised = spellingsOf(pay)
529
+ const corroborated = spellingsOf(mintAddress)
530
+ const claimed = [...new Set([...advertised, ...corroborated])]
475
531
 
476
532
  // Asked of every mint, advertisement or not: a mint that echoes the
477
533
  // capability without publishing it is still claiming it, and a wallet
478
534
  // reading the echo would believe it. Nothing pays the invoice that
479
535
  // comes back, so this is read-only - an unpaid quote costs a mint an
480
536
  // invoice and nothing else.
481
- const secret = bytesToHex(randomBytes(32))
482
- const h = noteId(secret)
483
- const bound = await quoteAt(h)
484
- const advertised = pay.mintToHash === true
485
- const corroborated = mintAddress?.mintToHash === true
486
- const echoed = bound.mintToHash === true
487
-
488
- if (!advertised && !corroborated && !echoed) {
537
+ // A fresh secret per spelling, never one shared between them: a mint
538
+ // that accepts both and binds an output id uniquely - as it should,
539
+ // and as the repeat probe below asserts - would rightly refuse the
540
+ // second spelling for naming an output the first just took.
541
+ const probed = claimed.length > 0 ? claimed : ['h']
542
+ const named = Object.fromEntries(
543
+ probed.map(spelling => {
544
+ const secret = bytesToHex(randomBytes(32))
545
+ return [spelling, {secret, h: noteId(secret)}]
546
+ })
547
+ )
548
+ const bound = {}
549
+ for (const spelling of probed) bound[spelling] = await quoteAt(spelling, named[spelling].h)
550
+ const echoedIn = probed.filter(spelling => bound[spelling].mintToHash === true)
551
+
552
+ if (claimed.length === 0 && echoedIn.length === 0) {
553
+ const refused = probed.map(spelling => bound[spelling]).find(body => body.status === 'ERROR')
489
554
  throw soft(
490
- bound.status === 'ERROR'
491
- ? `not offered, and an h on the quote was refused outright: ${bound.reason}`
555
+ refused
556
+ ? `not offered, and a named output was refused outright: ${refused.reason}`
492
557
  : 'not offered - a minted note\'s k1 is the invoice preimage, which every routing node on the payment path learns, so a wallet must claim and rotate the instant it settles'
493
558
  )
494
559
  }
495
560
 
496
561
  const problems = []
497
562
  const claimedBy = [
498
- advertised && 'the payRequest',
499
- corroborated && 'the mint address',
500
- echoed && 'the quote itself'
563
+ advertised.length > 0 && `the payRequest (${advertised.join(', ')})`,
564
+ corroborated.length > 0 && `the mint address (${corroborated.join(', ')})`,
565
+ echoedIn.length > 0 && 'the quote itself'
501
566
  ].filter(Boolean)
502
567
 
503
- assert(
504
- bound.status !== 'ERROR',
505
- `claims mintToHash (${claimedBy.join(', ')}) and refused a well-formed h: ${bound.reason}`
506
- )
507
- assert(typeof bound.pr === 'string', 'no pr in the response')
508
- const invoiced = invoiceAmountMsat(bound.pr)
509
- if (invoiced !== null) {
510
- assert(invoiced === amount, `asked for ${amount} msat, invoiced ${invoiced} msat`)
568
+ for (const spelling of probed) {
569
+ const body = bound[spelling]
570
+ assert(
571
+ body.status !== 'ERROR',
572
+ `claims this capability (${claimedBy.join(', ')}) and refused a well-formed hash sent as ${spelt(spelling)}: ${body.reason}`
573
+ )
574
+ assert(typeof body.pr === 'string', `no pr in the response to ${spelt(spelling)}`)
575
+ const invoiced = invoiceAmountMsat(body.pr)
576
+ if (invoiced !== null) {
577
+ assert(invoiced === amount, `asked for ${amount} msat, invoiced ${invoiced} msat`)
578
+ }
511
579
  }
512
580
 
513
581
  // A quote is not a note. Crediting one before its invoice settles
514
582
  // would hand out money for nothing.
515
583
  const withdrawUrl = pay.withdrawLink ? fromLud17(pay.withdrawLink) : null
516
584
  if (withdrawUrl) {
517
- const probe = new URL(withdrawUrl)
518
- probe.searchParams.set('k1', secret)
519
- const early = await get(probe)
520
- assert(
521
- early.status === 'ERROR',
522
- 'the note exists before anything paid for it - a quote is not a note'
523
- )
585
+ for (const spelling of probed) {
586
+ const probe = new URL(withdrawUrl)
587
+ probe.searchParams.set('k1', named[spelling].secret)
588
+ const early = await get(probe)
589
+ assert(
590
+ early.status === 'ERROR',
591
+ 'the note exists before anything paid for it - a quote is not a note'
592
+ )
593
+ }
524
594
  }
525
595
 
526
- // A malformed h must be refused BEFORE an invoice exists. A wallet
527
- // that pays a quote the mint was always going to reject has bought
528
- // nothing, and the mint keeps the sats.
529
- //
530
596
  // Malformed means not 32 bytes of hex, in any casing. Upper case is a
531
597
  // spelling, not a defect, and it is deliberately not probed: a WALLET
532
598
  // MUST send lowercase and every client here does, a SERVICE SHOULD
533
599
  // normalise before comparing, and one that refuses upper case outright
534
600
  // is strict rather than wrong - the wallet learns before it pays.
535
- for (const [what, value] of [
601
+ const malformed = [
536
602
  ['not hex', 'z'.repeat(64)],
537
603
  ['a character short', '0'.repeat(63)],
538
604
  ['a character long', '0'.repeat(65)],
539
605
  ['empty', '']
540
- ]) {
541
- const body = await quoteAt(value)
542
- assert(
543
- body.status === 'ERROR' && !body.pr,
544
- `issued an invoice for an h that is ${what} - a wallet would pay for a quote this mint cannot honour`
545
- )
606
+ ]
607
+
608
+ for (const spelling of probed) {
609
+ for (const [what, value] of malformed) {
610
+ const body = await quoteAt(spelling, value)
611
+ if (spelling === 'h') {
612
+ // A wallet that pays a quote the mint was always going to reject
613
+ // has bought nothing, and the mint keeps the sats.
614
+ assert(
615
+ body.status === 'ERROR' && !body.pr,
616
+ `issued an invoice for an h that is ${what} - a wallet would pay for a quote this mint cannot honour`
617
+ )
618
+ continue
619
+ }
620
+ // The comment spelling, where the draft says the opposite.
621
+ assert(
622
+ body.status !== 'ERROR',
623
+ `refused a comment that is ${what} (${body.reason}) - LUD-25 requires the no-name fallback here, because plain LUD-12 comments are free text and any wallet may send one`
624
+ )
625
+ assert(
626
+ !body.verify,
627
+ `served a LUD-21 verify URL on a quote whose comment is ${what}, which the draft credits as k1=P - verify then hands the note itself to anyone holding the invoice, not merely proof that it was paid`
628
+ )
629
+ }
546
630
  }
547
631
 
548
- // The three claims must agree. None of these disagreements loses
549
- // anyone money on its own - a wallet reading a missing field as false
550
- // falls back to the preimage flow, which is safe - so each is named
551
- // rather than failed. Whether the mint really binds is the one thing
552
- // this check cannot see, because that needs a settlement: it is
553
- // graded separately, and failed rather than warned.
554
- if (!echoed) {
632
+ // The claims must agree. None of these disagreements loses anyone
633
+ // money on its own - a wallet reading a missing field as false falls
634
+ // back to the preimage flow, which is safe - so each is named rather
635
+ // than failed. Whether the mint really binds is the one thing this
636
+ // check cannot see, because that needs a settlement: it is graded
637
+ // separately, and failed rather than warned.
638
+ if (advertised.includes('h') && echoedIn.length === 0) {
555
639
  problems.push(
556
640
  'bound quotes carry no mintToHash in the response, so a wallet cannot confirm at the one moment it is worth confirming, and falls back to racing the preimage'
557
641
  )
558
642
  }
559
- if (echoed && !advertised) {
643
+ if (echoedIn.length > 0 && advertised.length === 0) {
560
644
  problems.push(
561
645
  'echoes mintToHash on a quote but does not advertise it on the payRequest, which is the endpoint every mint publishes and the one a wallet decides from'
562
646
  )
563
647
  }
564
- if (advertised && mintAddress && !corroborated) {
648
+ // Only the `mintToHash` spelling belongs in both documents. LUD-25 asks
649
+ // a mint *payLink* to advertise `commentAllowed`, and the mint address
650
+ // document is a withdrawRequest, where a LUD-12 comment has nowhere to
651
+ // go - its absence there is correct, not a disagreement.
652
+ if (advertised.includes('h') && mintAddress && !corroborated.includes('h')) {
565
653
  problems.push('the payRequest advertises mintToHash and the mint address document does not')
566
654
  }
567
655
 
@@ -573,15 +661,17 @@ export const gradeMint = async (payUrl, report) => {
573
661
  // rule the withdraw callback already enforces.
574
662
  const otherAmount = Math.min(pay.maxSendable, amount * 2)
575
663
  if (otherAmount !== amount) {
576
- const twice = await quoteAt(h, otherAmount)
577
- if (twice.status !== 'ERROR') {
578
- problems.push(
579
- 'issued a second quote against an output hash it had already bound - whichever payment settles first takes the id, and the other payer has bought nothing'
580
- )
664
+ for (const spelling of probed) {
665
+ const twice = await quoteAt(spelling, named[spelling].h, otherAmount)
666
+ if (twice.status !== 'ERROR') {
667
+ problems.push(
668
+ `issued a second quote against an output already named by ${spelt(spelling)} - whichever payment settles first takes the id, and the other payer has bought nothing`
669
+ )
670
+ }
581
671
  }
582
672
  }
583
673
  if (problems.length > 0) throw soft(problems.join('; '))
584
- return `claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and refused four malformed ones`
674
+ return `named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and handled four malformed ones as the draft requires`
585
675
  })
586
676
 
587
677
  // The payRequest, with the mint address the checks above fetched hung
@@ -198,7 +198,7 @@
198
198
  "noteId": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
199
199
  "k1": "0505050505050505050505050505050505050505050505050505050505050505",
200
200
  "preimageIsAValidK1": false,
201
- "note": "the wallet can claim by asking the withdraw endpoint for its own secret directly - it never needs the verify poll at all"
201
+ "note": "a software wallet can claim by asking the withdraw endpoint for its own secret directly. A sealed signer that will not export that secret instead uses the optional bound receipt below to confirm the note without revealing k1"
202
202
  },
203
203
  "unbound": {
204
204
  "noteId": "e802086ad6a1e16b78352ad7296d2aabd835b1b16dbe951e1135b97c68e29d81",
@@ -207,6 +207,108 @@
207
207
  "note": "the wallet must poll verify for the preimage, and rotate the instant it has it"
208
208
  }
209
209
  },
210
+ "receipt": {
211
+ "description": "An optional LUD-21 settlement receipt for a bound quote. This is needed by a sealed signer that generated k1 but will not export it merely so its companion app can probe the withdraw endpoint. The quote commits to the exact output id and net amount before payment. Once settled, verify repeats that commitment and adds the ordinary LUD-25 note signature. No field changes the legacy LUD-21 meaning of preimage: it remains payment proof and still does not open the bound note.",
212
+ "optional": true,
213
+ "field": "mint",
214
+ "keyEstablishment": {
215
+ "rule": "The WALLET must know the receipt verification key before it pays: recover the signing node identity from the BOLT-11 invoice, or read mintPubkey from the payRequest under the wallet's existing trust/pinning policy.",
216
+ "payRequest": {
217
+ "mintToHash": true,
218
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
219
+ }
220
+ },
221
+ "commitment": {
222
+ "h": "the normalised output id committed by this quote; 32 bytes as 64 lowercase hex",
223
+ "amount": "the exact net note value in millisatoshis after fees",
224
+ "sig": "absent before settlement; after settlement, the ordinary recoverable LUD-25 signature over LNURLcash:<amount>:<h>"
225
+ },
226
+ "quote": {
227
+ "pr": "lnbc210n1pjqrstuvwxyz",
228
+ "verify": "https://mint.example/verify/e802086ad6a1e16b78352ad7296d2aabd835b1b16dbe951e1135b97c68e29d81",
229
+ "mintToHash": true,
230
+ "mint": {
231
+ "h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
232
+ "amount": 21000
233
+ }
234
+ },
235
+ "unsettled": {
236
+ "status": "OK",
237
+ "settled": false,
238
+ "preimage": null,
239
+ "pr": "lnbc210n1pjqrstuvwxyz",
240
+ "mint": {
241
+ "h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
242
+ "amount": 21000
243
+ }
244
+ },
245
+ "settled": {
246
+ "status": "OK",
247
+ "settled": true,
248
+ "preimage": "0606060606060606060606060606060606060606060606060606060606060606",
249
+ "pr": "lnbc210n1pjqrstuvwxyz",
250
+ "mint": {
251
+ "h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
252
+ "amount": 21000,
253
+ "sig": "5559e4ad39ea32a9f8dba641cc69e2874e962fd569cd661b5278ccd13f647d6e74bce7105178f3817f1938cee81765cc3ab9005a5821c5d2228c2003418542ea01"
254
+ }
255
+ },
256
+ "walletRules": [
257
+ "A WALLET that requires a receipt MUST refuse to show or pay an invoice unless quote.mintToHash is exactly true and quote.mint matches the h it requested and the exact amount it expects to receive.",
258
+ "Before accepting settlement, it MUST match verify.pr to quote.pr, match verify.mint.h and verify.mint.amount to the quote commitment, require settled to be exactly true, and verify mint.sig with the mint public key and its locally held k1.",
259
+ "A SERVICE MUST NOT return mint.sig before settlement. An unsettled response MAY repeat h and amount so a wallet can diagnose a mismatch, but that repetition is not a receipt.",
260
+ "Absence of quote.mint means the optional receipt is not offered. A software wallet can still use mintToHash and claim with its own k1; a sealed signer falls back before payment to the legacy preimage-and-rotate flow."
261
+ ],
262
+ "invalid": [
263
+ {
264
+ "name": "quote commits a different h",
265
+ "quote": {
266
+ "mintToHash": true,
267
+ "mint": {
268
+ "h": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
269
+ "amount": 21000
270
+ }
271
+ },
272
+ "reason": "the invoice is not demonstrably buying the output the wallet named"
273
+ },
274
+ {
275
+ "name": "verify changes the net amount",
276
+ "verify": {
277
+ "settled": true,
278
+ "mint": {
279
+ "h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
280
+ "amount": 20999,
281
+ "sig": "5559e4ad39ea32a9f8dba641cc69e2874e962fd569cd661b5278ccd13f647d6e74bce7105178f3817f1938cee81765cc3ab9005a5821c5d2228c2003418542ea01"
282
+ }
283
+ },
284
+ "reason": "the settled receipt is not the commitment shown before payment"
285
+ },
286
+ {
287
+ "name": "signature appears before settlement",
288
+ "verify": {
289
+ "settled": false,
290
+ "mint": {
291
+ "h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
292
+ "amount": 21000,
293
+ "sig": "5559e4ad39ea32a9f8dba641cc69e2874e962fd569cd661b5278ccd13f647d6e74bce7105178f3817f1938cee81765cc3ab9005a5821c5d2228c2003418542ea01"
294
+ }
295
+ },
296
+ "reason": "a note signature is evidence of minted value and cannot be issued speculatively"
297
+ },
298
+ {
299
+ "name": "settled response has the wrong signature",
300
+ "verify": {
301
+ "settled": true,
302
+ "mint": {
303
+ "h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
304
+ "amount": 21000,
305
+ "sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
306
+ }
307
+ },
308
+ "reason": "the signer cannot confirm a note the mint has not authenticated"
309
+ }
310
+ ]
311
+ },
210
312
  "normalisation": {
211
313
  "description": "One worked pair, so an implementation can check that the two spellings name one output rather than two.",
212
314
  "sent": "F849D67325FACF04177BC663B2DC544051831C589EF581D412F2EBA44834E77C",
@@ -441,7 +543,7 @@
441
543
  "A WALLET MUST send `h` as 64 lowercase hex. A SERVICE SHOULD normalise case before comparing and MUST NOT read the two spellings as two outputs, but a wallet that leans on that will meet a SERVICE that refuses upper case outright.",
442
544
  "A WALLET MUST decide from the payRequest, which every SERVICE publishes, rather than from the experimental mint address document, which many do not. A SERVICE that does not advertise `mintToHash` ignores `h`, and the note it mints is the preimage's.",
443
545
  "A WALLET MUST check the pay callback's own `mintToHash` before it pays. The other two advertisements can be cached or stale; that one is this quote, now. Absent means not bound, so claim the preimage way and rotate on sight.",
444
- "Against a SERVICE that does advertise it, a WALLET needs no verify poll to claim: it knows its own secret, so it asks the withdraw endpoint for it directly. Keep the poll as the fallback for SERVICEs without the capability.",
546
+ "Against a SERVICE that advertises mintToHash, a software WALLET needs no verify poll to claim: it knows its own secret, so it asks the withdraw endpoint for it directly. A sealed signer that will not export k1 MAY require the optional bound receipt and use verify only as authenticated settlement evidence.",
445
547
  "A note minted at a WALLET-chosen hash is the WALLET's from birth. A wallet deriving its secrets from a seed can restore that note from the seed alone, which a preimage-secret note can never be."
446
548
  ]
447
549
  }