lnurlcash-conformance 0.1.2 → 0.2.1

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,302 @@ 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.1 - 2026-08-22
8
+
9
+ - The grader ships its own TypeScript declarations. The mock mint had them
10
+ and the runner did not, so a TypeScript consumer hand-wrote a
11
+ `declare module` shim, and those drift: moneyer carried one, `gradeBoundMint`
12
+ landed here, and the shim did not know about it. A consumer could not call
13
+ the newest check without editing a copy of a declaration it does not own.
14
+ Now `runner/index.d.ts` sits next to the code it describes and the exports
15
+ map points at it. No runtime change of any kind.
16
+
17
+ ## 0.2.0 - 2026-08-22
18
+
19
+ - Naming the note you are buying. In LUD-25 a minted note's `k1` is the
20
+ payment preimage, so the preimage IS the money, and two sets of people
21
+ learn it without being trusted with it: every routing node on the
22
+ payment path, because that is how HTLC settlement works, and anyone who
23
+ merely saw the invoice, because they can poll LUD-21 verify with its
24
+ payment hash and take the preimage the moment it settles. A QR on a
25
+ desktop screen is exactly that. "Rotate immediately" is the only defence
26
+ and it is a race. A wallet may instead send `h` on the LUD-06 pay
27
+ callback, the sha256 of a secret it chose, and be credited there.
28
+
29
+ - New `vectors/mint-to-hash.json` fixes the parameter and both refusal
30
+ reasons, so kits in other languages answer the same wallet the same
31
+ way. `h` is 64 lowercase hex, exactly what it means on the withdraw
32
+ callback. A malformed one is refused with `Invalid h.` before any
33
+ invoice exists, so a wallet never pays for a quote the mint was always
34
+ going to reject; one that already names a note, an invoice, or another
35
+ quote's output is refused with `Invalid or already spent k1.`, the
36
+ same reason the withdraw callback gives a colliding output hash, so no
37
+ oracle appears. Ten cases, four of them refusals, plus a worked
38
+ settlement both ways round: bound, the note is at the wallet's own
39
+ secret and the preimage opens nothing; unbound, it is the preimage's,
40
+ exactly as before.
41
+
42
+ - Support is advertised in three places and they say different things.
43
+ `mintToHash: true` on the payRequest means "I accept an `h` on my pay
44
+ callback", and since every mint publishes a payRequest while the mint
45
+ address document is experimental, that is the one a wallet decides
46
+ from. The mint address document repeats it, as corroboration. The pay
47
+ callback's own response echoes it when *that* quote was bound, which
48
+ is the one that matters at the moment money moves, because the other
49
+ two can be cached or stale. Anything that is not exactly the boolean
50
+ `true` is no, everywhere, so a mint that omits the field is safely
51
+ read as not bound. All fifteen spellings are in the vector.
52
+
53
+ - Mock knob `mintToHash`, off by default, and with it off nothing about
54
+ this reaches the wire: nothing is advertised anywhere and the pay
55
+ callback does not read `h` at all. `mintToHashAdvertisedOn` narrows
56
+ which of the three places claim it, changing only what is claimed and
57
+ never what the mint does, so a mint that shipped the feature before
58
+ the advertisement can be reproduced. Three misbehaviours, each needing
59
+ `mintToHash` alongside: `mintToHashAcceptsMalformedH`,
60
+ `mintToHashAcceptsUsedH` and `mintToHashIgnoresH`, the last of which
61
+ claims the capability, echoes it on the quote, and mints at the
62
+ payment hash anyway.
63
+
64
+ - New grader check `accepts an output hash on the mint quote
65
+ (mintToHash, optional)`. Soft where it should be: a mint that says
66
+ nothing anywhere and ignores `h` is reported as not offering it and
67
+ the run passes, which is every mint today and not a defect. A mint
68
+ that claims it is asked to prove the refusals, and an invoice issued
69
+ for a malformed `h` fails, because a wallet that pays for a quote the
70
+ mint will reject has bought nothing while the mint keeps the sats.
71
+ Disagreements between the three claims are named rather than failed:
72
+ none of them loses anyone money on its own, since a wallet reading a
73
+ missing field as false falls back to the preimage flow.
74
+
75
+ - New grader check `a bound mint credits the hash the wallet named
76
+ (optional)`, exported as `gradeBoundMint` and wired to a new
77
+ `--preimage` flag. Like the minted-value check it needs a payment the
78
+ runner cannot make itself: given the note URL carrying the wallet's
79
+ own secret and the preimage of the invoice that funded it, it fails a
80
+ mint that claimed the capability and did not bind. That is the one
81
+ worth failing rather than warning, because a wallet that believed the
82
+ claim stopped rotating on sight.
83
+
84
+ - Also refused now, on the withdraw callback: an `h` or `h2` colliding
85
+ with the output a bound quote is waiting to credit. Unreachable unless
86
+ `mintToHash` is on, so nothing about a mint without it changes.
87
+
88
+ - The case rule, stated as a rule rather than a verdict. Hex is
89
+ case-insensitive, so `AAAA...` and `aaaa...` are the same 32 bytes and
90
+ name one output, not two. A wallet MUST send `h` as 64 lowercase hex,
91
+ which keeps the producer side strict and is what every client here
92
+ does. A service SHOULD normalise case before comparing, and MUST NOT
93
+ treat an otherwise well-formed upper-case `h` as a different output
94
+ from its lowercase form: keying the string it was handed files the
95
+ note under the upper-case spelling and never finds it again when the
96
+ wallet asks the withdraw endpoint for its own lowercase secret, and
97
+ nobody is told. The vector says so with a worked pair and with two
98
+ upper-case cases, one binding where its lowercase twin binds and one
99
+ colliding where its twin collides, which is also what pins case being
100
+ normalised *before* the collision check. Every genuinely malformed
101
+ case is unchanged: empty, not hex, and both off-by-one lengths. The
102
+ mock normalises. The grader does not probe it in either direction: a
103
+ mint that normalises loses nobody money, and one that refuses upper
104
+ case outright is being strict rather than wrong, because the wallet
105
+ learns before it pays.
106
+
107
+ - New `vectors/derivation.json`: deterministic note secrets from a BIP39
108
+ seed, so that a wallet can be restored from words alone and two
109
+ implementations of the same wallet derive the same notes.
110
+ - `k1` is WALLET-generated in LUD-25 and the draft says nothing about how
111
+ one is produced, so a wallet is free to derive it instead of drawing it
112
+ at random. Nothing about this is observable on the wire: the mint still
113
+ only ever sees `sha256(k1)`.
114
+ - The scheme, in full, so this entry alone is enough to reimplement it.
115
+ `root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed bytes)`,
116
+ then `k1 = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))`.
117
+ Output is 32 bytes, lowercase hex, the same size as a payment preimage.
118
+ `host` is the mint host as the wallet stores it, lowercase, with the
119
+ port when there is one; `index` is decimal ASCII counting from 0. The
120
+ seed is the 64-byte BIP39 seed of a 12-word English mnemonic with no
121
+ passphrase.
122
+ - Cases cover the standard `abandon ... about` mnemonic at `mint.example`
123
+ for indices 0, 1, 2, 19 and 20 (19 and 20 straddle a 20-index gap
124
+ limit), a second mnemonic at the same host and index to show the seed
125
+ separates them, and a host carrying a port.
126
+ - Every case carries `seedHex` as well as the mnemonic, so an
127
+ implementation with no BIP39 library can still test the derivation
128
+ half on its own.
129
+ - `@scure/bip39` is a devDependency, used only to generate the file. The
130
+ published package gains no runtime dependency.
131
+
132
+ - Self-check covers the new file: every `k1` recomputes from its own
133
+ `seedHex`, every mnemonic validates against the English wordlist and
134
+ produces the stated seed, and no two cases collide.
135
+
136
+ - The retried mutation. Every mutation in LUD-25 is a GET, and HTTP stacks
137
+ retry a GET when the connection they used is dropped: Go's `net/http`
138
+ retries one that failed on a reused idle connection, the JDK's
139
+ `HttpClient` retries idempotent methods with no switch to turn it off.
140
+ The service therefore sees the byte-identical request twice, and by the
141
+ time the second arrives its inputs are burned. Answering it as an
142
+ already-spent input tells the holder the mutation never happened, and a
143
+ holder that believes it discards the only copy of a secret the service
144
+ really did mint a note against. Nobody is told; the money is gone.
145
+
146
+ - New `vectors/retried-mutation.json` fixes what identical means, so two
147
+ services do not give the same wallet two different answers to the same
148
+ dropped connection: the same input `k1` set (a set, so a merge naming
149
+ the same notes in a different order is the same merge), the same `h`,
150
+ the same `h2` and the same `amount`, present or absent alike. Twelve
151
+ cases, four of them replays and eight of them still double-spend
152
+ attempts, each turning on one thing being different. Anything that is
153
+ not a retry keeps today's refusal and today's reason string, so no
154
+ oracle appears for whoever holds a burned secret. Provenance is
155
+ recorded, never inferred: matching on "a note exists at `h`" alone
156
+ would let anyone holding a burned `k1` and any outstanding note id
157
+ pull a success out of a mint.
158
+
159
+ - Mock knob `retriedMutation`, `'refuse'` by default, which is exactly
160
+ what this mock has always done. `'replay'` answers a byte-identical
161
+ repeat with the original success. The replay path is a read: it burns
162
+ nothing, mints nothing and moves no balance, and the signature is
163
+ recomputed from the output id and amount rather than stored.
164
+
165
+ - New grader check `replays a retried mutation rather than refusing it
166
+ (optional)`, covering a rotate and a split so `h2` and the change
167
+ amount are covered too. Soft, because this is a SHOULD: a mint that
168
+ refuses the retry is reported as not having implemented it, not
169
+ failed. What is not soft is damage, so a retry that burns the output
170
+ or changes its value fails outright whichever answer it gives.
171
+
172
+ - The existing `refuses a replayed burn` check is untouched. It sends a
173
+ burned input with a *fresh* output hash, which is a genuine
174
+ double-spend attempt and a different request, and it still passes in
175
+ both modes.
176
+
177
+ - Three optional extensions a mint may publish, none of them in LUD-25,
178
+ all of them absent or off unless asked for. A mock started with no
179
+ options answers byte for byte what it answered before, and a mint
180
+ publishing none of them is not graded down.
181
+
182
+ - **Mint info** on the experimental discovery endpoint. Mock knobs
183
+ `name`, `description`, `contact` (`{nostr, email, url}`), `tosUrl`,
184
+ `motd`, `version` and `previousPubkeys`, appended after the existing
185
+ fields so nothing above them moves. `fees: {baseFeeMsat, feePpm}` is
186
+ emitted whenever a fee is configured: the structured twin of the fee
187
+ line in the payRequest metadata, which stays exactly as it was.
188
+ `nodeCapacity` is emitted as before, under that name.
189
+
190
+ - **Liabilities**. Mock knob `stats: true` (default `false`, and when
191
+ off `/stats` falls through to the same 404 every unknown path gets)
192
+ serving `GET /stats` as `{at, outstandingMsat, outstandingNotes,
193
+ pendingMsat, pendingMelts, oldestPendingMeltAgeSecs, localBalanceMsat?,
194
+ coverage?, reconciledAt}`. Notes here are not blinded, so a mint can
195
+ state what it owes exactly. A note mid-melt counts under `pending`
196
+ rather than `outstanding`: its value is committed, not free, and it
197
+ comes back if the payment fails. `coverage` is
198
+ `localBalanceMsat / outstandingMsat` to four decimal places, omitted
199
+ when nothing is owed. Knob `localBalanceMsat` sets what the node
200
+ claims to hold, so a mock can be told to look under-covered.
201
+
202
+ - **Signing-key rotation**. Mock knobs `previousPrivateKey` (an old key
203
+ the mock still holds; its public half joins `previousPubkeys` on its
204
+ own) and `previousPubkeys`. `state.creditNote(k1, amount,
205
+ {previousKey: true})` signs one note under the old key and leaves the
206
+ rest under the new, and `/_test/credit?...&key=previous` does the
207
+ same out of process. `signWithPreviousKey` issues every note under the
208
+ old key while still advertising the new one: the mid-rotation state a
209
+ mint passes through when the advertisement moves before the signer.
210
+
211
+ - Grader, all soft, all read-only:
212
+
213
+ - `publishes a mint address (experimental, optional)` keeps its name and
214
+ its existing assertions, and now checks the shape of the new fields
215
+ when they are present: the string fields non-empty, `tosUrl` and
216
+ `contact.url` fetchable, `contact.nostr` decoded as an npub rather
217
+ than pattern-matched, `contact.email` address-shaped, `fees` numeric
218
+ and not negative, `previousPubkeys` an array of 33-byte compressed
219
+ pubkeys in hex that does not merely restate the current one. A
220
+ malformed field is a warning, never a failure, and absence is neither.
221
+ The pass line names which fields it saw. A mint publishing its node
222
+ capacity as `nodeCapacityMsat` warns as well: the wire name carries no
223
+ suffix, and anything mapping the documented name reads undefined.
224
+
225
+ - New `publishes liabilities (optional)`. No `/stats`, or a `/stats`
226
+ that answers with something other than a liabilities body, is a
227
+ warning and nothing more. When it does answer, `outstandingMsat` must
228
+ be a number at or above zero and `coverage` must be numeric when
229
+ present. A node holding less than the mint owes warns rather than
230
+ fails: whether a mint is fully backed is the operator's to disclose,
231
+ and one that publishes an uncomfortable number is behaving better than
232
+ one that publishes nothing.
233
+
234
+ - `signs the notes it issues (optional)` accepts a signature recovering
235
+ any key the mint publishes, the current `mintPubkey` first and then
236
+ anything in `previousPubkeys`, and says which it was. Grading a note
237
+ issued before a rotation as forged would punish a mint for rotating
238
+ properly. `gradeNote` takes the list as `options.previousPubkeys`; the
239
+ CLI carries it across from the discovery endpoint on its own, which
240
+ `gradeMint` now hangs off the payRequest it returns as `mintAddress`.
241
+
242
+ - New `vectors/payment-request.json`: one holder asking another for value,
243
+ as a string a payer's wallet can act on. Not to be confused with
244
+ `pay-request.json`, which is the LUD-06 payRequest a mint publishes; no
245
+ mint is involved in reading this one.
246
+ - Encoding is the NUT-18 `creqA` idiom with our own prefix:
247
+ `lnurlcashreq1` followed by the request canonicalised under RFC 8785
248
+ (JCS) and carried as unpadded base64url. Canonical because two wallets
249
+ building the same request must produce the same string, or the payee
250
+ cannot match what came back to what they asked for. One encode case is
251
+ the same request with its keys in a different order, encoding
252
+ identically, and one carries a non-ASCII memo, which JCS leaves alone
253
+ rather than escaping.
254
+ - `amount` is whole sat as a decimal string, matching what the 402
255
+ payment-method schemas carry; the payer sends `amount * 1000` msat
256
+ exactly. Sub-sat requests are not a thing.
257
+ - Decode cases cover the round trips, an expiry still in the future, an
258
+ expired one, one expiring exactly now, a bad prefix, no prefix, a
259
+ payload that is not base64url, a payload that is not a JSON object, an
260
+ unknown version, a non-integer amount in four spellings, an empty
261
+ mints array, no `methodDetails` at all, a `to` that is neither an npub
262
+ nor address-shaped, a `to` that looks like an npub but does not
263
+ decode, a currency that is not sat, and a malformed id. Every refusal
264
+ names a reason from a declared list, and every declared reason has a
265
+ case.
266
+ - The file states `evaluatedAt`, a fixed unix time the decode cases are
267
+ read at, so an expiry means the same thing on every run.
268
+
269
+ - New `vectors/settle-for-value.json`: the decision table a server works
270
+ through when a bearer note arrives as payment, with the order it works
271
+ through it in.
272
+ - The order is the interesting part. Host, then what the mint says
273
+ (which is where a spent or in-flight note surfaces), then the
274
+ signature when one is required, then the value, then the rotate. A
275
+ note wrong in two ways is refused for the first reason in order, or
276
+ two servers explain the same note two different ways, and cases where
277
+ two things are wrong at once pin that.
278
+ - The rotate is deliberately last: it is both the ownership transfer and
279
+ the double-spend check, and a server that rotates before comparing the
280
+ amount has taken the money and refused the request.
281
+ - Outcomes are `accept`, `wrong-host`, `insufficient`, `bad-signature`,
282
+ `missing-signature`, `spent` and `pending`, all reachable. Also pinned:
283
+ hosts compare lowercased with the port included, an empty accepted-mint
284
+ list takes nothing rather than everything, a note worth exactly the
285
+ price is paid, and a signature that does not verify is accepted when
286
+ none was required, because the value came from asking the mint and the
287
+ mint is authoritative.
288
+
289
+ - Self-check reimplements both decision tables rather than calling the
290
+ generator's, because a check that calls the function it is checking
291
+ proves only that the function is deterministic.
292
+
293
+ - `signature.json` gains a `rotation` block: a note signed under a
294
+ previous key with both keys published (valid), the same signature byte
295
+ for byte with only the current key published (invalid), a
296
+ current-key signature alongside a published previous one (valid), and a
297
+ signature under a key that was never published (invalid). The cases
298
+ carry `mintPubkeys` as a list rather than the single `mintPubkey` the
299
+ existing cases carry, and live in their own block for that reason: a
300
+ verifier that knows nothing about rotation reads `cases` and is
301
+ completely unaffected by this release.
302
+
7
303
  ## 0.1.2 - 2026-08-21
