lnurlcash-kit 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 CHANGED
@@ -3,6 +3,331 @@
3
3
  Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
4
4
  may carry breaking changes; pin an exact version.
5
5
 
6
+ ## 0.2.0 - 2026-08-22
7
+
8
+ ### Deterministic note secrets and restore from a seed
9
+
10
+ - `deriveNoteRoot(seed)`, `deriveNoteSecret(root, host, index)` and
11
+ `derivedSecretSource(root, host, start)` in `secrets.ts`, plus
12
+ `restoreNotes(baseUrl, root, host, {gap, start}, opts)` in a new
13
+ `restore.ts`. All additive; nothing existing changes shape.
14
+ - The scheme, in full, so this entry alone is enough to reimplement it:
15
+
16
+ ```
17
+ root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed)
18
+ k1_i = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))
19
+ ```
20
+
21
+ `seed` is raw bytes of any length. A 64-byte BIP39 seed (12 words,
22
+ English wordlist, no passphrase) is what wallets use in practice, but the
23
+ kit is seed-format agnostic and depends on no wordlist. `host` is the
24
+ mint host exactly as `serverOf` produces it: lowercase, port included
25
+ where there is one, so `127.0.0.1:8899` and `mint.example` derive
26
+ different secrets. `index` is decimal ASCII counting from 0, and the
27
+ separator is a single colon. The HMAC output is 32 bytes, rendered
28
+ lowercase hex, which is the size of a payment preimage and therefore
29
+ indistinguishable from a randomly drawn `k1` on the wire. `hashK1`
30
+ applies unchanged, so the mint only ever receives `sha256(k1)` and sees
31
+ nothing different from before.
32
+
33
+ Worked example. The BIP39 mnemonic `abandon abandon abandon abandon
34
+ abandon abandon abandon abandon abandon abandon abandon about` with an
35
+ empty passphrase gives the seed
36
+ `5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4`.
37
+ Its root is
38
+ `948f8f49347549cf2726e8b53f673a4185379344d2d7ba8877d3ded45d34d127`, and
39
+ index 0 at host `mint.example` is
40
+ `1f6016c80339b45dfdd1b3877c1a97d74b063cad54c4ccb866be39ed25ee2ab0`.
41
+ - Why it is in the kit rather than in a wallet: because the derivation is
42
+ written down once, the same words restore the same notes in a different
43
+ wallet, and the Kotlin, Python and Go ports agree with this one. That
44
+ cross-wallet portability is the point, and it is the reason the scheme
45
+ ships with a conformance vector.
46
+ - Counters are the wallet's, one per mint host. `derivedSecretSource` is a
47
+ `RandomSecret`, so it drops straight into
48
+ `LnurlcashOptions.randomSecret` and rotate, split and merge draw derived
49
+ secrets without knowing anything about derivation; `source.index()` reads
50
+ back the next unused index afterwards. A rotate consumes one index, a
51
+ split consumes two. Minting can draw from the same source where the mint
52
+ advertises `mintToHash` - see "Name the note you are buying" below - and
53
+ the note is then derived from birth rather than from its first rotate. Persist that counter in the SAME write that stages
54
+ the new records, and do it BEFORE the hash goes on the wire: a crash
55
+ between the bump and the request wastes an index, which costs nothing,
56
+ while a crash the other way round re-derives a secret the mint has
57
+ already seen and the second note minted at it collides with the first.
58
+ - `restoreNotes` walks indices from `start`, asking the mint what each
59
+ derived secret is worth. A live note is recorded; a note the mint reports
60
+ as spent still counts the index as used, since re-deriving it would mint
61
+ a duplicate id; a note the mint reports as pending is recorded with a
62
+ null amount for the caller to reconcile later; an unknown note counts
63
+ towards the gap. The walk stops after `gap` consecutive unknowns,
64
+ defaulting to 20, and `next` is one past the highest index the mint
65
+ recognised. It reads only, so an interrupted restore has changed nothing.
66
+ Any other failure is thrown rather than swallowed, because a short walk
67
+ reported as a finished one would leave the wallet re-deriving live
68
+ secrets.
69
+ - A restored note carries no signature and its `k1` has just been on the
70
+ wire, so rotate each one straight after restoring. That closes the
71
+ exposure and gets the signature in the same call.
72
+ - The seed is bearer material for every note the wallet will ever hold.
73
+ Store it the way the notes are stored, and never log it.
74
+ - `classifyNoteError` now returns `PendingNoteError` for the exact reason
75
+ string `pending`, which LUD-25 fixes verbatim. Previously only the
76
+ mutating callback classified it, so an informational GET on a note with a
77
+ melt in flight raised a bare `ServiceRejectedError` that callers had to
78
+ re-parse. `PendingNoteError` extends `ServiceRejectedError`, so anything
79
+ catching the parent is unaffected.
80
+
81
+ ### Name the note you are buying
82
+
83
+ - `requestInvoice(payCallback, amountMsat, {h, ...opts})` takes an optional
84
+ `h`: the sha256 of a secret the wallet chose, sent on the LUD-06 pay
85
+ callback exactly as `h` is sent on the withdraw callback. A mint that
86
+ accepts it credits the minted note at that hash on settlement, so the
87
+ wallet names the note it is buying instead of being handed one. The
88
+ options argument is the same object as before with one more optional
89
+ field on it, so every existing call is unaffected.
90
+ - Why it matters. Without `h` the payment preimage IS the money, and two
91
+ sets of people learn it without being trusted: every routing node on the
92
+ payment path, because that is how HTLC settlement works, and anyone who
93
+ merely saw the invoice, because they can poll LUD-21 `verify` with the
94
+ payment hash that travels inside it and take the preimage the moment it
95
+ settles. A QR on a desktop screen is exactly that. "Rotate immediately"
96
+ is a race against a thief in a tight polling loop with a warm connection.
97
+ Choosing the secret yourself is not a race at all.
98
+ - The capability appears in three places, and they say different things.
99
+ `PayRequestInfo.mintToHash` is "I accept an `h`", and is the one to
100
+ decide from: the payRequest is the only endpoint every mint has, it is
101
+ where a wallet already is at the moment it is about to mint, and it sits
102
+ alongside the `withdrawLink` the draft already hangs there for
103
+ LNURLcash's sake. `MintAddressInfo.mintToHash` is the same statement on
104
+ the experimental discovery document, kept for consistency with the other
105
+ capability fields there and the fallback for a mint that only says it
106
+ there. `InvoiceResult.mintToHash` is "I bound THIS quote to the hash you
107
+ named": per quote, and the one that matters at the moment money moves.
108
+ - So the order is: read the payRequest, fall back to the mint address, and
109
+ a mint that advertises in neither place ignores the `h`. Undefined means
110
+ the mint said nothing, which a wallet reads as no. Anything that is not
111
+ exactly the boolean `true` is no, everywhere, which matters on the
112
+ payRequest because that response is spread through and a truthy string
113
+ would otherwise land on the typed field.
114
+ - `InvoiceResult.mintToHash` being `false` is not a refusal: it means the
115
+ quote said nothing about `h`, and a mint may accept the parameter without
116
+ echoing it back. Decide from the advertisement, claim by probing.
117
+ - A malformed `h` is refused with `RequestRefusedError` before anything is
118
+ sent, so a wallet never pays for a quote the mint was always going to
119
+ reject. The hash is normalised to lowercase on the way out.
120
+ - Persist the secret BEFORE calling `requestInvoice`. Paying for a note and
121
+ then losing the secret is the one way this is worse than the preimage
122
+ scheme, and persisting first removes it.
123
+ - `claimMintedNote(withdrawLink, k1, opts)` is the claim half, returning
124
+ `{state, k1, amountMsat, callback}` with `state` one of `'minted'`,
125
+ `'unminted'`, `'pending'` or `'spent'`. A wallet that chose its own
126
+ secret has nothing to fetch, so it asks the mint what the note at that
127
+ secret is worth and a live answer is the claim. Poll it while the invoice
128
+ is unpaid; it reads only, so an `'unminted'` answer has changed nothing.
129
+ A mint that cannot be reached throws rather than reporting `'unminted'`,
130
+ which a caller would fairly read as "not yet" and give up on.
131
+ - No rotate follows a bound claim, and that is the point. The preimage
132
+ scheme needs one because the mint generated the secret and `verify` hands
133
+ it to anyone who saw the invoice. Here the mint never had it and no third
134
+ party can learn it, so the note belongs to the wallet from the moment it
135
+ exists. The claim GET does disclose the secret to the mint it is a claim
136
+ on, which is not the same exposure, and a wallet that wants an offline
137
+ signature on the note can still rotate to get one.
138
+ - **This changes what the derivation section above says about minting.**
139
+ Until now a freshly minted note was never derived: its secret was the
140
+ mint's preimage, which nothing in a seed produces, so the note existed
141
+ outside the wallet's derivation until the immediate rotate moved it back
142
+ in. Draw the secret from `deriveNoteSecret` at the next index, send its
143
+ hash as `h`, and the minted note is seed-derived from birth. `restoreNotes`
144
+ finds it with no rotate having happened at all, which is what closes the
145
+ window where a wallet that crashed between paying and rotating could not
146
+ recover the note from its words. The counter rule is unchanged and applies
147
+ to the mint too: persist the bumped index in the same write that stages the
148
+ pending mint, before the hash goes on the wire.
149
+ - Purely additive on the wire. A mint that does not offer `mintToHash`
150
+ ignores the parameter, keys the note by the preimage as it always has,
151
+ and the LUD-21 verify path is unchanged and still the way in.
152
+ - `createClient(opts).requestInvoice(payCallback, amountMsat, h?)` takes the
153
+ hash as a third argument, and `claimMintedNote` is bound alongside it.
154
+
155
+ ### Mint info, and verifying against a key history
156
+
157
+ - `MintAddressInfo` gains the operator fields a mint may publish on the
158
+ experimental discovery endpoint: `name`, `description`, `contact`
159
+ (`{nostr?, email?, url?}`), `tosUrl`, `motd`, `fees` (`{baseFeeMsat,
160
+ feePpm}`, the same shape `parseMintFee` returns, so it feeds
161
+ `applyMintFee` and `mintFeeBand` directly), `version` and
162
+ `previousPubkeys`. All optional, all absent on most mints, and none of
163
+ them is needed to spend a note.
164
+ - `fetchMintAddress` now maps the response field by field instead of
165
+ spreading it through. The spread is what hid `nodeCapacity` under its
166
+ wire name until 0.1.1, and it also put whatever a mint decided to send on
167
+ a typed object with no type behind it. An unrecognised wire field is now
168
+ dropped rather than carried, so a caller reading one off the object with
169
+ a cast will find it undefined; the version of this library that
170
+ understands that field will map it deliberately.
171
+ - `nodeCapacityMsat` is now populated from either spelling. The bare
172
+ `nodeCapacity` is what the reference mint, the mock and everything that
173
+ copied them emit, and it wins where a mint sends both; one live mint
174
+ emits `nodeCapacityMsat` instead, which previously survived only by
175
+ riding the spread.
176
+ - `verifyNoteSignature(k1, amountMsat, sig, keys)` accepts a single pubkey
177
+ or an array, and is true if any of them signed. New
178
+ `verifyNoteSignatureAgainst(...)` returns `{valid, pubkey}` so a caller
179
+ learns WHICH key signed. An empty array is a rejection, never a pass.
180
+ - Why: a mint that rotates its signing key would otherwise invalidate every
181
+ outstanding signature at once, and a wallet holding only the new key would
182
+ read every note it already had as forged. The mint publishes its retired
183
+ keys as `previousPubkeys`, the wallet verifies against the current key and
184
+ that history together, and a note that verifies only against a retired key
185
+ is one to rotate so the mint re-signs it. Only one recovery is performed
186
+ per signature layout, so a long key history costs a string comparison
187
+ each, not a recovery each.
188
+
189
+ ### Accepting a note as payment
190
+
191
+ - `settleNoteForValue(noteUrl, {mints, minMsat, requireSignature}, opts)`
192
+ in a new `settle.ts`, returning `{note, newUrl}`. It is the decision
193
+ sequence every server accepting a bearer note performs, written once:
194
+ parse the input; check the note's mint is one the server accepts, before
195
+ any round trip, so an unaccepted mint is never contacted; fetch the
196
+ authoritative value and the mint's signing key; verify the signature over
197
+ that value where the server demands one; compare against the price;
198
+ rotate.
199
+ - The rotate is the settlement, not bookkeeping after it. It burns the
200
+ secret the payer handed over and mints a replacement only the server
201
+ knows, in one atomic request at the mint, so it transfers ownership and
202
+ rejects a replay in the same call: a second presentation of the same note
203
+ finds it spent. A server that checks a note's value and grants access
204
+ without rotating has verified a photograph of a banknote.
205
+ - New `InsufficientValueError`, extending `ServiceRejectedError` and
206
+ carrying `amountMsat` and `minMsat`, so a server can say how short a note
207
+ was rather than "declined". An unaccepted mint or a signature that will
208
+ not verify raises `ServiceRejectedError`; a spent note passes
209
+ `NoteSpentError` through; a note with a melt in flight raises
210
+ `PendingNoteError`, which is worth retrying rather than refusing. Nothing
211
+ is burned by any refusal, so a rejected note is still the payer's, intact.
212
+ - `AmbiguousMutationError` from the rotate reaches the caller unchanged,
213
+ carrying the fresh secret. If that request landed, the secret is the money
214
+ and it belongs to the server: persist it before anything else.
215
+ - An empty `mints` list accepts nothing. A note is a claim on one specific
216
+ operator, and "any mint" is not a policy a server should be able to hold
217
+ by accident.
218
+ - The value compared against the price is always the one the mint states.
219
+ A note URL's own `amount` is a claim by whoever encoded it, and a
220
+ signature, where one is required, is checked over the mint's figure, so an
221
+ inflated URL fails rather than passing on a signature issued for the true
222
+ amount.
223
+
224
+ ### A retried mutation no longer loses the secret it minted
225
+
226
+ - Every mutation is a GET, and HTTP stacks retry a GET whose connection
227
+ dropped: browsers on a stale keep-alive connection, Go's `net/http` on a
228
+ reused idle one, the JDK's `HttpClient` on any idempotent method with no
229
+ switch to stop it. The retry is byte-identical, so the mint sees the same
230
+ request twice and answers the second with its ordinary refusal for a
231
+ burned input, its inputs having been burned by the first. The caller was
232
+ told the mutation never happened while a note sat at the hash it had
233
+ disclosed, and the only copy of that secret went out of scope with the
234
+ call. The money was not stolen, it was made unspendable by anyone at all.
235
+ - `rotateNote`, `splitNote` and `mergeNotes` now attach the secrets they
236
+ generated to a `NoteSpentError` or a `NoteUnknownError`, the way
237
+ `AmbiguousMutationError` already carried them. New `newSecretsOf(err)`
238
+ reads them off any error in one line, returning an empty array when there
239
+ are none, so a caller's catch block does not have to know which family it
240
+ is holding.
241
+ - Only those two classes carry anything. They are the refusals that mean
242
+ "this input is not spendable", which is exactly what a landed-then-retried
243
+ mutation looks like. `PendingNoteError` means the input is alive and
244
+ untouched, and a refusal on policy grounds (dust, a fee, a sunsetting
245
+ mint) burned nothing, so both carry nothing and a caller may discard its
246
+ staged records at once.
247
+ - The classification itself is unchanged, deliberately. At the wire a retry
248
+ and a genuine double spend are the same answer, and whether the input was
249
+ live when the request went out is knowledge the caller has and this
250
+ library does not. So it hands back the secret rather than a verdict:
251
+ persist it, then ask the mint what the note at that secret is worth. A
252
+ live note means the mutation landed.
253
+
254
+ ### The mint's signing key is called mintPubkey
255
+
256
+ - `MintAddressInfo.mintPubkey` carries the wire value unchanged and is the
257
+ name to reach for. `nodePubkey` remains, populated with the same value,
258
+ and is deprecated: it will be removed at the next breaking change.
259
+ Nothing breaks in this release.
260
+ - The two keys in a discovery document are different keys. `mintPubkey` is
261
+ what a note's signature verifies against; the Lightning node's identity
262
+ key is embedded in `nodeUri`. Every other `node*` field on the type
263
+ really is about the node - alias, colour, capacity, channel and peer
264
+ counts - so the signing key was the one exception, and its name said
265
+ nothing about that. A reader who pulled the pubkey out of `nodeUri` and
266
+ tried to verify a note with it got a failure that explained nothing.
267
+ - It also makes the package internally consistent: the same key is already
268
+ called `mintPubkey` on a note's own info, so a reader moving between the
269
+ two objects met one key under two names.
270
+
271
+ ### Payment requests
272
+
273
+ - `encodePaymentRequest(request)`, `decodePaymentRequest(string, {now})`,
274
+ `isPaymentRequest(string)` and `paymentRequestAmountMsat(request)` in a
275
+ new `request.ts`, with the `PaymentRequest` type and the
276
+ `PAYMENT_REQUEST_PREFIX` constant.
277
+ - A request names an amount, the mints the payee accepts and where to
278
+ deliver, so a payer's wallet can split a note and send it straight across
279
+ instead of doing a mint-and-zap round trip through the mint's node for
280
+ something neither party needed a node for:
281
+
282
+ ```json
283
+ {"v": 1, "id": "0123456789abcdef", "amount": "500", "currency": "sat",
284
+ "methodDetails": {"mints": ["mint.example"]},
285
+ "to": "npub1...", "memo": "lunch", "expires": 1756000000}
286
+ ```
287
+
288
+ `id` is 16 lowercase hex characters, `amount` is whole sats as a decimal
289
+ string with no leading zeros, `to` is a Nostr npub or a Lightning Address
290
+ and is absent on a charge request served over HTTP, and `expires` is unix
291
+ seconds. `methodDetails` also accepts an optional `mintPubkeys`.
292
+ - Encoded as `lnurlcashreq1` followed by base64url (unpadded) of the
293
+ request serialised as RFC 8785 JCS-canonical JSON: keys sorted by UTF-16
294
+ code unit at every level, no whitespace, integers only. Canonical because
295
+ a request is a thing people copy, quote back and match against a record of
296
+ what they asked for, so two encodings of the same request must be the same
297
+ string. This is NUT-18's `creqA` idiom with our own prefix, and it stays
298
+ short enough for a single static QR.
299
+ - The object is the same charge request an HTTP 402 lnurlcash rail serves,
300
+ plus the transport fields a wallet-to-wallet send needs, so one encoder
301
+ covers both.
302
+ - Validation is strict in both directions, an unrecognised field included:
303
+ quietly paying a request one did not fully understand is how a payer pays
304
+ the wrong person. Every refusal is a `ProtocolError`.
305
+ - `amount` is in sat, which is the one exception to this library's
306
+ msat-everywhere rule, because the field is shared with the 402 rail and
307
+ the Cashu payment method and both count in whole units.
308
+ `paymentRequestAmountMsat` converts exactly, so nothing has to multiply by
309
+ hand.
310
+ - An expired request does not decode, since paying one is always wrong. At
311
+ the expiry counts as expired, not merely past it, so a payer whose clock
312
+ is a second behind the payee's does not send a note against a request the
313
+ payee has already written off. `isPaymentRequest` still returns true for
314
+ it, so a scanner routes it to the pay screen and the holder is told it
315
+ lapsed rather than that their input was gibberish, and
316
+ `decodePaymentRequest(input, {now: 0})` returns it for display.
317
+ - `to` is checked rather than shape-matched: an npub must survive its bech32
318
+ checksum. A request naming a destination nobody can route to is a request
319
+ nobody can pay, and a mistyped npub passes any regex.
320
+ - **`lnurlcashreq1` means the schema above and nothing else.** An earlier
321
+ HTTP 402 rail emitted a shorter object under the same prefix -
322
+ `{"a": 21, "m": ["mint.example"], "u": "sat"}`, with the amount as a
323
+ number, no version and no id - and two schemas under one prefix cannot
324
+ both be right. This is the one the conformance vectors pin, so it is the
325
+ definition. The decoder reads the short form anyway, because returning
326
+ nothing for a string it can plainly understand helps no one, and gives it
327
+ a deterministic id derived from its own canonical bytes so the same
328
+ challenge always reads back as the same request. Nothing in this library
329
+ ever emits the short form.
330
+
6
331
  ## 0.1.2 - 2026-08-21
