lnurlcash-conformance 0.1.2 → 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 +286 -0
- package/README.md +91 -4
- package/llms.txt +88 -1
- package/mock-mint/index.d.ts +155 -3
- package/mock-mint/index.mjs +362 -25
- package/package.json +4 -1
- package/runner/cli.mjs +19 -0
- package/runner/index.mjs +472 -4
- package/vectors/derivation.json +75 -0
- package/vectors/index.json +6 -1
- package/vectors/mint-to-hash.json +447 -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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,292 @@ 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
|
+
|
|
7
293
|
## 0.1.2 - 2026-08-21
|
|
8
294
|
|
|
9
295
|
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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))
|