8
304
 
9
305
  - The minted-value check took a band instead of a single number, because
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,6 +57,9 @@ 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 |
60
64
  | `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
61
65
 
@@ -93,9 +97,34 @@ must survive:
93
97
  | `--baseFeeMsat=N --feePpm=N` | advertises and withholds a mint fee |
94
98
  | `--roundFeeToSat` | rounds the withheld fee up to a whole sat — the note mints short of the formula |
95
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.
96
107
  | `--verify=false` | no LUD-21 endpoint at all, not merely unadvertised |
97
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 |
98
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 |
127
+
99
128
  As a library, for your own test suite:
100
129
 
101
130
  ```js
@@ -109,7 +138,9 @@ await mint.close()
109
138
 
110
139
  `mint.state` exposes `creditNote`, `noteState`, `settleMelt`, `failMelt` and
111
140
  the raw note and invoice maps, so a test can assert what the SERVICE
112
- 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.
113
144
 
114
145
  ## The grader
115
146
 
@@ -125,6 +156,48 @@ everyone on the payment's route knows the payment hash), whether an
125
156
  unknown note is reported distinguishably from a spent one, and the
126
157
  experimental mint address.
127
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
+
128
201
  One check needs a real payment, which the runner cannot make on its own.
129
202
  Given a freshly minted, never-rotated note and what its mint invoice was