7
332
 
8
333
  - `mintFeeBand` and `withinMintFeeBand`. LUD-25 states the mint fee as
package/README.md CHANGED
@@ -122,6 +122,31 @@ This is not hypothetical: the same hazard broke the
122
122
  development, by two different mechanisms, and is now a named scenario in the
123
123
  conformance vectors.
124
124
 
125
+ Because a retry cannot always be prevented, a mutation refused with the
126
+ input already spent or unknown carries its outputs anyway:
127
+
128
+ ```ts
129
+ try {
130
+ await rotateNote(callback, oldK1)
131
+ } catch (err) {
132
+ const secrets = newSecretsOf(err) // works on both error families
133
+ if (secrets.length) {
134
+ await save(secrets) // first. always.
135
+ // then ask: is there a note at that secret?
136
+ const fate = await probeBurnedNote(buildNoteUrl(base, secrets[0]))
137
+ // 'live' -> the mutation landed and you own the output
138
+ // 'gone' -> the refusal was honest, discard
139
+ }
140
+ }
141
+ ```
142
+
143
+ The class does not change: at the wire a retry and a genuine double spend are
144
+ the same answer, and whether your input was live when the request went out is
145
+ something you know and this library does not. So it hands back the secret
146
+ rather than a verdict. A refusal that cannot be a landed mutation, such as a
147
+ mint refusing on policy grounds, carries nothing, and you can discard your
148
+ staged records at once.
149
+
125
150
  **4. A melt's `OK` means "in flight", not "spent".** The service pays
