lnurlcash-conformance 0.1.1 → 0.2.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,357 @@ 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.2.0 - 2026-08-22
8
+
9
+ - Naming the note you are buying. In LUD-25 a minted note's `k1` is the
10
+ payment preimage, so the preimage IS the money, and two sets of people
11
+ learn it without being trusted with it: every routing node on the
12
+ payment path, because that is how HTLC settlement works, and anyone who
13
+ merely saw the invoice, because they can poll LUD-21 verify with its
14
+ payment hash and take the preimage the moment it settles. A QR on a
15
+ desktop screen is exactly that. "Rotate immediately" is the only defence
16
+ and it is a race. A wallet may instead send `h` on the LUD-06 pay
17
+ callback, the sha256 of a secret it chose, and be credited there.
18
+
19
+ - New `vectors/mint-to-hash.json` fixes the parameter and both refusal
20
+ reasons, so kits in other languages answer the same wallet the same
21
+ way. `h` is 64 lowercase hex, exactly what it means on the withdraw
22
+ callback. A malformed one is refused with `Invalid h.` before any
23
+ invoice exists, so a wallet never pays for a quote the mint was always
24
+ going to reject; one that already names a note, an invoice, or another
25
+ quote's output is refused with `Invalid or already spent k1.`, the
26
+ same reason the withdraw callback gives a colliding output hash, so no
27
+ oracle appears. Ten cases, four of them refusals, plus a worked
28
+ settlement both ways round: bound, the note is at the wallet's own
29
+ secret and the preimage opens nothing; unbound, it is the preimage's,
30
+ exactly as before.
31
+
32
+ - Support is advertised in three places and they say different things.
33
+ `mintToHash: true` on the payRequest means "I accept an `h` on my pay
34
+ callback", and since every mint publishes a payRequest while the mint
35
+ address document is experimental, that is the one a wallet decides
36
+ from. The mint address document repeats it, as corroboration. The pay
37
+ callback's own response echoes it when *that* quote was bound, which
38
+ is the one that matters at the moment money moves, because the other
39
+ two can be cached or stale. Anything that is not exactly the boolean
40
+ `true` is no, everywhere, so a mint that omits the field is safely
41
+ read as not bound. All fifteen spellings are in the vector.
42
+
43
+ - Mock knob `mintToHash`, off by default, and with it off nothing about
44
+ this reaches the wire: nothing is advertised anywhere and the pay
45
+ callback does not read `h` at all. `mintToHashAdvertisedOn` narrows
46
+ which of the three places claim it, changing only what is claimed and
47
+ never what the mint does, so a mint that shipped the feature before
48
+ the advertisement can be reproduced. Three misbehaviours, each needing
49
+ `mintToHash` alongside: `mintToHashAcceptsMalformedH`,
50
+ `mintToHashAcceptsUsedH` and `mintToHashIgnoresH`, the last of which
51
+ claims the capability, echoes it on the quote, and mints at the
52
+ payment hash anyway.
53
+
54
+ - New grader check `accepts an output hash on the mint quote
55
+ (mintToHash, optional)`. Soft where it should be: a mint that says
56
+ nothing anywhere and ignores `h` is reported as not offering it and
57
+ the run passes, which is every mint today and not a defect. A mint
58
+ that claims it is asked to prove the refusals, and an invoice issued
59
+ for a malformed `h` fails, because a wallet that pays for a quote the
60
+ mint will reject has bought nothing while the mint keeps the sats.
61
+ Disagreements between the three claims are named rather than failed:
62
+ none of them loses anyone money on its own, since a wallet reading a
63
+ missing field as false falls back to the preimage flow.
64
+
65
+ - New grader check `a bound mint credits the hash the wallet named
66
+ (optional)`, exported as `gradeBoundMint` and wired to a new
67
+ `--preimage` flag. Like the minted-value check it needs a payment the
68
+ runner cannot make itself: given the note URL carrying the wallet's
69
+ own secret and the preimage of the invoice that funded it, it fails a
70
+ mint that claimed the capability and did not bind. That is the one
71
+ worth failing rather than warning, because a wallet that believed the
72
+ claim stopped rotating on sight.
73
+
74
+ - Also refused now, on the withdraw callback: an `h` or `h2` colliding
75
+ with the output a bound quote is waiting to credit. Unreachable unless
76
+ `mintToHash` is on, so nothing about a mint without it changes.
77
+
78
+ - The case rule, stated as a rule rather than a verdict. Hex is
79
+ case-insensitive, so `AAAA...` and `aaaa...` are the same 32 bytes and
80
+ name one output, not two. A wallet MUST send `h` as 64 lowercase hex,
81
+ which keeps the producer side strict and is what every client here
82
+ does. A service SHOULD normalise case before comparing, and MUST NOT
83
+ treat an otherwise well-formed upper-case `h` as a different output
84
+ from its lowercase form: keying the string it was handed files the
85
+ note under the upper-case spelling and never finds it again when the
86
+ wallet asks the withdraw endpoint for its own lowercase secret, and
87
+ nobody is told. The vector says so with a worked pair and with two
88
+ upper-case cases, one binding where its lowercase twin binds and one
89
+ colliding where its twin collides, which is also what pins case being
90
+ normalised *before* the collision check. Every genuinely malformed
91
+ case is unchanged: empty, not hex, and both off-by-one lengths. The
92
+ mock normalises. The grader does not probe it in either direction: a
93
+ mint that normalises loses nobody money, and one that refuses upper
94
+ case outright is being strict rather than wrong, because the wallet
95
+ learns before it pays.
96
+
97
+ - New `vectors/derivation.json`: deterministic note secrets from a BIP39
98
+ seed, so that a wallet can be restored from words alone and two
99
+ implementations of the same wallet derive the same notes.
100
+ - `k1` is WALLET-generated in LUD-25 and the draft says nothing about how
101
+ one is produced, so a wallet is free to derive it instead of drawing it
102
+ at random. Nothing about this is observable on the wire: the mint still
103
+ only ever sees `sha256(k1)`.
104
+ - The scheme, in full, so this entry alone is enough to reimplement it.
105
+ `root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed bytes)`,
106
+ then `k1 = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))`.
107
+ Output is 32 bytes, lowercase hex, the same size as a payment preimage.
108
+ `host` is the mint host as the wallet stores it, lowercase, with the
109
+ port when there is one; `index` is decimal ASCII counting from 0. The
110
+ seed is the 64-byte BIP39 seed of a 12-word English mnemonic with no
111
+ passphrase.
112
+ - Cases cover the standard `abandon ... about` mnemonic at `mint.example`
113
+ for indices 0, 1, 2, 19 and 20 (19 and 20 straddle a 20-index gap
114
+ limit), a second mnemonic at the same host and index to show the seed
115
+ separates them, and a host carrying a port.
116
+ - Every case carries `seedHex` as well as the mnemonic, so an
117
+ implementation with no BIP39 library can still test the derivation
118
+ half on its own.
119
+ - `@scure/bip39` is a devDependency, used only to generate the file. The
120
+ published package gains no runtime dependency.
121
+
122
+ - Self-check covers the new file: every `k1` recomputes from its own
123
+ `seedHex`, every mnemonic validates against the English wordlist and
124
+ produces the stated seed, and no two cases collide.
125
+
126
+ - The retried mutation. Every mutation in LUD-25 is a GET, and HTTP stacks
127
+ retry a GET when the connection they used is dropped: Go's `net/http`
128
+ retries one that failed on a reused idle connection, the JDK's
129
+ `HttpClient` retries idempotent methods with no switch to turn it off.
130
+ The service therefore sees the byte-identical request twice, and by the
131
+ time the second arrives its inputs are burned. Answering it as an
132
+ already-spent input tells the holder the mutation never happened, and a
133
+ holder that believes it discards the only copy of a secret the service
134
+ really did mint a note against. Nobody is told; the money is gone.
135
+
136
+ - New `vectors/retried-mutation.json` fixes what identical means, so two
137
+ services do not give the same wallet two different answers to the same
138
+ dropped connection: the same input `k1` set (a set, so a merge naming
139
+ the same notes in a different order is the same merge), the same `h`,
140
+ the same `h2` and the same `amount`, present or absent alike. Twelve
141
+ cases, four of them replays and eight of them still double-spend
142
+ attempts, each turning on one thing being different. Anything that is
143
+ not a retry keeps today's refusal and today's reason string, so no
144
+ oracle appears for whoever holds a burned secret. Provenance is
145
+ recorded, never inferred: matching on "a note exists at `h`" alone
146
+ would let anyone holding a burned `k1` and any outstanding note id
147
+ pull a success out of a mint.
148
+
149
+ - Mock knob `retriedMutation`, `'refuse'` by default, which is exactly
150
+ what this mock has always done. `'replay'` answers a byte-identical
151
+ repeat with the original success. The replay path is a read: it burns
152
+ nothing, mints nothing and moves no balance, and the signature is
153
+ recomputed from the output id and amount rather than stored.
154
+
155
+ - New grader check `replays a retried mutation rather than refusing it
156
+ (optional)`, covering a rotate and a split so `h2` and the change
157
+ amount are covered too. Soft, because this is a SHOULD: a mint that
158
+ refuses the retry is reported as not having implemented it, not
159
+ failed. What is not soft is damage, so a retry that burns the output
160
+ or changes its value fails outright whichever answer it gives.
161
+
162
+ - The existing `refuses a replayed burn` check is untouched. It sends a
163
+ burned input with a *fresh* output hash, which is a genuine
164
+ double-spend attempt and a different request, and it still passes in
165
+ both modes.
166
+
167
+ - Three optional extensions a mint may publish, none of them in LUD-25,
168
+ all of them absent or off unless asked for. A mock started with no
169
+ options answers byte for byte what it answered before, and a mint
170
+ publishing none of them is not graded down.
171
+
172
+ - **Mint info** on the experimental discovery endpoint. Mock knobs
173
+ `name`, `description`, `contact` (`{nostr, email, url}`), `tosUrl`,
174
+ `motd`, `version` and `previousPubkeys`, appended after the existing
175
+ fields so nothing above them moves. `fees: {baseFeeMsat, feePpm}` is
176
+ emitted whenever a fee is configured: the structured twin of the fee
177
+ line in the payRequest metadata, which stays exactly as it was.
178
+ `nodeCapacity` is emitted as before, under that name.
179
+
180
+ - **Liabilities**. Mock knob `stats: true` (default `false`, and when
181
+ off `/stats` falls through to the same 404 every unknown path gets)
182
+ serving `GET /stats` as `{at, outstandingMsat, outstandingNotes,
183
+ pendingMsat, pendingMelts, oldestPendingMeltAgeSecs, localBalanceMsat?,
184
+ coverage?, reconciledAt}`. Notes here are not blinded, so a mint can
185
+ state what it owes exactly. A note mid-melt counts under `pending`
186
+ rather than `outstanding`: its value is committed, not free, and it
187
+ comes back if the payment fails. `coverage` is
188
+ `localBalanceMsat / outstandingMsat` to four decimal places, omitted
189
+ when nothing is owed. Knob `localBalanceMsat` sets what the node
190
+ claims to hold, so a mock can be told to look under-covered.
191
+
192
+ - **Signing-key rotation**. Mock knobs `previousPrivateKey` (an old key
193
+ the mock still holds; its public half joins `previousPubkeys` on its
194
+ own) and `previousPubkeys`. `state.creditNote(k1, amount,
195
+ {previousKey: true})` signs one note under the old key and leaves the
196
+ rest under the new, and `/_test/credit?...&key=previous` does the
197
+ same out of process. `signWithPreviousKey` issues every note under the
198
+ old key while still advertising the new one: the mid-rotation state a
199
+ mint passes through when the advertisement moves before the signer.
200
+
201
+ - Grader, all soft, all read-only:
202
+
203
+ - `publishes a mint address (experimental, optional)` keeps its name and
204
+ its existing assertions, and now checks the shape of the new fields
205
+ when they are present: the string fields non-empty, `tosUrl` and
206
+ `contact.url` fetchable, `contact.nostr` decoded as an npub rather
207
+ than pattern-matched, `contact.email` address-shaped, `fees` numeric
208
+ and not negative, `previousPubkeys` an array of 33-byte compressed
209
+ pubkeys in hex that does not merely restate the current one. A
210
+ malformed field is a warning, never a failure, and absence is neither.
211
+ The pass line names which fields it saw. A mint publishing its node
212
+ capacity as `nodeCapacityMsat` warns as well: the wire name carries no
213
+ suffix, and anything mapping the documented name reads undefined.
214
+
215
+ - New `publishes liabilities (optional)`. No `/stats`, or a `/stats`
216
+ that answers with something other than a liabilities body, is a
217
+ warning and nothing more. When it does answer, `outstandingMsat` must
218
+ be a number at or above zero and `coverage` must be numeric when
219
+ present. A node holding less than the mint owes warns rather than
220
+ fails: whether a mint is fully backed is the operator's to disclose,
221
+ and one that publishes an uncomfortable number is behaving better than
222
+ one that publishes nothing.
223
+
224
+ - `signs the notes it issues (optional)` accepts a signature recovering
225
+ any key the mint publishes, the current `mintPubkey` first and then
226
+ anything in `previousPubkeys`, and says which it was. Grading a note
227
+ issued before a rotation as forged would punish a mint for rotating
228
+ properly. `gradeNote` takes the list as `options.previousPubkeys`; the
229
+ CLI carries it across from the discovery endpoint on its own, which
230
+ `gradeMint` now hangs off the payRequest it returns as `mintAddress`.
231
+
232
+ - New `vectors/payment-request.json`: one holder asking another for value,
233
+ as a string a payer's wallet can act on. Not to be confused with
234
+ `pay-request.json`, which is the LUD-06 payRequest a mint publishes; no
235
+ mint is involved in reading this one.
236
+ - Encoding is the NUT-18 `creqA` idiom with our own prefix:
237
+ `lnurlcashreq1` followed by the request canonicalised under RFC 8785
238
+ (JCS) and carried as unpadded base64url. Canonical because two wallets
239
+ building the same request must produce the same string, or the payee
240
+ cannot match what came back to what they asked for. One encode case is
241
+ the same request with its keys in a different order, encoding
242
+ identically, and one carries a non-ASCII memo, which JCS leaves alone
243
+ rather than escaping.
244
+ - `amount` is whole sat as a decimal string, matching what the 402
245
+ payment-method schemas carry; the payer sends `amount * 1000` msat
246
+ exactly. Sub-sat requests are not a thing.
247
+ - Decode cases cover the round trips, an expiry still in the future, an
248
+ expired one, one expiring exactly now, a bad prefix, no prefix, a
249
+ payload that is not base64url, a payload that is not a JSON object, an
250
+ unknown version, a non-integer amount in four spellings, an empty
251
+ mints array, no `methodDetails` at all, a `to` that is neither an npub
252
+ nor address-shaped, a `to` that looks like an npub but does not
253
+ decode, a currency that is not sat, and a malformed id. Every refusal
254
+ names a reason from a declared list, and every declared reason has a
255
+ case.
256
+ - The file states `evaluatedAt`, a fixed unix time the decode cases are
257
+ read at, so an expiry means the same thing on every run.
258
+
259
+ - New `vectors/settle-for-value.json`: the decision table a server works
260
+ through when a bearer note arrives as payment, with the order it works
261
+ through it in.
262
+ - The order is the interesting part. Host, then what the mint says
263
+ (which is where a spent or in-flight note surfaces), then the
264
+ signature when one is required, then the value, then the rotate. A
265
+ note wrong in two ways is refused for the first reason in order, or
266
+ two servers explain the same note two different ways, and cases where
267
+ two things are wrong at once pin that.
268
+ - The rotate is deliberately last: it is both the ownership transfer and
269
+ the double-spend check, and a server that rotates before comparing the
270
+ amount has taken the money and refused the request.
271
+ - Outcomes are `accept`, `wrong-host`, `insufficient`, `bad-signature`,
272
+ `missing-signature`, `spent` and `pending`, all reachable. Also pinned:
273
+ hosts compare lowercased with the port included, an empty accepted-mint
274
+ list takes nothing rather than everything, a note worth exactly the
275
+ price is paid, and a signature that does not verify is accepted when
276
+ none was required, because the value came from asking the mint and the
277
+ mint is authoritative.
278
+
279
+ - Self-check reimplements both decision tables rather than calling the
280
+ generator's, because a check that calls the function it is checking
281
+ proves only that the function is deterministic.
282
+
283
+ - `signature.json` gains a `rotation` block: a note signed under a
284
+ previous key with both keys published (valid), the same signature byte
285
+ for byte with only the current key published (invalid), a
286
+ current-key signature alongside a published previous one (valid), and a
287
+ signature under a key that was never published (invalid). The cases
288
+ carry `mintPubkeys` as a list rather than the single `mintPubkey` the
289
+ existing cases carry, and live in their own block for that reason: a
290
+ verifier that knows nothing about rotation reads `cases` and is
291
+ completely unaffected by this release.
292
+
293
+ ## 0.1.2 - 2026-08-21
294
+
295
+ - The minted-value check took a band instead of a single number, because
296
+ it was failing the reference implementation.
297
+ - LUD-25 states the mint fee as `base_fee_msat` plus a ppm cut and says
298
+ nothing about rounding. dni's lnurl-mint ceilings that fee to a whole
299
+ sat on purpose, so the mint is "never short a sat"; moneyer is
300
+ msat-exact. Every public mint on the awesome list except moneyer runs
301
+ lnurl-mint, so the majority of live services round.
302
+ - The check asserted equality against the msat-exact formula, and even
303
+ named the rounding in its failure message as something "the formula
304
+ does not allow". Measured on real sats: 40,000 msat at
305
+ mint.forgesworn.dev with a 1000 + 1000 ppm fee credits 38,000, not
306
+ 38,960. A clean grade was unreachable for the reference.
307
+ - Deciding which reading is right is not this repo's job - "either may
308
+ be wrong, and the LUD-25 PR is where that gets settled". So the
309
+ compliant answer is now the range between the two: the formula is the
310
+ most a holder can be credited, the sat-ceilinged fee the least. The
311
+ report names which one it saw.
312
+ - Both edges are graded, and selfgrade proves it: a msat past the
313
+ ceilinged fee fails, and so does crediting more than the formula. A
314
+ band with no edges would grade nothing. The mock's `roundFeeToSat` is
315
+ reclassified from a misbehaviour to the reference's behaviour, and
316
+ gains `extraFeeMsat` for landing outside the band deliberately.
317
+
318
+ - Two refusals LUD-25 spells out were never graded. Both are reachable
319
+ against a live mint, and both were being stepped around rather than
320
+ tested.
321
+ - `refuses a split with no h2`: a split names two outputs, so a mint
322
+ given only `h` either refuses or invents the change note's secret
323
+ itself. The second is the exact prior-holder exposure wallet-generated
324
+ secrets exist to close, and it was ungraded.
325
+ - `refuses a split whose change cannot cover the base fee`: the grader
326
+ already knew the advertised base fee, and skipped (`note too small to
327
+ split past the advertised base fee`) rather than deliberately leaving
328
+ change one msat short of it. It now does that on purpose and expects
329
+ `insufficient value`. Warns when no fee is advertised, since the rule
330
+ cannot bite.
331
+ - Both confirm the note survives the refusal, and both are self-verified:
332
+ the mock gains `acceptsMissingH2` and `splitIgnoresBaseFee`, and
333
+ selfgrade asserts each is caught by name rather than by failure count.
334
+ - The grader never melts, so `pending`, restore-on-failure and a melt's own
335
+ LUD-21 `verify` cannot be graded against a live service - melting real
336
+ sats is not something a grader may do on its own initiative. The mock
337
+ covers all three for client-side suites. Said so in the README, which
338
+ previously left the gap to be inferred.
339
+
340
+ - `withdrawLink` has two legal spellings in the wild. LUD-25 calls it "a
341
+ raw, non bech32-encoded URL as described in LUD-17", and LUD-17 describes
342
+ both the `lnurlw://` scheme and the plain `https://` URL it stands for.
343
+ lnurl-mint (and the spec's own diagram) emit `https://mint.example/w`;
344
+ moneyer emits `lnurlw://moneyer.dev/w`. The mock mint only ever served
345
+ the second, so a client that broke on the reference mint's form would
346
+ still have passed here.
347
+ - The mock mint now serves the plain `https://` spelling by default,
348
+ matching the reference mint, and takes `withdrawLinkForm: 'lnurlw'`
349
+ for the other. A test asserting the old `lnurlw://` default needs
350
+ updating (lnurlcash-kit's did).
351
+ - `pay-request.json` gains accepted cases for the plain form and for an
352
+ onion host; a parser must pass both through untouched.
353
+ - The grader accepts either spelling, rejects a bech32 `lnurl1...` value,
354
+ and names the form in its report. Its three ad-hoc `lnurlw://` rewrites
355
+ are now one exported `fromLud17`.
356
+ - Selfgrade runs the compliant mock in both forms.
357
+
7
358
  ## 0.1.1 - 2026-08-20
8
359
 
9
360
  - The mock mint's mint-address response now carries the node stats
package/README.md CHANGED
@@ -46,6 +46,7 @@ for (const c of cases) {
46
46
  | File | Covers |
47
47
  | --- | --- |
48
48
  | `signature.json` | offline verification, both recovery-id orderings, malformed input |
49
+ | `derivation.json` | deterministic note secrets from a BIP39 seed |
49
50
  | `bech32.json` | LUD-01 encoding, round trips, corrupted checksums |
50
51
  | `url-admission.json` | which URLs may be fetched, and why `data:` must never be |
51
52
  | `input-resolution.json` | bech32, LUD-17, Lightning Addresses, bare domains |
@@ -56,7 +57,11 @@ for (const c of cases) {
56
57
  | `responses.json` | classifying every reply, including the ambiguous ones |
57
58
  | `withdraw-info.json` | the informational GET, and what makes a response invalid |
58
59
  | `pay-request.json` | minting, LUD-11 disposable, LUD-21 verify |
60
+ | `payment-request.json` | `lnurlcashreq1`: one holder asking another for value |
61
+ | `settle-for-value.json` | the decision table a server works through to take a note as payment |
62
+ | `retried-mutation.json` | what makes a repeated mutation a retry rather than a double-spend |
59
63
  | `lifecycle.json` | behavioural requirements, as scenarios to drive |
64
+ | `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
60
65
 
61
66
  Regenerate with `npm run generate`; check them with `npm test`, which
62
67
  verifies every digest recomputes, every declared signature really does
@@ -92,7 +97,33 @@ must survive:
92
97
  | `--baseFeeMsat=N --feePpm=N` | advertises and withholds a mint fee |
93
98
  | `--roundFeeToSat` | rounds the withheld fee up to a whole sat — the note mints short of the formula |
94
99
  | `--verifyLeaksEarly` | serves the preimage from verify before settlement — the bearer secret, to anyone with the hash |
100
+ | `--mintToHashAcceptsMalformedH` | claims `mintToHash` and invoices an `h` that is not 64 lowercase hex, so a wallet pays for a quote the mint will refuse |
101
+ | `--mintToHashAcceptsUsedH` | claims it and invoices an `h` that already names a note, an invoice or another quote's output |
102
+ | `--mintToHashIgnoresH` | claims it, echoes it back on the quote, and mints at the payment hash anyway, so the preimage is still the money |
103
+
104
+ The three `mintToHash*` misbehaviours need `--mintToHash` alongside them;
105
+ on their own they do nothing, because a mint that never offered the
106
+ capability cannot misuse it.
95
107
  | `--verify=false` | no LUD-21 endpoint at all, not merely unadvertised |
108
+ | `--withdrawLinkForm=lnurlw` | spells `withdrawLink` as `lnurlw://host/w` instead of the plain `https://host/w` the reference mint emits. Both are legal; a client has to take both |
109
+
110
+ Five behaviours are outside LUD-25 and outside that table, because none
111
+ of them is misbehaviour. All are absent or off unless you ask
112
+ for them, so a mock started with no options answers exactly what it always
113
+ answered:
114
+
115
+ | Flag | What it does |
116
+ | --- | --- |
117
+ | `--name --description --contact --tosUrl --motd --version` | mint info on the experimental discovery endpoint: who runs this, how to reach them, the terms, and what the operator wants holders to know today |
118
+ | `--baseFeeMsat --feePpm` | also publishes `fees: {baseFeeMsat, feePpm}` on that endpoint, the structured twin of the fee line in the payRequest metadata |
119
+ | `--stats` | serves `GET /stats`: what the mint owes, what is in flight, what the node holds, and the coverage between them |
120
+ | `--localBalanceMsat=N` | what the node behind a stats-publishing mock claims to hold, so a mock can be told to look under-covered |
121
+ | `--previousPubkeys=a,b` | keys this mint has signed under before, so notes issued before a rotation still verify |
122
+ | `--previousPrivateKey=<hex>` | an old signing key the mock still holds. Its public half joins `previousPubkeys` on its own |
123
+ | `--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
+ | `--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
+ | `--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 |
126
+ | `--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 |
96
127
 
97
128
  As a library, for your own test suite:
98
129
 
@@ -107,7 +138,9 @@ await mint.close()
107
138
 
108
139
  `mint.state` exposes `creditNote`, `noteState`, `settleMelt`, `failMelt` and
109
140
  the raw note and invoice maps, so a test can assert what the SERVICE
110
- actually did rather than what it said.
141
+ actually did rather than what it said. `creditNote(k1, amount, {previousKey:
142
+ true})` signs that one note under `previousPrivateKey`, which is how a case
143
+ puts one note under the old signing key and the rest under the new.
111
144
 
112
145
  ## The grader
113
146
 
@@ -115,24 +148,83 @@ actually did rather than what it said.
115
148
  npx lnurlcash-conform mint@example.com
116
149
  ```
117
150
 
118
- Read-only by default: resolves the payRequest, checks the `withdrawLink`,
151
+ Read-only by default: resolves the payRequest, checks the `withdrawLink`
152
+ (either legal spelling, and the report says which one the mint uses),
119
153
  the fee advertisement, invoice amounts, that LUD-21 verify serves no
120
154
  preimage before settlement (on a mint that value IS the bearer secret, and
121
155
  everyone on the payment's route knows the payment hash), whether an
122
156
  unknown note is reported distinguishably from a spent one, and the
123
157
  experimental mint address.
124
158
 
159
+ One more is graded softly, and it is the one that changes what a bearer
160
+ note is. In LUD-25 a minted note's `k1` is the payment preimage, so the
161
+ preimage is the money, and every routing node on the payment path learns
162
+ it, as does anyone who merely saw the invoice and polled LUD-21 verify with
163
+ its payment hash. A QR on a desktop screen is exactly that. A mint may
164
+ instead take an `h` on its pay callback, the sha256 of a secret the wallet
165
+ chose, and credit the note there; the preimage is then an ordinary payment
166
+ proof that opens nothing. The mint says so in three places, and they mean
167
+ different things: `mintToHash: true` on the payRequest (every mint has one,
168
+ so it is what a wallet decides from), the same on the experimental mint
169
+ address document (corroboration), and the same echoed on the pay callback's
170
+ own response when *that* quote was bound (the one that matters at the moment
171
+ money moves, because the other two can be cached). Anything that is not
172
+ exactly the boolean `true` is no.
173
+
174
+ A mint that says nothing anywhere is reported as not offering it and passes,
175
+ which is every mint today. A mint that claims it is asked to prove the
176
+ refusals: a malformed `h` must get no invoice at all, since a wallet that
177
+ pays for a quote the mint will reject has bought nothing. Malformed means
178
+ not 32 bytes of hex, in any casing. A wallet MUST send `h` as 64 lowercase
179
+ hex and every client here does, but hex is case-insensitive, so a service
180
+ SHOULD normalise before comparing and MUST NOT read `AAAA...` and `aaaa...`
181
+ as two different outputs: keying the string it was handed files the note
182
+ where the wallet will never look for it, and nobody is told. A service that
183
+ refuses upper case outright is being strict rather than wrong, so the grader
184
+ does not probe it either way. Where the three
185
+ claims disagree, the grader names the disagreement rather than failing it:
186
+ none of those loses anyone money on its own. What is failed is a mint that
187
+ claims the capability and does not bind, because a wallet believing the
188
+ claim stops rotating on sight; that one needs a settlement to see, so it
189
+ rides on `--preimage`.
190
+
191
+ Three other things a mint may publish are graded softly, because none of them
192
+ is in LUD-25: the mint info on the discovery endpoint, a `/stats` endpoint
193
+ stating what the mint owes against what its node holds, and the signing
194
+ keys it has used before. Publishing none of them costs nothing. Publishing
195
+ one in the wrong shape is a warning, not a failure, because a wallet will
196
+ try to render it and someone should say so. A mint whose node holds less
197
+ than it owes warns too: whether it is fully backed is the operator's to
198
+ disclose, and a mint that publishes an uncomfortable number is behaving
199
+ better than one that publishes nothing.
200
+
125
201
  One check needs a real payment, which the runner cannot make on its own.
126
202
  Given a freshly minted, never-rotated note and what its mint invoice was
127
- paid at, it compares the note's value against the fee formula - msat-exact,
128
- so a fee implementation that quietly rounds up to whole sats is caught:
203
+ paid at, it compares the note's value against the advertised fee:
204
+
205
+ LUD-25 says nothing about whether that fee rounds, and the two live
206
+ implementations differ. dni's lnurl-mint ceilings it to a whole sat on
207
+ purpose, so the mint is never short a sat; moneyer withholds the
208
+ msat-exact amount. Both pass. The check grades the range between them and
209
+ names which it saw, and a msat outside it either way fails - a mint taking
210
+ more than the ceilinged fee, or crediting more than it advertised.
129
211
 
130
212
  ```bash
131
213
  npx lnurlcash-conform mint@example.com --note='lnurlw://...?k1=...' --paid=500000
132
214
  # or --pr=<the mint invoice>, when it carries an amount
133
215
  ```
134
216
 
135
- This is still read-only. The full run spends:
217
+ The bound-mint check needs a payment too. Mint against a hash you chose
218
+ yourself, then hand the runner your own secret and the preimage of the
219
+ invoice you paid: the note must really be at your secret, and the preimage
220
+ must open nothing.
221
+
222
+ ```bash
223
+ npx lnurlcash-conform mint@example.com --note='lnurlw://...?k1=<your secret>' \
224
+ --preimage=<the preimage of the invoice you paid>
225
+ ```
226
+
227
+ Both are still read-only. The full run spends:
136
228
 
137
229
  ```bash
138
230
  npx lnurlcash-conform mint@example.com --note='lnurlw://...?k1=...' --spend
@@ -142,18 +234,34 @@ It burns the note it is given and prints where the value ended up. It
142
234
  checks that the informational GET is idempotent and echoes the queried
143
235
  `k1`, that the URL's own `amount` is ignored, that a rotate with no `h` is
144
236
  refused, that a rotate returns no secret, that signatures verify against the
145
- advertised `mintPubkey`, that split and merge conserve value - exactly,
237
+ advertised `mintPubkey` or any key the mint still publishes as a previous
238
+ one, that split and merge conserve value - exactly,
146
239
  under LUD-25's fee algebra, when the mint's fee advertisement is known -
147
- and that a burned secret cannot be replayed. It also probes three adversarial shapes a
240
+ that a byte-identical repeat of a mutation is answered with the original
241
+ success rather than as an already-spent input (a SHOULD, so a mint that
242
+ has not implemented it is reported as such rather than failed), and that a
243
+ burned secret cannot be replayed. It also probes three adversarial shapes a
148
244
  mint must refuse atomically: a duplicated `k1` (which a careless mint counts
149
245
  twice, minting money from nothing), an output hash that collides with an
150
246
  existing note id (minting over it hands the output to whoever already knows
151
- that id's preimage), and a split whose `h` equals `h2` (one id cannot carry
152
- two notes). And it replays the callback as a POST and as an OPTIONS
247
+ that id's preimage), a split whose `h` equals `h2` (one id cannot carry
248
+ two notes), a split naming only one output hash (a mint that accepts it is
249
+ generating the change secret itself), and a split leaving change one msat
250
+ short of the advertised base fee (which LUD-25 says to refuse with
251
+ `insufficient value`, not to serve at a loss). And it replays the callback as a POST and as an OPTIONS
153
252
  preflight - real HTTP stacks send both on their own initiative, so the
154
253
  mutating endpoint must answer GET only. After every refusal it confirms the refused note is still
155
254
  spendable. Use a small note. Exit code is non-zero if anything failed.
156
255
 
256
+ **What the grader cannot reach.** It never melts. Melting spends real sats
257
+ against a real mint, which is not something a grading tool may decide to do,
258
+ so `pending` on a k1 mid-melt, restoring the note when the outgoing payment
259
+ fails, and the melt's own LUD-21 `verify` are all outside what a grade can
260
+ say anything about. They are not unspecified and not untested: the mock mint
261
+ implements every one of them (`meltNeverSettles`, `meltAlwaysFails`), so a
262
+ client suite driving the mock covers the whole melt path. A clean grade
263
+ means the read-only and non-melt mutating surface is compliant, no more.
264
+
157
265
  The grader shares no code with any LNURLcash library — it is written against
158
266
  `fetch` and `@noble` directly. A grader that shared an implementation with
159
267
  the thing it grades would agree with that implementation's mistakes, which
@@ -171,6 +279,9 @@ Spec and reference implementations, all by dni, all MIT:
171
279
  - [lnurl-mint](https://github.com/dni/lnurl-mint) — the reference service
172
280
  - [lnurl-wallet](https://github.com/dni/lnurl-wallet) — the reference wallet
173
281
 
282
+ Implementations to run these vectors against are indexed in
283
+ [awesome-lnurlcash](https://github.com/TheCryptoDonkey/awesome-lnurlcash).
284
+
174
285
  Contributions of vectors are welcome, particularly from implementers who
175
286
  found a case these missed. See [CONTRIBUTING.md](CONTRIBUTING.md).
176
287