130
203
  paid at, it compares the note's value against the advertised fee:
@@ -141,7 +214,17 @@ npx lnurlcash-conform mint@example.com --note='lnurlw://...?k1=...' --paid=50000
141
214
  # or --pr=<the mint invoice>, when it carries an amount
142
215
  ```
143
216
 
144
- 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:
145
228
 
146
229
  ```bash
147
230
  npx lnurlcash-conform mint@example.com --note='lnurlw://...?k1=...' --spend
@@ -151,9 +234,13 @@ It burns the note it is given and prints where the value ended up. It
151
234
  checks that the informational GET is idempotent and echoes the queried
152
235
  `k1`, that the URL's own `amount` is ignored, that a rotate with no `h` is
153
236
  refused, that a rotate returns no secret, that signatures verify against the
154
- 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,
155
239
  under LUD-25's fee algebra, when the mint's fee advertisement is known -
156
- 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
157
244
  mint must refuse atomically: a duplicated `k1` (which a careless mint counts
158
245
  twice, minting money from nothing), an output hash that collides with an
159
246
  existing note id (minting over it hands the output to whoever already knows
package/llms.txt CHANGED
@@ -11,6 +11,7 @@ Plain JSON in vectors/, loadable from any language, listed in
11
11
  vectors/index.json:
12
12
 
13
13
  signature.json offline verification, both recovery-id orderings
14
+ derivation.json deterministic note secrets from a BIP39 seed
14
15
  bech32.json LUD-01 encoding
15
16
  url-admission.json which URLs may be fetched (https, or http to loopback/.onion)
16
17
  input-resolution.json bech32, LUD-17, Lightning Address, bare domain
@@ -21,6 +22,10 @@ callbacks.json exact query per operation
21
22
  responses.json classifying replies incl. ambiguous outcomes
22
23
  withdraw-info.json the informational GET
23
24
  pay-request.json minting, LUD-11, LUD-21
25
+ payment-request.json lnurlcashreq1: asking another holder for value
26
+ settle-for-value.json the decision table for taking a note as payment
27
+ retried-mutation.json what makes a repeat a retry, not a double-spend
28
+ mint-to-hash.json naming the note you are buying: h on the pay callback
24
29
  lifecycle.json behavioural scenarios
25
30
  threat-suite.json transport/exposure scorecard, options vs attacks (non-normative)
26
31
 
@@ -37,14 +42,43 @@ serverGeneratedSecrets, meltNeverSettles, meltAlwaysFails, slowMs, sunset,
37
42
  baseFeeMsat, feePpm, verify=false, withdrawLinkForm=lnurlw (the lnurlw://
38
43
  spelling of withdrawLink; default is the plain https://, as lnurl-mint).
39
44
 
45
+ retriedMutation=replay (a byte-identical repeat of a mutation gets the
46
+ original success back instead of "already spent"; the default 'refuse' is
47
+ what the mock has always done).
48
+
49
+ mintToHash=true (the pay callback takes an optional h - 64 lowercase hex,
50
+ sha256 of a WALLET-chosen secret, the same thing h means on the withdraw
51
+ callback - and credits the minted note there, so the payment preimage is
52
+ not a valid k1). Advertised in three places: mintToHash: true on the
53
+ payRequest, the same on the mint address document, and the same echoed on
54
+ the pay callback's own response when THAT quote was bound.
55
+ mintToHashAdvertisedOn=payRequest,mintAddress,quote narrows which of the
56
+ three claim it (all three by default; it changes only what is claimed).
57
+ Misbehaviours, each needing mintToHash as well: mintToHashAcceptsMalformedH,
58
+ mintToHashAcceptsUsedH, mintToHashIgnoresH.
59
+
60
+ Optional extensions, all outside LUD-25, all absent or off by default:
61
+ name, description, contact, tosUrl, motd, version (mint info on
62
+ /.well-known/lnurlw/<user>); fees (emitted there whenever a fee is
63
+ configured); stats=true (serves GET /stats: at, outstandingMsat,
64
+ outstandingNotes, pendingMsat, pendingMelts, oldestPendingMeltAgeSecs,
65
+ localBalanceMsat, coverage, reconciledAt) with localBalanceMsat to set
66
+ what the node claims to hold; previousPrivateKey and previousPubkeys
67
+ (signing-key rotation), plus signWithPreviousKey to issue every note under
68
+ the old key. state.creditNote(k1, amount, {previousKey: true}) signs one.
69
+
40
70
  CLI: npx lnurlcash-mock-mint --port=8899 (nothing is payable)
41
71
 
42
72
  ## Grader
43
73
 
44
74
  npx lnurlcash-conform mint@example.com read-only
75
+ npx lnurlcash-conform mint@example.com --note=<url> --preimage=<hex> bound mint
45
76
  npx lnurlcash-conform mint@example.com --note=<url> --spend full, burns the note
46
77
 
47
- Exit code non-zero on any failure.
78
+ Exit code non-zero on any failure. The optional extensions are graded
79
+ softly: a mint publishing none of them loses nothing, a published field of
80
+ the wrong shape warns, and a signature under a key the mint publishes in
81
+ previousPubkeys is accepted as its own.
48
82
 
49
83
  ## The signature scheme, canonically
50
84
 
@@ -59,6 +93,59 @@ also accept recovery_id || r || s. Library layouts differ:
59
93
  @noble recid-leading; coincurve trailing; Rust secp256k1 64-byte compact +
60
94
  RecoveryId; Go btcec recid+27 leading.
61
95
 
96
+ ## Payment requests, canonically
97
+
98
+ lnurlcashreq1 || base64url(JCS(request)), no padding. JCS is RFC 8785:
99
+ keys sorted by UTF-16 code unit, no whitespace, non-ASCII left alone.
100
+ Canonical so two wallets building the same request produce the same
101
+ string. request = {v: 1, id: 16 hex, amount: whole sat as a DECIMAL
102
+ STRING, currency: "sat", methodDetails: {mints: [host, ...]}, to?: npub or
103
+ name@domain, memo?: string, expires?: unix seconds}. The payer sends
104
+ amount * 1000 msat exactly. Refusal reasons are enumerated in the vector.
105
+
106
+ ## Settling a note for value, in order
107
+
108
+ host in acceptedMints -> ask the mint (spent/pending surface here) ->
109
+ signature when required -> value >= price -> rotate. The rotate is LAST:
110
+ it is the settlement and the double-spend check in one call, and a server
111
+ that rotates before comparing the amount has taken the money and refused
112
+ the request. Full table in settle-for-value.json.
113
+
114
+ ## Naming the note you are buying
115
+
116
+ In LUD-25 a minted note's k1 is the payment preimage, so the preimage IS
117
+ the money, and two sets of untrusted people learn it: every routing node
118
+ on the payment path, and anyone who saw the invoice and polled LUD-21
119
+ verify with its payment hash. A WALLET MAY instead send h=<64 lowercase
120
+ hex> on the LUD-06 pay callback, the sha256 of a secret it chose; the
121
+ SERVICE credits the note there and the preimage opens nothing. Support is
122
+ advertised as mintToHash: true in three places - the payRequest (every
123
+ mint has one, so decide from this), the mint address document (the same
124
+ fact, corroboration), and the pay callback's own response (this quote was
125
+ bound; the only one that cannot be stale). Anything not exactly the
126
+ boolean true is no. A WALLET MUST send h as 64 lowercase hex; a SERVICE
127
+ SHOULD normalise case before comparing and MUST NOT read AAAA... and
128
+ aaaa... as two outputs, since keying the string handed over files the note
129
+ where the wallet never looks. A malformed h - not 32 bytes of hex in any
130
+ casing - is refused with "Invalid h." before any invoice exists; an h already naming a note, an invoice or another quote's
131
+ output is refused with "Invalid or already spent k1.", the same reason the
132
+ withdraw callback gives, so no oracle appears. A WALLET MUST persist its
133
+ secret BEFORE asking for the invoice. Purely additive: without h, nothing
134
+ changes. Table in mint-to-hash.json.
135
+
136
+ ## The retried mutation
137
+
138
+ Every mutation is a GET, and HTTP stacks retry a GET on a dropped
139
+ connection, so a SERVICE sees the identical request twice with its inputs
140
+ burned the second time. Answering "already spent" makes the holder discard
141
+ the only copy of a secret the SERVICE really did mint against. Identical =
142
+ same input k1 SET + same h + same h2 + same amount, present or absent
143
+ alike. Anything else naming a burned input is a double-spend and keeps
144
+ today's refusal, so no oracle appears. Provenance is recorded, not
145
+ inferred. The replay is a READ: nothing burns, nothing mints, the sig is
146
+ recomputed from (output id, amount). SHOULD, not MUST: the grader reports
147
+ it as unimplemented rather than failing. Table in retried-mutation.json.
148
+
62
149
  ## Fee arithmetic
63
150
 
64
151
  apply(gross, fee) = max(0, gross - base - floor(gross*ppm/1e6))