lnurlcash-kit 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,348 @@
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.1 - 2026-08-22
7
+
8
+ - `WithdrawRequestInfo` carries `payLink`, the way home a SERVICE may
9
+ publish on a note's informational GET. It is the reverse of the
10
+ `withdrawLink` a payRequest advertises, and it is the only route a
11
+ bearer-note wallet has to a mint's discovery document: the document lives
12
+ under a username the note never mentions and cannot be guessed from the
13
+ callback. Without it, a WALLET that has only ever received notes cannot
14
+ read the mint's `previousPubkeys`, so an announced key rotation is
15
+ indistinguishable from a substituted key and gets refused.
16
+ - A `payLink` on any origin but the note's own is dropped rather than
17
+ passed on, so a caller can treat its presence as the fact it looks like.
18
+ Whoever controls the host controls the pin anyway, which is TOFU's own
19
+ argument, but that argument does not stretch to letting a SERVICE
20
+ nominate a THIRD party to vouch for its key history, and refusing costs
21
+ nothing.
22
+
23
+ ## 0.2.0 - 2026-08-22
24
+
25
+ ### Deterministic note secrets and restore from a seed
26
+
27
+ - `deriveNoteRoot(seed)`, `deriveNoteSecret(root, host, index)` and
28
+ `derivedSecretSource(root, host, start)` in `secrets.ts`, plus
29
+ `restoreNotes(baseUrl, root, host, {gap, start}, opts)` in a new
30
+ `restore.ts`. All additive; nothing existing changes shape.
31
+ - The scheme, in full, so this entry alone is enough to reimplement it:
32
+
33
+ ```
34
+ root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed)
35
+ k1_i = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))
36
+ ```
37
+
38
+ `seed` is raw bytes of any length. A 64-byte BIP39 seed (12 words,
39
+ English wordlist, no passphrase) is what wallets use in practice, but the
40
+ kit is seed-format agnostic and depends on no wordlist. `host` is the
41
+ mint host exactly as `serverOf` produces it: lowercase, port included
42
+ where there is one, so `127.0.0.1:8899` and `mint.example` derive
43
+ different secrets. `index` is decimal ASCII counting from 0, and the
44
+ separator is a single colon. The HMAC output is 32 bytes, rendered
45
+ lowercase hex, which is the size of a payment preimage and therefore
46
+ indistinguishable from a randomly drawn `k1` on the wire. `hashK1`
47
+ applies unchanged, so the mint only ever receives `sha256(k1)` and sees
48
+ nothing different from before.
49
+
50
+ Worked example. The BIP39 mnemonic `abandon abandon abandon abandon
51
+ abandon abandon abandon abandon abandon abandon abandon about` with an
52
+ empty passphrase gives the seed
53
+ `5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4`.
54
+ Its root is
55
+ `948f8f49347549cf2726e8b53f673a4185379344d2d7ba8877d3ded45d34d127`, and
56
+ index 0 at host `mint.example` is
57
+ `1f6016c80339b45dfdd1b3877c1a97d74b063cad54c4ccb866be39ed25ee2ab0`.
58
+ - Why it is in the kit rather than in a wallet: because the derivation is
59
+ written down once, the same words restore the same notes in a different
60
+ wallet, and the Kotlin, Python and Go ports agree with this one. That
61
+ cross-wallet portability is the point, and it is the reason the scheme
62
+ ships with a conformance vector.
63
+ - Counters are the wallet's, one per mint host. `derivedSecretSource` is a
64
+ `RandomSecret`, so it drops straight into
65
+ `LnurlcashOptions.randomSecret` and rotate, split and merge draw derived
66
+ secrets without knowing anything about derivation; `source.index()` reads
67
+ back the next unused index afterwards. A rotate consumes one index, a
68
+ split consumes two. Minting can draw from the same source where the mint
69
+ advertises `mintToHash` - see "Name the note you are buying" below - and
70
+ the note is then derived from birth rather than from its first rotate. Persist that counter in the SAME write that stages
71
+ the new records, and do it BEFORE the hash goes on the wire: a crash
72
+ between the bump and the request wastes an index, which costs nothing,
73
+ while a crash the other way round re-derives a secret the mint has
74
+ already seen and the second note minted at it collides with the first.
75
+ - `restoreNotes` walks indices from `start`, asking the mint what each
76
+ derived secret is worth. A live note is recorded; a note the mint reports
77
+ as spent still counts the index as used, since re-deriving it would mint
78
+ a duplicate id; a note the mint reports as pending is recorded with a
79
+ null amount for the caller to reconcile later; an unknown note counts
80
+ towards the gap. The walk stops after `gap` consecutive unknowns,
81
+ defaulting to 20, and `next` is one past the highest index the mint
82
+ recognised. It reads only, so an interrupted restore has changed nothing.
83
+ Any other failure is thrown rather than swallowed, because a short walk
84
+ reported as a finished one would leave the wallet re-deriving live
85
+ secrets.
86
+ - A restored note carries no signature and its `k1` has just been on the
87
+ wire, so rotate each one straight after restoring. That closes the
88
+ exposure and gets the signature in the same call.
89
+ - The seed is bearer material for every note the wallet will ever hold.
90
+ Store it the way the notes are stored, and never log it.
91
+ - `classifyNoteError` now returns `PendingNoteError` for the exact reason
92
+ string `pending`, which LUD-25 fixes verbatim. Previously only the
93
+ mutating callback classified it, so an informational GET on a note with a
94
+ melt in flight raised a bare `ServiceRejectedError` that callers had to
95
+ re-parse. `PendingNoteError` extends `ServiceRejectedError`, so anything
96
+ catching the parent is unaffected.
97
+
98
+ ### Name the note you are buying
99
+
100
+ - `requestInvoice(payCallback, amountMsat, {h, ...opts})` takes an optional
101
+ `h`: the sha256 of a secret the wallet chose, sent on the LUD-06 pay
102
+ callback exactly as `h` is sent on the withdraw callback. A mint that
103
+ accepts it credits the minted note at that hash on settlement, so the
104
+ wallet names the note it is buying instead of being handed one. The
105
+ options argument is the same object as before with one more optional
106
+ field on it, so every existing call is unaffected.
107
+ - Why it matters. Without `h` the payment preimage IS the money, and two
108
+ sets of people learn it without being trusted: every routing node on the
109
+ payment path, because that is how HTLC settlement works, and anyone who
110
+ merely saw the invoice, because they can poll LUD-21 `verify` with the
111
+ payment hash that travels inside it and take the preimage the moment it
112
+ settles. A QR on a desktop screen is exactly that. "Rotate immediately"
113
+ is a race against a thief in a tight polling loop with a warm connection.
114
+ Choosing the secret yourself is not a race at all.
115
+ - The capability appears in three places, and they say different things.
116
+ `PayRequestInfo.mintToHash` is "I accept an `h`", and is the one to
117
+ decide from: the payRequest is the only endpoint every mint has, it is
118
+ where a wallet already is at the moment it is about to mint, and it sits
119
+ alongside the `withdrawLink` the draft already hangs there for
120
+ LNURLcash's sake. `MintAddressInfo.mintToHash` is the same statement on
121
+ the experimental discovery document, kept for consistency with the other
122
+ capability fields there and the fallback for a mint that only says it
123
+ there. `InvoiceResult.mintToHash` is "I bound THIS quote to the hash you
124
+ named": per quote, and the one that matters at the moment money moves.
125
+ - So the order is: read the payRequest, fall back to the mint address, and
126
+ a mint that advertises in neither place ignores the `h`. Undefined means
127
+ the mint said nothing, which a wallet reads as no. Anything that is not
128
+ exactly the boolean `true` is no, everywhere, which matters on the
129
+ payRequest because that response is spread through and a truthy string
130
+ would otherwise land on the typed field.
131
+ - `InvoiceResult.mintToHash` being `false` is not a refusal: it means the
132
+ quote said nothing about `h`, and a mint may accept the parameter without
133
+ echoing it back. Decide from the advertisement, claim by probing.
134
+ - A malformed `h` is refused with `RequestRefusedError` before anything is
135
+ sent, so a wallet never pays for a quote the mint was always going to
136
+ reject. The hash is normalised to lowercase on the way out.
137
+ - Persist the secret BEFORE calling `requestInvoice`. Paying for a note and
138
+ then losing the secret is the one way this is worse than the preimage
139
+ scheme, and persisting first removes it.
140
+ - `claimMintedNote(withdrawLink, k1, opts)` is the claim half, returning
141
+ `{state, k1, amountMsat, callback}` with `state` one of `'minted'`,
142
+ `'unminted'`, `'pending'` or `'spent'`. A wallet that chose its own
143
+ secret has nothing to fetch, so it asks the mint what the note at that
144
+ secret is worth and a live answer is the claim. Poll it while the invoice
145
+ is unpaid; it reads only, so an `'unminted'` answer has changed nothing.
146
+ A mint that cannot be reached throws rather than reporting `'unminted'`,
147
+ which a caller would fairly read as "not yet" and give up on.
148
+ - No rotate follows a bound claim, and that is the point. The preimage
149
+ scheme needs one because the mint generated the secret and `verify` hands
150
+ it to anyone who saw the invoice. Here the mint never had it and no third
151
+ party can learn it, so the note belongs to the wallet from the moment it
152
+ exists. The claim GET does disclose the secret to the mint it is a claim
153
+ on, which is not the same exposure, and a wallet that wants an offline
154
+ signature on the note can still rotate to get one.
155
+ - **This changes what the derivation section above says about minting.**
156
+ Until now a freshly minted note was never derived: its secret was the
157
+ mint's preimage, which nothing in a seed produces, so the note existed
158
+ outside the wallet's derivation until the immediate rotate moved it back
159
+ in. Draw the secret from `deriveNoteSecret` at the next index, send its
160
+ hash as `h`, and the minted note is seed-derived from birth. `restoreNotes`
161
+ finds it with no rotate having happened at all, which is what closes the
162
+ window where a wallet that crashed between paying and rotating could not
163
+ recover the note from its words. The counter rule is unchanged and applies
164
+ to the mint too: persist the bumped index in the same write that stages the
165
+ pending mint, before the hash goes on the wire.
166
+ - Purely additive on the wire. A mint that does not offer `mintToHash`
167
+ ignores the parameter, keys the note by the preimage as it always has,
168
+ and the LUD-21 verify path is unchanged and still the way in.
169
+ - `createClient(opts).requestInvoice(payCallback, amountMsat, h?)` takes the
170
+ hash as a third argument, and `claimMintedNote` is bound alongside it.
171
+
172
+ ### Mint info, and verifying against a key history
173
+
174
+ - `MintAddressInfo` gains the operator fields a mint may publish on the
175
+ experimental discovery endpoint: `name`, `description`, `contact`
176
+ (`{nostr?, email?, url?}`), `tosUrl`, `motd`, `fees` (`{baseFeeMsat,
177
+ feePpm}`, the same shape `parseMintFee` returns, so it feeds
178
+ `applyMintFee` and `mintFeeBand` directly), `version` and
179
+ `previousPubkeys`. All optional, all absent on most mints, and none of
180
+ them is needed to spend a note.
181
+ - `fetchMintAddress` now maps the response field by field instead of
182
+ spreading it through. The spread is what hid `nodeCapacity` under its
183
+ wire name until 0.1.1, and it also put whatever a mint decided to send on
184
+ a typed object with no type behind it. An unrecognised wire field is now
185
+ dropped rather than carried, so a caller reading one off the object with
186
+ a cast will find it undefined; the version of this library that
187
+ understands that field will map it deliberately.
188
+ - `nodeCapacityMsat` is now populated from either spelling. The bare
189
+ `nodeCapacity` is what the reference mint, the mock and everything that
190
+ copied them emit, and it wins where a mint sends both; one live mint
191
+ emits `nodeCapacityMsat` instead, which previously survived only by
192
+ riding the spread.
193
+ - `verifyNoteSignature(k1, amountMsat, sig, keys)` accepts a single pubkey
194
+ or an array, and is true if any of them signed. New
195
+ `verifyNoteSignatureAgainst(...)` returns `{valid, pubkey}` so a caller
196
+ learns WHICH key signed. An empty array is a rejection, never a pass.
197
+ - Why: a mint that rotates its signing key would otherwise invalidate every
198
+ outstanding signature at once, and a wallet holding only the new key would
199
+ read every note it already had as forged. The mint publishes its retired
200
+ keys as `previousPubkeys`, the wallet verifies against the current key and
201
+ that history together, and a note that verifies only against a retired key
202
+ is one to rotate so the mint re-signs it. Only one recovery is performed
203
+ per signature layout, so a long key history costs a string comparison
204
+ each, not a recovery each.
205
+
206
+ ### Accepting a note as payment
207
+
208
+ - `settleNoteForValue(noteUrl, {mints, minMsat, requireSignature}, opts)`
209
+ in a new `settle.ts`, returning `{note, newUrl}`. It is the decision
210
+ sequence every server accepting a bearer note performs, written once:
211
+ parse the input; check the note's mint is one the server accepts, before
212
+ any round trip, so an unaccepted mint is never contacted; fetch the
213
+ authoritative value and the mint's signing key; verify the signature over
214
+ that value where the server demands one; compare against the price;
215
+ rotate.
216
+ - The rotate is the settlement, not bookkeeping after it. It burns the
217
+ secret the payer handed over and mints a replacement only the server
218
+ knows, in one atomic request at the mint, so it transfers ownership and
219
+ rejects a replay in the same call: a second presentation of the same note
220
+ finds it spent. A server that checks a note's value and grants access
221
+ without rotating has verified a photograph of a banknote.
222
+ - New `InsufficientValueError`, extending `ServiceRejectedError` and
223
+ carrying `amountMsat` and `minMsat`, so a server can say how short a note
224
+ was rather than "declined". An unaccepted mint or a signature that will
225
+ not verify raises `ServiceRejectedError`; a spent note passes
226
+ `NoteSpentError` through; a note with a melt in flight raises
227
+ `PendingNoteError`, which is worth retrying rather than refusing. Nothing
228
+ is burned by any refusal, so a rejected note is still the payer's, intact.
229
+ - `AmbiguousMutationError` from the rotate reaches the caller unchanged,
230
+ carrying the fresh secret. If that request landed, the secret is the money
231
+ and it belongs to the server: persist it before anything else.
232
+ - An empty `mints` list accepts nothing. A note is a claim on one specific
233
+ operator, and "any mint" is not a policy a server should be able to hold
234
+ by accident.
235
+ - The value compared against the price is always the one the mint states.
236
+ A note URL's own `amount` is a claim by whoever encoded it, and a
237
+ signature, where one is required, is checked over the mint's figure, so an
238
+ inflated URL fails rather than passing on a signature issued for the true
239
+ amount.
240
+
241
+ ### A retried mutation no longer loses the secret it minted
242
+
243
+ - Every mutation is a GET, and HTTP stacks retry a GET whose connection
244
+ dropped: browsers on a stale keep-alive connection, Go's `net/http` on a
245
+ reused idle one, the JDK's `HttpClient` on any idempotent method with no
246
+ switch to stop it. The retry is byte-identical, so the mint sees the same
247
+ request twice and answers the second with its ordinary refusal for a
248
+ burned input, its inputs having been burned by the first. The caller was
249
+ told the mutation never happened while a note sat at the hash it had
250
+ disclosed, and the only copy of that secret went out of scope with the
251
+ call. The money was not stolen, it was made unspendable by anyone at all.
252
+ - `rotateNote`, `splitNote` and `mergeNotes` now attach the secrets they
253
+ generated to a `NoteSpentError` or a `NoteUnknownError`, the way
254
+ `AmbiguousMutationError` already carried them. New `newSecretsOf(err)`
255
+ reads them off any error in one line, returning an empty array when there
256
+ are none, so a caller's catch block does not have to know which family it
257
+ is holding.
258
+ - Only those two classes carry anything. They are the refusals that mean
259
+ "this input is not spendable", which is exactly what a landed-then-retried
260
+ mutation looks like. `PendingNoteError` means the input is alive and
261
+ untouched, and a refusal on policy grounds (dust, a fee, a sunsetting
262
+ mint) burned nothing, so both carry nothing and a caller may discard its
263
+ staged records at once.
264
+ - The classification itself is unchanged, deliberately. At the wire a retry
265
+ and a genuine double spend are the same answer, and whether the input was
266
+ live when the request went out is knowledge the caller has and this
267
+ library does not. So it hands back the secret rather than a verdict:
268
+ persist it, then ask the mint what the note at that secret is worth. A
269
+ live note means the mutation landed.
270
+
271
+ ### The mint's signing key is called mintPubkey
272
+
273
+ - `MintAddressInfo.mintPubkey` carries the wire value unchanged and is the
274
+ name to reach for. `nodePubkey` remains, populated with the same value,
275
+ and is deprecated: it will be removed at the next breaking change.
276
+ Nothing breaks in this release.
277
+ - The two keys in a discovery document are different keys. `mintPubkey` is
278
+ what a note's signature verifies against; the Lightning node's identity
279
+ key is embedded in `nodeUri`. Every other `node*` field on the type
280
+ really is about the node - alias, colour, capacity, channel and peer
281
+ counts - so the signing key was the one exception, and its name said
282
+ nothing about that. A reader who pulled the pubkey out of `nodeUri` and
283
+ tried to verify a note with it got a failure that explained nothing.
284
+ - It also makes the package internally consistent: the same key is already
285
+ called `mintPubkey` on a note's own info, so a reader moving between the
286
+ two objects met one key under two names.
287
+
288
+ ### Payment requests
289
+
290
+ - `encodePaymentRequest(request)`, `decodePaymentRequest(string, {now})`,
291
+ `isPaymentRequest(string)` and `paymentRequestAmountMsat(request)` in a
292
+ new `request.ts`, with the `PaymentRequest` type and the
293
+ `PAYMENT_REQUEST_PREFIX` constant.
294
+ - A request names an amount, the mints the payee accepts and where to
295
+ deliver, so a payer's wallet can split a note and send it straight across
296
+ instead of doing a mint-and-zap round trip through the mint's node for
297
+ something neither party needed a node for:
298
+
299
+ ```json
300
+ {"v": 1, "id": "0123456789abcdef", "amount": "500", "currency": "sat",
301
+ "methodDetails": {"mints": ["mint.example"]},
302
+ "to": "npub1...", "memo": "lunch", "expires": 1756000000}
303
+ ```
304
+
305
+ `id` is 16 lowercase hex characters, `amount` is whole sats as a decimal
306
+ string with no leading zeros, `to` is a Nostr npub or a Lightning Address
307
+ and is absent on a charge request served over HTTP, and `expires` is unix
308
+ seconds. `methodDetails` also accepts an optional `mintPubkeys`.
309
+ - Encoded as `lnurlcashreq1` followed by base64url (unpadded) of the
310
+ request serialised as RFC 8785 JCS-canonical JSON: keys sorted by UTF-16
311
+ code unit at every level, no whitespace, integers only. Canonical because
312
+ a request is a thing people copy, quote back and match against a record of
313
+ what they asked for, so two encodings of the same request must be the same
314
+ string. This is NUT-18's `creqA` idiom with our own prefix, and it stays
315
+ short enough for a single static QR.
316
+ - The object is the same charge request an HTTP 402 lnurlcash rail serves,
317
+ plus the transport fields a wallet-to-wallet send needs, so one encoder
318
+ covers both.
319
+ - Validation is strict in both directions, an unrecognised field included:
320
+ quietly paying a request one did not fully understand is how a payer pays
321
+ the wrong person. Every refusal is a `ProtocolError`.
322
+ - `amount` is in sat, which is the one exception to this library's
323
+ msat-everywhere rule, because the field is shared with the 402 rail and
324
+ the Cashu payment method and both count in whole units.
325
+ `paymentRequestAmountMsat` converts exactly, so nothing has to multiply by
326
+ hand.
327
+ - An expired request does not decode, since paying one is always wrong. At
328
+ the expiry counts as expired, not merely past it, so a payer whose clock
329
+ is a second behind the payee's does not send a note against a request the
330
+ payee has already written off. `isPaymentRequest` still returns true for
331
+ it, so a scanner routes it to the pay screen and the holder is told it
332
+ lapsed rather than that their input was gibberish, and
333
+ `decodePaymentRequest(input, {now: 0})` returns it for display.
334
+ - `to` is checked rather than shape-matched: an npub must survive its bech32
335
+ checksum. A request naming a destination nobody can route to is a request
336
+ nobody can pay, and a mistyped npub passes any regex.
337
+ - **`lnurlcashreq1` means the schema above and nothing else.** An earlier
338
+ HTTP 402 rail emitted a shorter object under the same prefix -
339
+ `{"a": 21, "m": ["mint.example"], "u": "sat"}`, with the amount as a
340
+ number, no version and no id - and two schemas under one prefix cannot
341
+ both be right. This is the one the conformance vectors pin, so it is the
342
+ definition. The decoder reads the short form anyway, because returning
343
+ nothing for a string it can plainly understand helps no one, and gives it
344
+ a deterministic id derived from its own canonical bytes so the same
345
+ challenge always reads back as the same request. Nothing in this library
346
+ ever emits the short form.
347
+
6
348
  ## 0.1.2 - 2026-08-21
7
349
 
8
350
  - `mintFeeBand` and `withinMintFeeBand`. LUD-25 states the mint fee as