lnurlcash-conformance 0.2.3 → 0.3.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,21 @@ 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.3.0 - 2026-08-24
8
+
9
+ - Add an optional bound-mint settlement receipt for sealed signers. A
10
+ receipt-capable quote commits to `mint: {h, amount}` before payment; its
11
+ settled LUD-21 response repeats the invoice, output and exact net value and
12
+ adds the ordinary LUD-25 note signature. Absence remains compatible with
13
+ the current preimage-and-rotate flow.
14
+ - `vectors/mint-to-hash.json` now carries the valid quote, unsettled and
15
+ settled shapes plus wrong-output, wrong-amount, premature-signature and
16
+ invalid-signature cases. `docs/BOUND-MINT-RECEIPTS.md` contains candidate
17
+ normative LUD-25 text and the compatibility matrix.
18
+ - The mock mint gains `mintReceipt`; when enabled it publishes the
19
+ verification key before payment, commits the quote and signs only after
20
+ settlement. Self-check and self-grade verify the complete lifecycle.
21
+
7
22
  ## 0.2.3 - 2026-08-22
8
23
 
9
24
  - **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
@@ -461,7 +465,14 @@ export const createMockMint = async (options = {}) => {
461
465
  // one a wallet should decide from. Spread in last and only when
462
466
  // the option is on, so a mock started with no options answers
463
467
  // exactly what it always answered.
464
- ...(mintToHashPlaces.has('payRequest') ? {mintToHash: true} : {})
468
+ ...(mintToHashPlaces.has('payRequest') ? {mintToHash: true} : {}),
469
+ // A receipt verifier needs the signing key before payment. The
470
+ // baseline mock remains byte-for-byte unchanged when receipts are
471
+ // off; a real node-key signer can alternatively be recovered from
472
+ // the BOLT-11 invoice itself.
473
+ ...(opts.mintReceipt && opts.verify && opts.signatures
474
+ ? {mintPubkey: pubkey}
475
+ : {})
465
476
  })
466
477
  }
467
478
 
@@ -628,18 +639,28 @@ export const createMockMint = async (options = {}) => {
628
639
 
629
640
  const preimage = bytesToHex(randomBytes(32))
630
641
  const paymentHash = noteId(preimage)
631
- const invoice = {amountMsat: net, preimage, settled: false}
642
+ const pr = fakeInvoice(amount, preimage)
643
+ const invoice = {amountMsat: net, preimage, settled: false, pr}
632
644
  if (boundTo) {
633
645
  invoice.boundTo = boundTo
634
646
  boundOutputs.set(boundTo, paymentHash)
635
647
  }
636
648
  invoices.set(paymentHash, invoice)
637
- const body = {pr: fakeInvoice(amount, preimage), disposable: false}
649
+ const body = {pr, disposable: false}
638
650
  if (opts.verify) body.verify = `${origin}/verify/${paymentHash}`
639
651
  // Appended last, and only when the quote really was bound, so a
640
652
  // mock that was never told about any of this answers byte for byte
641
653
  // what it always answered.
642
654
  if (echoBound) body.mintToHash = true
655
+ if (
656
+ echoBound &&
657
+ boundTo &&
658
+ opts.mintReceipt &&
659
+ opts.verify &&
660
+ opts.signatures
661
+ ) {
662
+ body.mint = {h: boundTo, amount: net}
663
+ }
643
664
  return send(body)
644
665
  }
645
666
 
@@ -652,7 +673,7 @@ export const createMockMint = async (options = {}) => {
652
673
  }
653
674
  const invoice = invoices.get(verifyMatch[1].toLowerCase())
654
675
  if (!invoice) return fail('Unknown payment hash.')
655
- return send({
676
+ const body = {
656
677
  status: 'OK',
657
678
  settled: invoice.settled,
658
679
  // the preimage IS the bearer secret here - a real SERVICE should
@@ -661,8 +682,18 @@ export const createMockMint = async (options = {}) => {
661
682
  // bound with its own h: that note is credited elsewhere, so the
662
683
  // preimage is an ordinary payment proof and leaks nothing.
663
684
  preimage: invoice.settled || opts.verifyLeaksEarly ? invoice.preimage : null,
664
- pr: fakeInvoice(invoice.amountMsat, invoice.preimage)
665
- })
685
+ pr: invoice.pr ?? fakeInvoice(invoice.amountMsat, invoice.preimage)
686
+ }
687
+ if (invoice.boundTo && opts.mintReceipt && opts.signatures) {
688
+ body.mint = {
689
+ h: invoice.boundTo,
690
+ amount: invoice.amountMsat,
691
+ ...(invoice.settled
692
+ ? {sig: sign(invoice.boundTo, invoice.amountMsat)}
693
+ : {})
694
+ }
695
+ }
696
+ return send(body)
666
697
  }
667
698
 
668
699
  // ---- LUD-03 informational GET ----
@@ -776,7 +807,8 @@ export const createMockMint = async (options = {}) => {
776
807
  invoices.set(paymentHash, {
777
808
  amountMsat: note.amountMsat,
778
809
  preimage: meltPreimage,
779
- settled: false
810
+ settled: false,
811
+ pr
780
812
  })
781
813
  }
782
814
  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.3.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",
@@ -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
  }