126
151
  asynchronously and only burns the note once the payment settles, restoring
127
152
  it if the payment fails. A failed melt is never reported back through the
@@ -136,6 +161,12 @@ who saw the unpaid invoice can poll for it — the payment hash travels inside
136
161
  the invoice. First rotater wins. A wallet that rotates on settlement wins by
137
162
  construction; a human copying a preimage by hand does not.
138
163
 
164
+ That is a race, and the way to win a race is not to enter it. Where a mint
165
+ advertises `mintToHash`, name the note you are buying and the preimage is
166
+ not its secret at all: see
167
+ [Minting a note you named yourself](#minting-a-note-you-named-yourself).
168
+ The rule above stands for every mint that does not offer it.
169
+
139
170
  ## Offline verification
140
171
 
141
172
  A service may sign each note with its Lightning node identity key, so a
@@ -156,6 +187,289 @@ The signature commits to the note's *hash*, not its secret — so you can
156
187
  prove a mint issued a note, to expose one that will not honour it, without
157
188
  handing over what would let anyone spend it.
158
189
 
190
+ ### When a mint rotates its signing key
191
+
192
+ Rotating invalidates nothing. The notes already issued are still genuine and
193
+ their signatures still verify, but only against the key that made them, so a
194
+ wallet holding the new key alone would suddenly read every outstanding note
195
+ as forged. A mint publishes the keys it has retired as `previousPubkeys` on
196
+ its mint address, and verification takes the whole set:
197
+
198
+ ```ts
199
+ const {mintPubkey, previousPubkeys = []} = await fetchMintAddress(addressUrl)
200
+ const check = verifyNoteSignatureAgainst(k1, amountMsat, sig, [
201
+ mintPubkey,
202
+ ...previousPubkeys
203
+ ])
204
+ // check.pubkey names the key that signed. A note that verifies only against
205
+ // a retired one is worth rotating: the mint re-signs it under the current key.
206
+ ```
207
+
208
+ `verifyNoteSignature` takes the same one-or-many argument and returns a plain
209
+ boolean. An empty list is a rejection, not a pass.
210
+
211
+ ### What else a mint says about itself
212
+
213
+ `mintPubkey` is the key note signatures verify against, and it is *not* the
214
+ Lightning node's key: that one is embedded in `nodeUri`, and every other
215
+ `node*` field really is about the node. Verifying a note against the key
216
+ pulled out of `nodeUri` fails, and the failure says nothing about why.
217
+ `nodePubkey` is a deprecated alias for the same value, kept for one release.
218
+
219
+ `fetchMintAddress` reads the experimental discovery endpoint, and a mint may
220
+ publish a `name`, a `description`, `contact` details, a `tosUrl`, a `motd`,
221
+ its structured `fees` and its software `version` there. All optional, all
222
+ absent on most mints, and none of it is needed to spend a note. Surface the
223
+ MOTD when it changes: it is how an operator announces maintenance, a fee
224
+ change or a sunset date, and there is no other channel to a bearer holder.
225
+ The endpoint carries no LUD number, so treat a rejection as "no extra
226
+ information" and fall back to `fetchPayRequest`.
227
+
228
+ ## Secrets
229
+
230
+ A note's `k1` is generated by the wallet, and LUD-25 says nothing about how.
231
+ Draw it from a CSPRNG and the note lives only in your wallet file: the mint
232
+ holds `sha256(k1)` and cannot tell you apart from a stranger, so a lost file
233
+ is lost money. Derive it from a seed instead and the wallet restores from
234
+ words alone.
235
+
236
+ ```
237
+ root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed)
238
+ k1_i = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))
239
+ ```
240
+
241
+ `seed` is raw bytes. A 64-byte BIP39 seed is what wallets use in practice,
242
+ but nothing here depends on BIP39, so a device with its own entropy store
243
+ derives the same way and no consumer carries a wordlist it does not need.
244
+ `host` is the mint host exactly as `serverOf` spells it, lowercase and with
245
+ the port where there is one, so `127.0.0.1:8899` and `mint.example` never
246
+ collide. `index` is decimal ASCII from 0. The output is 32 bytes of hex, the
247
+ size of a payment preimage, and the mint sees nothing different: it only
248
+ ever receives `sha256(k1)`.
249
+
250
+ Because the scheme is written down here rather than invented per wallet, the
251
+ same words restore the same notes in a *different* wallet. That
252
+ cross-wallet portability is the point of putting it in the kit, with
253
+ [a conformance vector](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
254
+ for the ports.
255
+
256
+ ```ts
257
+ import {deriveNoteRoot, derivedSecretSource, restoreNotes} from 'lnurlcash-kit'
258
+
259
+ const root = deriveNoteRoot(seed) // seed: Uint8Array, yours to keep safe
260
+ const source = derivedSecretSource(root, 'mint.example', counter)
261
+
262
+ // hand it to any mutating call and the fresh secrets come from the seed
263
+ const {k1, change} = await splitNote(callback, [note], 40_000, {randomSecret: source})
264
+ saveCounter('mint.example', source.index()) // a split consumed two indices
265
+ ```
266
+
267
+ Persist that counter in the **same write that stages the new records**, and
268
+ do it **before** the hash goes on the wire. A crash between the bump and the
269
+ request wastes an index, which costs nothing. A crash the other way round
270
+ re-derives a secret the mint has already seen, and the second note minted at
271
+ it collides with the first. This is the rule wallets get wrong.
272
+
273
+ Restoring walks the indices and asks the mint what each derived secret is
274
+ worth:
275
+
276
+ ```ts
277
+ const {found, next} = await restoreNotes('https://mint.example/w', root, 'mint.example')
278
+ ```
279
+
280
+ A live note is recorded, a spent index still counts as used (re-deriving it
281
+ would mint a duplicate), an unknown one counts towards the gap, and the walk
282
+ stops after 20 consecutive unknowns. `next` is the counter to resume from.
283
+ Restoring puts every `k1` it walks on the wire and a restored note carries no
284
+ signature, so rotate each one straight after: that closes the exposure and
285
+ gets the signature in the same call.
286
+
287
+ The seed is bearer material for every note the wallet will ever hold. Store
288
+ it the way you store the notes, and never log it.
289
+
290
+ ## Minting a note you named yourself
291
+
292
+ By default the secret of a freshly minted note is the invoice's payment
293
+ preimage, which means the money is a thing two sets of people learn without
294
+ being trusted. Every routing node on the payment path sees it, because that
295
+ is how HTLC settlement works. And anyone who merely saw the unpaid invoice
296
+ can poll LUD-21 `verify` with the payment hash inside it and take the
297
+ preimage the moment it settles, which is what a QR code on a desktop screen
298
+ hands out.
299
+
300
+ A mint can instead bind the note to a hash you supply, the same `h` you
301
+ already send on every rotate, split and merge. Then you chose the secret,
302
+ nobody else ever had it, and the preimage is an ordinary payment proof.
303
+
304
+ ```ts
305
+ import {
306
+ fetchPayRequest, requestInvoice, claimMintedNote,
307
+ deriveNoteRoot, deriveNoteSecret, hashK1
308
+ } from 'lnurlcash-kit'
309
+
310
+ const pay = await fetchPayRequest(payUrl) // a Lightning Address resolves here
311
+ if (!pay.mintToHash) { /* preimage path, rotate on claim */ }
312
+
313
+ const root = deriveNoteRoot(seed)
314
+ const k1 = deriveNoteSecret(root, 'mint.example', nextIndex)
315
+ await persist({k1, index: nextIndex}) // BEFORE the invoice. always.
316
+
317
+ const {pr} = await requestInvoice(pay.callback, 21_000, {h: hashK1(k1)})
318
+
319
+ // pay `pr`, then poll. No verify, because you already know the secret.
320
+ const claim = await claimMintedNote(pay.withdrawLink!, k1)
321
+ // 'unminted' -> not settled yet, ask again
322
+ // 'minted' -> claim.amountMsat is what it is worth, claim.callback melts it
323
+ ```
324
+
325
+ Ask before you buy. `mintToHash` is how a mint says it accepts the parameter,
326
+ and reading it first is the difference between naming your own note and paying
327
+ for one whose secret three other parties can learn. It turns up in three
328
+ places, saying three different things:
329
+
330
+ | Where | What it means |
331
+ | --- | --- |
332
+ | `PayRequestInfo.mintToHash` | "I accept an `h`." **Decide from this one.** |
333
+ | `MintAddressInfo.mintToHash` | the same fact on the experimental discovery document |
334
+ | `InvoiceResult.mintToHash` | "I bound *this quote* to the hash you named" |
335
+
336
+ Prefer the payRequest: it is the only endpoint every mint has, it is where
337
+ your wallet already is when it is about to mint, and it sits next to the
338
+ `withdrawLink` the draft already hangs there for LNURLcash's sake. Fall back
339
+ to `fetchMintAddress` for a mint that only advertises on that document. A mint
340
+ that says it in neither place ignores the `h`, keys the note by the preimage
341
+ as it always has, and the verify path is unchanged. Anything that is not
342
+ exactly `true` is a no, everywhere, and `false` on the invoice result is
343
+ silence rather than a refusal, so decide from the advertisement and claim by
344
+ probing.
345
+
346
+ **Persist the secret before you ask for the invoice.** Paying for a note and
347
+ then losing the secret is the one way this is worse than the preimage scheme,
348
+ and persisting first removes it. Derive it rather than drawing it at random
349
+ and there is a second reason: the note is then seed-derived *from birth*, so
350
+ `restoreNotes` finds it without any rotate having happened. Under the preimage
351
+ scheme a minted note lives outside your derivation until the immediate rotate
352
+ pulls it in, and a wallet that crashes in that window cannot recover the note
353
+ from its words.
354
+
355
+ No rotate follows a bound claim. The preimage scheme needs one because the
356
+ mint made the secret and hands it out; here the mint never had it, so the note
357
+ is yours from the moment it exists. The claim GET does show the secret to the
358
+ mint it is a claim on, which is a different thing from showing it to whoever
359
+ scanned the QR, and you can still rotate if you want the offline signature.
360
+
361
+ ## Asking to be paid
362
+
363
+ "Send me 500 sat" today means handing over a Lightning Address, which is a
364
+ mint-and-zap round trip through the mint's node for something neither party
365
+ needed a node for. A payment request names the amount, the mints the payee
366
+ will accept and where to deliver, and the payer's wallet splits a note and
367
+ sends it straight across. Wallet to wallet; the mint only ever sees a split.
368
+
369
+ ```ts
370
+ import {encodePaymentRequest, decodePaymentRequest, paymentRequestAmountMsat} from 'lnurlcash-kit'
371
+
372
+ const encoded = encodePaymentRequest({
373
+ v: 1,
374
+ id: '0123456789abcdef', // 8 random bytes, hex
375
+ amount: '500', // whole sats, decimal string
376
+ currency: 'sat',
377
+ methodDetails: {mints: ['mint.example']},
378
+ to: 'npub1...', // or alice@mint.example
379
+ memo: 'lunch'
380
+ })
381
+ // lnurlcashreq1eyJhbW91bnQiOiI1MDAiLCJjdXJyZW5jeSI6InNhdCIsImlkIjoiMDEy...
382
+
383
+ const request = decodePaymentRequest(scanned) // throws ProtocolError if it is not one
384
+ const owed = paymentRequestAmountMsat(request) // 500_000
385
+ ```
386
+
387
+ The encoding is `lnurlcashreq1` followed by base64url of the request as
388
+ [JCS](https://www.rfc-editor.org/rfc/rfc8785)-canonical JSON, which is
389
+ NUT-18's `creqA` idiom with our own prefix. Canonical because a request is a
390
+ thing people copy, quote back and match against a record of what they asked
391
+ for: two encodings of the same request must be the same string, or none of
392
+ that works. It stays short enough for one static QR.
393
+
394
+ The object is the same charge request an HTTP 402 lnurlcash rail serves,
395
+ plus the transport fields a wallet-to-wallet send needs, so one encoder
396
+ covers both. Validation is strict in both directions, including an
397
+ unrecognised field: quietly paying a request you did not fully understand is
398
+ how you pay the wrong person.
399
+
400
+ `amount` is in **sat**, and it is the one exception to this library's
401
+ msat-everywhere rule. That is deliberate: the field is shared with the 402
402
+ rail and the Cashu payment method, both of which count in whole units. Use
403
+ `paymentRequestAmountMsat` rather than multiplying by hand.
404
+
405
+ An expired request will not decode, because paying one is always wrong. At
406
+ the expiry counts as expired, not merely past it, so a payer whose clock is a
407
+ second behind the payee's does not send a note against a request the payee
408
+ has already written off. `isPaymentRequest` still returns true for it, so a
409
+ scanner routes it to the pay screen and the user is told it lapsed rather
410
+ than that their input was gibberish; `decodePaymentRequest(input, {now: 0})`
411
+ returns it for display.
412
+
413
+ `to` is checked, not merely shape-matched: an npub has to survive its bech32
414
+ checksum, because a request naming a destination nobody can route to is a
415
+ request nobody can pay.
416
+
417
+ **`lnurlcashreq1` means this schema and nothing else.** An earlier HTTP 402
418
+ rail emitted a shorter object under the same prefix (`{"a": 21, "m":
419
+ ["mint.example"], "u": "sat"}`: amount as a number, no version, no id). Two
420
+ schemas under one prefix cannot both be right, and this is the one the
421
+ vectors pin. The decoder reads the short form anyway, because refusing a
422
+ string it can plainly understand helps nobody, and gives it a deterministic
423
+ id derived from its own bytes. Nothing here ever emits it.
424
+
425
+ ## Taking a note as payment
426
+
427
+ A server that accepts bearer notes for something makes the same decisions
428
+ every time, in this order, and `settleNoteForValue` is that order written
429
+ once:
430
+
431
+ ```ts
432
+ import {settleNoteForValue, InsufficientValueError} from 'lnurlcash-kit'
433
+
434
+ try {
435
+ const {note, newUrl} = await settleNoteForValue(offered, {
436
+ mints: ['mint.example'], // hosts this server accepts. An empty list accepts nothing.
437
+ minMsat: 21_000, // the price
438
+ requireSignature: false // demand offline proof of issuance first
439
+ })
440
+ grantAccess() // note.k1 is yours now; newUrl is a note to store or melt
441
+ } catch (err) {
442
+ if (err instanceof InsufficientValueError) refuse(err.amountMsat, err.minMsat)
443
+ else refuse()
444
+ }
445
+ ```
446
+
447
+ 1. the input parses as a note at all
448
+ 2. its mint is one this server accepts (checked before any round trip, so an
449
+ unaccepted mint is never contacted)
450
+ 3. an informational GET for the **authoritative** value and the mint's key
451
+ 4. the signature, where the server demands one, over the value the mint
452
+ stated rather than the one the URL claims
453
+ 5. that value covers the price
454
+ 6. **rotate**
455
+
456
+ Step 6 is the settlement, not bookkeeping after it. Rotating burns the
457
+ secret the payer handed over and mints a replacement only this server knows,
458
+ in one atomic request: it transfers ownership and rejects a replay in the
459
+ same call, because a second presentation of the same note finds it spent. A
460
+ server that checks a note's value and grants access without rotating has
461
+ verified a photograph of a banknote.
462
+
463
+ Refusals are typed, and nothing is burned by any of them: `ServiceRejectedError`
464
+ for an unaccepted mint or a signature that will not verify,
465
+ `InsufficientValueError` (carrying both amounts) for a note worth too
466
+ little, `NoteSpentError` for one already spent or presented twice,
467
+ `PendingNoteError` for one with a melt in flight, which is worth retrying
468
+ rather than refusing outright. `AmbiguousMutationError` from the rotate is
469
+ the case to handle with care: persist `err.newSecrets` before anything else,
470
+ because if the request landed then that secret is the money and it is now
471
+ this server's.
472
+
159
473
  ## Scope
160
474
 
161
475
  This library speaks the protocol. It does not store notes, hold keys, manage