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 +351 -0
- package/README.md +120 -9
- package/llms.txt +91 -2
- package/mock-mint/index.d.ts +171 -3
- package/mock-mint/index.mjs +392 -31
- package/package.json +4 -1
- package/runner/cli.mjs +19 -0
- package/runner/index.mjs +576 -24
- package/vectors/derivation.json +75 -0
- package/vectors/index.json +7 -1
- package/vectors/mint-to-hash.json +447 -0
- package/vectors/pay-request.json +28 -0
- package/vectors/payment-request.json +279 -0
- package/vectors/retried-mutation.json +243 -0
- package/vectors/settle-for-value.json +300 -0
- package/vectors/signature.json +67 -1
- package/vectors/threat-suite.json +327 -0
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
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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),
|
|
152
|
-
two notes)
|
|
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
|
|