lnurlcash-kit 0.6.0 → 0.8.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
@@ -2,6 +2,135 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0 - 2026-09-04
6
+
7
+ **LUD-25's own derivation, and it is now the one to mint under.** The draft's
8
+ "Seed-recoverable note secrets" section specifies a BIP-32 scheme under
9
+ `m/139'`, and the reference wallet implements it. This kit had shipped its own
10
+ HMAC scheme four days before that section existed. One convention is the whole
11
+ point of writing either of them down, so the specified one wins.
12
+
13
+ - `deriveCashRoot(seed)`, `deriveCashDomainNode(root, host)`,
14
+ `deriveCashSecret(root, host, index)`, `cashSecretAt(domainNode, index)`,
15
+ `cashSecretSource(root, host, start)`, `cashNodeToHex` / `cashNodeFromHex`
16
+ and `deriveCashChild` in a new `cash.ts`. Additive - nothing existing
17
+ changes shape.
18
+ - The scheme, in full, so this entry alone is enough to reimplement it:
19
+
20
+ ```
21
+ cashHashingKey = m/139'/0
22
+ (d1, d2, d3, d4) = HMAC-SHA256(key = cashHashingKey, msg = utf8(host))[0..16]
23
+ as 4 big-endian uint32
24
+ k1_i = m/139'/d1/d2/d3/d4/i'
25
+ ```
26
+
27
+ `d1..d4` are used **exactly as they fall**. BIP-32 already reads any index
28
+ `>= 2^31` as hardened, so which of the four levels are hardened is decided
29
+ by the mint's host name and roughly half of them will be. Masking the top
30
+ bit, or hardening all four, derives a different tree and restores nothing,
31
+ silently. Only `i` is always hardened. `host` is what `serverOf` produces -
32
+ lowercase, port included - byte-identical to the reference wallet's.
33
+
34
+ Worked example. The BIP39 mnemonic `abandon abandon abandon abandon abandon
35
+ abandon abandon abandon abandon abandon abandon about` with an empty
36
+ passphrase gives `m/139'` as
37
+ `c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60`
38
+ (privateKey || chainCode). At `mint.example` the four levels are
39
+ `[2589708612, 3693348916, 172082394, 3793182078]`, of which the third is
40
+ the only unhardened one, and index 0 is
41
+ `de5b81405a12e1297b350d80e2ad85043ed5b9436a0c5592d3302778de330499`.
42
+ - **The hardware-signer path.** Every unhardened level sits at or above the
43
+ per-mint node, so a signer provisioned with `deriveCashDomainNode`'s output
44
+ rather than the seed needs no elliptic curve at all: each `i'` beneath it is
45
+ HMAC-SHA512 and one modular addition. Whoever derives that node can derive
46
+ every note secret the wallet will hold at that mint, so it is provisioning
47
+ material - one mint's subtree, not the wallet.
48
+ - BIP-32 is implemented here from its own primitives rather than pulled in as
49
+ a dependency, and tested against BIP-32's published test vector 1. Every
50
+ LUD-25 value above is checked against output from the reference
51
+ implementation's own library.
52
+
53
+ **`restoreFromSeed` walks both schemes.** Notes minted under the old scheme
54
+ are still money and a wallet that walked only the new one would leave them at
55
+ a mint it can no longer name.
56
+
57
+ - `restoreFromSeed(baseUrl, seed, host, {gap?, start?, probeK1?,
58
+ allowSecretDisclosure?}, opts?)`. `RestoredNote.scheme` is `bip32` or
59
+ `hmac`, `next` is `{bip32, hmac}`, and `start` takes one per scheme.
60
+ - `restoreNotes` is unchanged in signature and behaviour, and still walks the
61
+ legacy scheme alone. `RestoredNote` and `UnresolvedIndex` gain a `scheme`
62
+ field.
63
+ - `deriveNoteRoot`, `deriveNoteSecret` and `derivedSecretSource` are not
64
+ deprecated and are not going anywhere. Do not mint under them.
65
+
66
+ **Say plainly what a restore can and cannot do.** LUD-25 requires a hash
67
+ lookup to answer for a burned note exactly as it answers for one that never
68
+ existed, and both reference mints do. So a by-hash walk cannot see a spent
69
+ index at all; and since a rotate burns the *old* index, a wallet's spent
70
+ indices sit below its live ones, and one that has rotated more than `gap`
71
+ times scans as completely empty. The persisted per-host counter is what makes
72
+ recovery work - the scan is the fallback. That counter is not secret, so it
73
+ belongs in an ordinary backup, and a restore should merge counters upwards
74
+ only. Documented on `RestoreOptions.start`, in the README and in `llms.txt`.
75
+
76
+ Graded against `lnurlcash-conformance` 0.7.0, whose `cash-derivation.json`
77
+ cases this suite now runs: the LUD-25 path, the four domain levels per host,
78
+ the hardened-by-magnitude flags, and BIP-32's own published test vector 1.
79
+
80
+ ## 0.7.0 - 2026-09-04
81
+
82
+ **Offline verification is mandatory, and this library now insists on it.**
83
+ LUD-25 stopped treating a note signature as optional: a SERVICE MUST
84
+ publish `mintPubkey` and MUST sign every note a rotate, split or merge
85
+ mints. A wallet that quietly accepted unsigned notes was handing its holder
86
+ something nobody downstream could check, which is exactly the gap offline
87
+ verification exists to close.
88
+
89
+ - `fetchNoteInfo` and `fetchNoteInfoByHash` refuse a `withdrawRequest` that
90
+ publishes no `mintPubkey`, or one that is not a 33-byte compressed
91
+ secp256k1 key. `WithdrawRequestInfo.mintPubkey` is typed as present.
92
+ - `rotateNote`, `splitNote`, `mergeNotes` and their `*WithHash` forms throw
93
+ the new `UnverifiableNoteError` when the SERVICE confirms the mutation but
94
+ returns no `sig` (or no `sig2` on a split's change).
95
+ - **That error carries the secrets.** The mutation landed - `status` was OK -
96
+ so the note exists at the hash the wallet disclosed and its secret is the
97
+ only key to that value. `newSecretsOf()` reads them exactly as it reads
98
+ them off an ambiguous mutation. Enforcing conformance must never be the
99
+ thing that destroys the money.
100
+ - `requireSignatures: false` opts out, for a mint that predates the
101
+ requirement. One option, stated once, at the call site that needs it.
102
+
103
+ **A mutation whose answer was lost is now re-sent, and usually completes.**
104
+ LUD-25 gained a "Retrying a mutation" section: a SERVICE MUST answer a
105
+ byte-identical rotate, split or merge with the success it already returned,
106
+ signature and all, rather than with the already-spent refusal its burned
107
+ inputs would otherwise earn.
108
+
109
+ That closes the sharpest edge in the protocol. Every mutation is a GET,
110
+ HTTP treats GET as idempotent, and stacks retry one whose connection
111
+ dropped - browsers on a stale keep-alive, Go's `net/http` on a reused
112
+ connection, the JDK's `HttpClient` with no way to switch it off. The mint
113
+ saw the request twice, answered the second as already spent, and the wallet
114
+ was told a mutation had not happened while a note sat at the hash it had
115
+ disclosed. Now the second answer is the first one.
116
+
117
+ - `mutationRetries` defaults to 1. Set 0 for the previous behaviour.
118
+ - Only rotate, split and merge. A melt is **never** retried: it carries
119
+ `pr`, is paid out asynchronously, and the replay rule does not cover it.
120
+ - Only an ambiguous failure is retried. A definitive refusal is the
121
+ SERVICE's considered answer and asking again cannot improve it.
122
+ - The retry re-sends the identical request rather than rebuilding it. The
123
+ replay is matched on the k1 set, `h`, `h2` and `amount`, so a freshly
124
+ generated secret would make the second attempt a different mutation - and
125
+ a second real burn.
126
+
127
+ Against a SERVICE that has not implemented the replay rule, retrying leaves
128
+ a caller exactly where giving up would have: the same secrets on the same
129
+ error, and the same instruction to go and ask what the note at each hash is
130
+ worth.
131
+
132
+ Requires `lnurlcash-conformance` 0.6.0, whose vectors carry the same MUSTs.
133
+
5
134
  ## 0.6.0 - 2026-08-31
6
135
 
7
136
  - `namesMintOutput()` now requires `commentAllowed >= 64`; the additive
package/README.md CHANGED
@@ -58,14 +58,15 @@ console.log(info.maxWithdrawable, 'msat')
58
58
  // that GET put the secret on the wire, so rotate it
59
59
  const fresh = await rotateNote(info.callback, info.k1)
60
60
 
61
- // and check the mint really issued it, without asking anyone
62
- if (info.mintPubkey && fresh.signature) {
63
- verifyNoteSignature(fresh.k1, info.maxWithdrawable, fresh.signature, info.mintPubkey)
64
- }
61
+ // and check the mint really issued it, without asking anyone. Both fields
62
+ // are guaranteed here: LUD-25 requires the mint to publish mintPubkey and
63
+ // to sign what it mints, and this library refuses a mint that does neither.
64
+ verifyNoteSignature(fresh.k1, info.maxWithdrawable, fresh.signature!, info.mintPubkey)
65
65
  ```
66
66
 
67
67
  Every request function takes options last — `fetch`, `timeoutMs`, `offline`,
68
- `randomSecret`. `createClient(options)` binds one set once:
68
+ `randomSecret`, `requireSignatures`, `mutationRetries`.
69
+ `createClient(options)` binds one set once:
69
70
 
70
71
  ```ts
71
72
  const client = createClient({timeoutMs: 10_000})
@@ -107,23 +108,39 @@ try {
107
108
 
108
109
  `RequestRefusedError` is the opposite and safe: nothing left the process.
109
110
 
110
- **3. Your HTTP stack must not retry.** Every mutation is a GET, HTTP treats GET
111
- as idempotent, and an LNURLcash mutation is not — the first attempt burns the
112
- input. A retried mutation is answered "already spent", which reads as a
113
- *definitive* rejection, so the fresh secret gets discarded along with the note
114
- the service just minted. Node's `fetch` does not retry on its own, but a
115
- browser will resend an idempotent request that failed on a stale pooled
116
- connection, and any retry wrapper, service worker or proxy in front of this
117
- will do the same. If you pass your own `fetch`, do not make it retry these.
118
-
119
- This is not hypothetical: the same hazard broke the
111
+ **3. A retried mutation is now a replay, not a double spend.** Every mutation
112
+ is a GET, HTTP treats GET as idempotent, and an LNURLcash mutation is not —
113
+ the first attempt burns the input. For most of this draft's life that was the
114
+ sharpest edge in the protocol: a stack that resent a dropped GET got "already
115
+ spent" for the second attempt, which reads as a *definitive* rejection, so the
116
+ fresh secret got discarded along with the note the service had just minted.
117
+ Node's `fetch` does not retry on its own, but a browser resends an idempotent
118
+ request that failed on a stale pooled connection, and Go and the JDK do the
119
+ same by their own routes — the hazard broke the
120
120
  [Kotlin](https://github.com/TheCryptoDonkey/lnurlcash-kotlin) and
121
121
  [Go](https://github.com/TheCryptoDonkey/lnurlcash-go) siblings during
122
- development, by two different mechanisms, and is now a named scenario in the
123
- conformance vectors.
122
+ development, by two different mechanisms.
124
123
 
125
- Because a retry cannot always be prevented, a mutation refused with the
126
- input already spent or unknown carries its outputs anyway:
124
+ LUD-25 closed it. A service MUST answer a byte-identical rotate, split or
125
+ merge with the success it already returned, signature and all. So this library
126
+ re-sends one whose answer was lost, and an unstoppable transport retry is now
127
+ simply invisible:
128
+
129
+ ```ts
130
+ // the connection dropped after the mint applied this. It completes anyway.
131
+ const fresh = await rotateNote(callback, oldK1)
132
+ ```
133
+
134
+ `mutationRetries` sets how many times (default 1; `0` restores the old
135
+ give-up-at-once behaviour). Only rotate, split and merge are re-sent — never a
136
+ melt, which carries `pr`, is paid asynchronously and has no replay guarantee —
137
+ and only an ambiguous failure, never a refusal the service actually
138
+ considered. The re-sent request is byte-identical, because the replay is
139
+ matched on the k1 set, `h`, `h2` and `amount`.
140
+
141
+ A service that has not implemented the rule answers the second attempt as
142
+ already spent, exactly as before. So the old defence stays: a mutation refused
143
+ with the input already spent or unknown carries its outputs anyway:
127
144
 
128
145
  ```ts
129
146
  try {
@@ -161,8 +178,9 @@ do not mint. Existing notes still redeem through ordinary LUD-03.
161
178
 
162
179
  ## Offline verification
163
180
 
164
- A service may sign each note with its Lightning node identity key, so a
165
- holder can confirm issuer and amount with nothing but the note:
181
+ Mandatory, and enforced here. A service MUST publish `mintPubkey` and MUST
182
+ sign every note a rotate, split or merge mints, so a holder can confirm
183
+ issuer and amount with nothing but the note:
166
184
 
167
185
  ```
168
186
  message = "LNURLcash:" || amount_msat || ":" || hex(sha256(k1))
@@ -170,6 +188,14 @@ digest = sha256(sha256("Lightning Signed Message:" || message))
170
188
  sig = 65 bytes, r || s || recovery_id
171
189
  ```
172
190
 
191
+ A `withdrawRequest` publishing no `mintPubkey`, or one that is not a 33-byte
192
+ compressed secp256k1 key, is refused with a `ProtocolError`. A mutation the
193
+ service confirms but does not sign raises `UnverifiableNoteError` — which
194
+ **carries the fresh secrets**, because the mutation landed and the note it
195
+ minted is real; read them with `newSecretsOf` and persist them before
196
+ anything else. Pass `requireSignatures: false` to deal with a mint that
197
+ predates the requirement.
198
+
173
199
  `verifyNoteSignature` recovers the pubkey and compares it to `mintPubkey`.
174
200
  It accepts the recovery id at either end, because lnurl-mint once emitted
175
201
  the reverse layout and other implementations may still; trying both is safe,
@@ -219,36 +245,87 @@ information" and fall back to `fetchPayRequest`.
219
245
 
220
246
  ## Secrets
221
247
 
222
- A note's `k1` is generated by the wallet, and LUD-25 says nothing about how.
223
- Draw it from a CSPRNG and the note lives only in your wallet file: the mint
224
- holds `sha256(k1)` and cannot tell you apart from a stranger, so a lost file
225
- is lost money. Derive it from a seed instead and the wallet restores from
226
- words alone.
248
+ A note's `k1` is generated by the wallet. Draw it from a CSPRNG and the note
249
+ lives only in your wallet file: the mint holds `sha256(k1)` and cannot tell
250
+ you apart from a stranger, so a lost file is lost money. Derive it from a
251
+ seed instead and the wallet restores from words alone, and the same words
252
+ restore the same notes in a *different* wallet.
253
+
254
+ LUD-25 specifies how, and this is the scheme to mint under:
227
255
 
228
256
  ```
229
- root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed)
230
- k1_i = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))
257
+ cashHashingKey = m/139'/0
258
+ (d1, d2, d3, d4) = HMAC-SHA256(key = cashHashingKey, msg = utf8(host))[0..16] as 4 uint32
259
+ k1_i = m/139'/d1/d2/d3/d4/i'
231
260
  ```
232
261
 
262
+ `d1..d4` are used **exactly as they fall**. BIP-32 reads any index `>= 2^31`
263
+ as hardened, so which of the four levels are hardened is decided by the
264
+ mint's own host name, and half of them will be. Do not mask the top bit and
265
+ do not harden all four: either one derives a different tree, and a wallet
266
+ restoring against it finds nothing, silently. Only `i` is always hardened.
267
+
233
268
  `seed` is raw bytes. A 64-byte BIP39 seed is what wallets use in practice,
234
269
  but nothing here depends on BIP39, so a device with its own entropy store
235
270
  derives the same way and no consumer carries a wordlist it does not need.
236
271
  `host` is the mint host exactly as `serverOf` spells it, lowercase and with
237
272
  the port where there is one, so `127.0.0.1:8899` and `mint.example` never
238
- collide. `index` is decimal ASCII from 0. The output is 32 bytes of hex, the
239
- size of a payment preimage, and the mint sees nothing different: it only
240
- ever receives `sha256(k1)`.
241
-
242
- Because the scheme is written down here rather than invented per wallet, the
243
- same words restore the same notes in a *different* wallet. That
244
- cross-wallet portability is the point of putting it in the kit, with
245
- [a conformance vector](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
273
+ collide. `index` counts from 0. The output is 32 bytes of hex, the size of a
274
+ payment preimage, and the mint sees nothing different: it only ever receives
275
+ `sha256(k1)`.
276
+
277
+ ```ts
278
+ import {deriveCashRoot, cashSecretSource, restoreFromSeed} from 'lnurlcash-kit'
279
+
280
+ const root = deriveCashRoot(seed) // seed: Uint8Array, yours to keep safe
281
+ const source = cashSecretSource(root, 'mint.example', counter)
282
+ ```
283
+
284
+ ### The hardware-signer path
285
+
286
+ Every unhardened level sits at or above the per-mint node, so a signer given
287
+ `m/139'/d1/d2/d3/d4` rather than the seed needs **no elliptic curve at all**:
288
+ each `i'` beneath it is HMAC-SHA512 and one modular addition. That is the
289
+ difference between a device that can do LUD-25 recovery and one that would
290
+ need secp256k1 added to its firmware.
291
+
292
+ ```ts
293
+ import {deriveCashDomainNode, cashNodeToHex, cashNodeFromHex, cashSecretAt} from 'lnurlcash-kit'
294
+
295
+ const node = deriveCashDomainNode(root, 'mint.example')
296
+ provision(cashNodeToHex(node)) // 64 bytes: privateKey || chainCode
297
+
298
+ // on the device, or anywhere holding only that node
299
+ cashSecretAt(cashNodeFromHex(hex), index)
300
+ ```
301
+
302
+ Whoever derives that node can derive every note secret the wallet will ever
303
+ hold **at that mint**. It is provisioning material: one mint's subtree, not
304
+ the wallet.
305
+
306
+ ### The legacy scheme
307
+
308
+ This kit shipped its own derivation in 0.2.0, four days before LUD-25 had a
309
+ section on one:
310
+
311
+ ```
312
+ root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed)
313
+ k1_i = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))
314
+ ```
315
+
316
+ `deriveNoteRoot`, `deriveNoteSecret` and `derivedSecretSource` still
317
+ implement it and are not going anywhere, because notes minted under it are
318
+ still money. Do not mint under it. `restoreFromSeed` walks it alongside the
319
+ specified scheme so none of those notes goes missing.
320
+
321
+ Both schemes ship with
322
+ [conformance vectors](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
246
323
  for the ports.
247
324
 
248
325
  ```ts
249
326
  import {deriveNoteRoot, derivedSecretSource, restoreNotes} from 'lnurlcash-kit'
250
327
 
251
- const root = deriveNoteRoot(seed) // seed: Uint8Array, yours to keep safe
328
+ const root = deriveNoteRoot(seed) // legacy: existing notes only
252
329
  const source = derivedSecretSource(root, 'mint.example', counter)
253
330
 
254
331
  // hand it to any mutating call and the fresh secrets come from the seed
@@ -262,20 +339,37 @@ request wastes an index, which costs nothing. A crash the other way round
262
339
  re-derives a secret the mint has already seen, and the second note minted at
263
340
  it collides with the first. This is the rule wallets get wrong.
264
341
 
342
+ ### Restoring
343
+
265
344
  Restoring walks the indices and asks the mint what each derived secret is
266
- worth:
345
+ worth. From a seed it walks both schemes at once:
267
346
 
268
347
  ```ts
269
- const {found, next} = await restoreNotes('https://mint.example/w', root, 'mint.example')
348
+ const {found, next} = await restoreFromSeed('https://mint.example/w', seed, 'mint.example')
349
+ // found[].scheme is 'bip32' or 'hmac'; next is {bip32, hmac}
270
350
  ```
271
351
 
272
- A live note is recorded, a spent index still counts as used (re-deriving it
273
- would mint a duplicate), an unknown one counts towards the gap, and the walk
274
- stops after 20 consecutive unknowns. `next` is the counter to resume from.
275
- Restoring puts every `k1` it walks on the wire and a restored note carries no
352
+ A live note is recorded, an unknown index counts towards the gap, and the
353
+ walk stops after 20 consecutive unknowns. A restored note carries no
276
354
  signature, so rotate each one straight after: that closes the exposure and
277
355
  gets the signature in the same call.
278
356
 
357
+ **The scan is the fallback, not the backup.** LUD-25 requires a hash lookup
358
+ to answer for a burned note exactly as it answers for one that never existed,
359
+ so a by-hash walk cannot see a spent index at all. A rotate burns the *old*
360
+ index, which means a wallet's spent indices sit below its live ones: rotate
361
+ more than `gap` times and a scan from 0 finds nothing whatever. The counter
362
+ `next` gives you is the thing that makes recovery work, so **persist it and
363
+ back it up**. It is not secret - an index reveals nothing without the root -
364
+ so it belongs in an ordinary backup, and a restore should merge counters
365
+ upwards only, never down.
366
+
367
+ Only a walk that discloses raw secrets (`allowSecretDisclosure`) sees "spent"
368
+ at all, because only a `k1` lookup gets that answer. It costs the whole
369
+ window it walked: every index it touched is burned whether or not a note was
370
+ ever minted there, since the secret is in someone's log now. `next` skips
371
+ them for you.
372
+
279
373
  The seed is bearer material for every note the wallet will ever hold. Store
280
374
  it the way you store the notes, and never log it.
281
375
 
@@ -297,14 +391,14 @@ had it, and the preimage is ordinary payment proof.
297
391
  ```ts
298
392
  import {
299
393
  fetchPayRequest, requestInvoice, claimMintedNote,
300
- deriveNoteRoot, deriveNoteSecret, hashK1, namesMintOutput
394
+ deriveCashRoot, deriveCashSecret, hashK1, namesMintOutput
301
395
  } from 'lnurlcash-kit'
302
396
 
303
397
  const pay = await fetchPayRequest(payUrl) // a Lightning Address resolves here
304
398
  if (!namesMintOutput(pay)) throw new Error('mint lacks commentAllowed: 64')
305
399
 
306
- const root = deriveNoteRoot(seed)
307
- const k1 = deriveNoteSecret(root, 'mint.example', nextIndex)
400
+ const root = deriveCashRoot(seed)
401
+ const k1 = deriveCashSecret(root, 'mint.example', nextIndex)
308
402
  await persist({k1, index: nextIndex}) // BEFORE the invoice. always.
309
403
 
310
404
  const {pr} = await requestInvoice(pay.callback, 21_000, {h: hashK1(k1)})
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ type LnurlcashOptions = {
13
13
  timeoutMs?: number;
14
14
  offline?: boolean;
15
15
  randomSecret?: RandomSecret;
16
+ requireSignatures?: boolean;
17
+ mutationRetries?: number;
16
18
  };
17
19
 
18
20
  type MintFee = {
@@ -41,7 +43,7 @@ type WithdrawRequestInfo = {
41
43
  minWithdrawable: number;
42
44
  maxWithdrawable: number;
43
45
  defaultDescription?: string;
44
- mintPubkey?: string;
46
+ mintPubkey: string;
45
47
  payLink?: string;
46
48
  };
47
49
  declare const fetchNoteInfo: (url: string, options?: LnurlcashOptions) => Promise<WithdrawRequestInfo>;
@@ -189,9 +191,11 @@ type SettledForValue = {
189
191
  };
190
192
  declare const settleNoteForValue: (noteUrl: string, { mints, minMsat, requireSignature }: SettleForValueOptions, options?: LnurlcashOptions) => Promise<SettledForValue>;
191
193
 
194
+ type NoteScheme = 'bip32' | 'hmac';
192
195
  type RestoredNote = {
193
196
  index: number;
194
197
  k1: string;
198
+ scheme: NoteScheme;
195
199
  amountMsat: number | null;
196
200
  state: 'live' | 'pending';
197
201
  callback?: string;
@@ -199,6 +203,7 @@ type RestoredNote = {
199
203
  type UnresolvedIndex = {
200
204
  index: number;
201
205
  k1: string;
206
+ scheme: NoteScheme;
202
207
  reason: string;
203
208
  };
204
209
  type RestoreResult = {
@@ -215,6 +220,15 @@ type RestoreOptions = {
215
220
  allowSecretDisclosure?: boolean;
216
221
  };
217
222
  declare const restoreNotes: (baseUrl: string, root: Uint8Array, host: string, { gap, start, probeK1, allowSecretDisclosure }?: RestoreOptions, options?: LnurlcashOptions) => Promise<RestoreResult>;
223
+ type SeedRestoreOptions = Omit<RestoreOptions, 'start'> & {
224
+ start?: {
225
+ [K in NoteScheme]?: number;
226
+ };
227
+ };
228
+ type SeedRestoreResult = Omit<RestoreResult, 'next'> & {
229
+ next: Record<NoteScheme, number>;
230
+ };
231
+ declare const restoreFromSeed: (baseUrl: string, seed: Uint8Array, host: string, { gap, start, probeK1, allowSecretDisclosure }?: SeedRestoreOptions, options?: LnurlcashOptions) => Promise<SeedRestoreResult>;
218
232
 
219
233
  declare const isBech32Lnurl: (data: string) => boolean;
220
234
  declare const toBech32Lnurl: (url: string) => string;
@@ -240,6 +254,22 @@ declare const buildNoteUrl: (withdrawLink: string, k1: string, amountMsat?: numb
240
254
  declare const withNewK1: (url: string, k1: string, amountMsat: number, signature?: string) => string;
241
255
  declare const withoutK1: (url: string, amountMsat: number, signature?: string) => string;
242
256
 
257
+ type CashNode = {
258
+ privateKey: Uint8Array;
259
+ chainCode: Uint8Array;
260
+ };
261
+ declare const deriveCashChild: (node: CashNode, index: number) => CashNode;
262
+ declare const deriveCashRoot: (seed: Uint8Array) => CashNode;
263
+ declare const cashDomainIndices: (root: CashNode, host: string) => number[];
264
+ declare const deriveCashDomainNode: (root: CashNode, host: string) => CashNode;
265
+ declare const cashSecretAt: (domainNode: CashNode, index: number) => string;
266
+ declare const deriveCashSecret: (root: CashNode, host: string, index: number) => string;
267
+ declare const cashNodeToHex: (node: CashNode) => string;
268
+ declare const cashNodeFromHex: (hex: string) => CashNode;
269
+ declare const cashSecretSource: (root: CashNode, host: string, start?: number) => RandomSecret & {
270
+ index: () => number;
271
+ };
272
+
243
273
  declare const PAYMENT_REQUEST_PREFIX = "lnurlcashreq1";
244
274
  type PaymentRequestMethodDetails = {
245
275
  mints: string[];
@@ -308,6 +338,10 @@ declare class HashLookupUnsupportedError extends LnurlcashError {
308
338
  }
309
339
  declare class AmbiguousMintError extends LnurlcashError {
310
340
  }
341
+ declare class UnverifiableNoteError extends LnurlcashError {
342
+ newSecrets: string[];
343
+ constructor(message: string, newSecrets?: string[]);
344
+ }
311
345
  declare class AmbiguousMutationError extends AmbiguousMintError {
312
346
  readonly newSecrets: string[];
313
347
  constructor(message: string, newSecrets: string[]);
@@ -338,8 +372,9 @@ declare const createClient: (options?: LnurlcashOptions) => {
338
372
  fetchInvoiceVerification: (verifyUrl: string) => Promise<VerifyResult>;
339
373
  claimMintedNote: (withdrawLink: string, k1: string) => Promise<MintClaim>;
340
374
  restoreNotes: (baseUrl: string, root: Uint8Array, host: string, restoreOptions?: RestoreOptions) => Promise<RestoreResult>;
375
+ restoreFromSeed: (baseUrl: string, seed: Uint8Array, host: string, restoreOptions?: SeedRestoreOptions) => Promise<SeedRestoreResult>;
341
376
  settleNoteForValue: (noteUrl: string, terms: SettleForValueOptions) => Promise<SettledForValue>;
342
377
  };
343
378
  type LnurlcashClient = ReturnType<typeof createClient>;
344
379
 
345
- export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type DecodeOptions, HashLookupUnsupportedError, type HashedMutationResult, type HashedSplitResult, InsufficientValueError, type InvoiceRequestOptions, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintClaim, type MintContact, type MintFee, type MintFeeBand, type NoteInfoByHash, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, type PayRequestInfo, type PaymentRequest, type PaymentRequestMethodDetails, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RestoreOptions, type RestoreResult, type RestoredNote, type RotateResult, ServiceRejectedError, type SettleForValueOptions, type SettledForValue, type SettledNote, type SignatureCheck, type SplitResult, type UnresolvedIndex, type ValidatedBoundMintReceipt, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
380
+ export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type CashNode, type DecodeOptions, HashLookupUnsupportedError, type HashedMutationResult, type HashedSplitResult, InsufficientValueError, type InvoiceRequestOptions, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintClaim, type MintContact, type MintFee, type MintFeeBand, type NoteInfoByHash, type NoteScheme, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, type PayRequestInfo, type PaymentRequest, type PaymentRequestMethodDetails, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RestoreOptions, type RestoreResult, type RestoredNote, type RotateResult, type SeedRestoreOptions, type SeedRestoreResult, ServiceRejectedError, type SettleForValueOptions, type SettledForValue, type SettledNote, type SignatureCheck, type SplitResult, type UnresolvedIndex, UnverifiableNoteError, type ValidatedBoundMintReceipt, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, cashDomainIndices, cashNodeFromHex, cashNodeToHex, cashSecretAt, cashSecretSource, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveCashChild, deriveCashDomainNode, deriveCashRoot, deriveCashSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreFromSeed, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { bech32, base64urlnopad } from '@scure/base';
2
2
  import { hmac } from '@noble/hashes/hmac.js';
3
- import { sha256 } from '@noble/hashes/sha2.js';
3
+ import { sha256, sha512 } from '@noble/hashes/sha2.js';
4
4
  import { utf8ToBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
5
5
  import { secp256k1 } from '@noble/curves/secp256k1.js';
6
6
 
@@ -218,6 +218,89 @@ var withoutK1 = (url, amountMsat, signature) => {
218
218
  else newUrl.searchParams.delete("sig");
219
219
  return newUrl.toString();
220
220
  };
221
+ var HARDENED = 2147483648;
222
+ var CURVE_N = secp256k1.Point.Fn.ORDER;
223
+ var MASTER_KEY_DOMAIN = utf8ToBytes("Bitcoin seed");
224
+ var CASH_PURPOSE = 139;
225
+ var numberOf = (bytes) => bytes.length === 0 ? 0n : BigInt(`0x${bytesToHex(bytes)}`);
226
+ var to32Bytes = (value) => hexToBytes(value.toString(16).padStart(64, "0"));
227
+ var readUint32BE = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
228
+ var deriveCashChild = (node, index) => {
229
+ if (!Number.isSafeInteger(index) || index < 0 || index > 4294967295) {
230
+ throw new RangeError(`A BIP-32 child index must be a uint32, not ${index}.`);
231
+ }
232
+ const data = new Uint8Array(37);
233
+ if (index >= HARDENED) {
234
+ data.set(node.privateKey, 1);
235
+ } else {
236
+ data.set(
237
+ secp256k1.Point.BASE.multiply(numberOf(node.privateKey)).toBytes(true),
238
+ 0
239
+ );
240
+ }
241
+ data[33] = index >>> 24 & 255;
242
+ data[34] = index >>> 16 & 255;
243
+ data[35] = index >>> 8 & 255;
244
+ data[36] = index & 255;
245
+ const material = hmac(sha512, node.chainCode, data);
246
+ const left = numberOf(material.subarray(0, 32));
247
+ const key = (left + numberOf(node.privateKey)) % CURVE_N;
248
+ if (left >= CURVE_N || key === 0n) {
249
+ throw new Error(
250
+ `BIP-32 derivation at index ${index} produced an invalid key. Use the next index.`
251
+ );
252
+ }
253
+ return { privateKey: to32Bytes(key), chainCode: material.slice(32) };
254
+ };
255
+ var masterFrom = (seed) => {
256
+ if (seed.length < 16 || seed.length > 64) {
257
+ throw new RangeError(
258
+ `A BIP-32 seed must be 16 to 64 bytes, not ${seed.length}.`
259
+ );
260
+ }
261
+ const material = hmac(sha512, MASTER_KEY_DOMAIN, seed);
262
+ const key = numberOf(material.subarray(0, 32));
263
+ if (key === 0n || key >= CURVE_N) {
264
+ throw new Error("This seed does not produce a valid BIP-32 master key.");
265
+ }
266
+ return { privateKey: material.slice(0, 32), chainCode: material.slice(32) };
267
+ };
268
+ var deriveCashRoot = (seed) => deriveCashChild(masterFrom(seed), CASH_PURPOSE + HARDENED);
269
+ var cashDomainIndices = (root, host) => {
270
+ const hashingKey = deriveCashChild(root, 0).privateKey;
271
+ const material = hmac(sha256, hashingKey, utf8ToBytes(host));
272
+ return [0, 4, 8, 12].map((offset) => readUint32BE(material, offset));
273
+ };
274
+ var deriveCashDomainNode = (root, host) => cashDomainIndices(root, host).reduce(deriveCashChild, root);
275
+ var requireIndex2 = (index) => {
276
+ if (!Number.isSafeInteger(index) || index < 0 || index >= HARDENED) {
277
+ throw new RangeError(
278
+ `A note index must be an integer in [0, 2^31), not ${index}.`
279
+ );
280
+ }
281
+ return index;
282
+ };
283
+ var cashSecretAt = (domainNode, index) => bytesToHex(
284
+ deriveCashChild(domainNode, requireIndex2(index) + HARDENED).privateKey
285
+ );
286
+ var deriveCashSecret = (root, host, index) => cashSecretAt(deriveCashDomainNode(root, host), index);
287
+ var cashNodeToHex = (node) => bytesToHex(node.privateKey) + bytesToHex(node.chainCode);
288
+ var cashNodeFromHex = (hex) => {
289
+ const bytes = hexToBytes(hex.trim().toLowerCase());
290
+ if (bytes.length !== 64) {
291
+ throw new RangeError(
292
+ `A cash node is 64 bytes - a 32-byte key and a 32-byte chain code - not ${bytes.length}.`
293
+ );
294
+ }
295
+ return { privateKey: bytes.slice(0, 32), chainCode: bytes.slice(32) };
296
+ };
297
+ var cashSecretSource = (root, host, start = 0) => {
298
+ const domainNode = deriveCashDomainNode(root, host);
299
+ let next = requireIndex2(start);
300
+ const source = (() => cashSecretAt(domainNode, next++));
301
+ source.index = () => next;
302
+ return source;
303
+ };
221
304
 
222
305
  // src/errors.ts
223
306
  var LnurlcashError = class extends Error {
@@ -264,6 +347,13 @@ var HashLookupUnsupportedError = class extends LnurlcashError {
264
347
  };
265
348
  var AmbiguousMintError = class extends LnurlcashError {
266
349
  };
350
+ var UnverifiableNoteError = class extends LnurlcashError {
351
+ newSecrets;
352
+ constructor(message, newSecrets = []) {
353
+ super(message);
354
+ this.newSecrets = newSecrets;
355
+ }
356
+ };
267
357
  var AmbiguousMutationError = class extends AmbiguousMintError {
268
358
  newSecrets;
269
359
  constructor(message, newSecrets) {
@@ -283,6 +373,7 @@ var InsufficientValueError = class extends ServiceRejectedError {
283
373
  };
284
374
  var newSecretsOf = (err) => {
285
375
  if (err instanceof AmbiguousMutationError) return err.newSecrets;
376
+ if (err instanceof UnverifiableNoteError) return err.newSecrets;
286
377
  if (err instanceof ServiceRejectedError) return err.newSecrets ?? [];
287
378
  return [];
288
379
  };
@@ -465,8 +556,18 @@ var resolveOptions = (options = {}) => ({
465
556
  fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)),
466
557
  timeoutMs: options.timeoutMs ?? 3e4,
467
558
  offline: options.offline ?? false,
468
- randomSecret: options.randomSecret ?? defaultRandomSecret
559
+ randomSecret: options.randomSecret ?? defaultRandomSecret,
560
+ requireSignatures: options.requireSignatures ?? true,
561
+ // A negative or non-finite count is read as none rather than thrown on:
562
+ // this is a resilience knob, and refusing the whole operation over it
563
+ // would be a worse answer than not retrying.
564
+ mutationRetries: normaliseRetries(options.mutationRetries)
469
565
  });
566
+ var normaliseRetries = (value) => {
567
+ if (value === void 0) return 1;
568
+ if (!Number.isFinite(value) || value <= 0) return 0;
569
+ return Math.floor(value);
570
+ };
470
571
  var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
471
572
  var MAX_REDIRECTS = 5;
472
573
  var MAX_BODY_BYTES = 1048576;
@@ -699,10 +800,17 @@ var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeys) => verifyN
699
800
  var verifyNoteSignatureHash = (h, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys).valid;
700
801
 
701
802
  // src/client.ts
702
- var assertWithdrawRequestShape = (body, { requireK1 }) => {
803
+ var COMPRESSED_PUBKEY = /^0[23][0-9a-f]{64}$/;
804
+ var isCompressedPubkey = (value) => typeof value === "string" && COMPRESSED_PUBKEY.test(value.trim().toLowerCase());
805
+ var assertWithdrawRequestShape = (body, { requireK1, requireMintPubkey }) => {
703
806
  if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || requireK1 && typeof body.k1 !== "string" || typeof body.maxWithdrawable !== "number" || !Number.isSafeInteger(body.maxWithdrawable) || body.maxWithdrawable < 0 || body.minWithdrawable !== void 0 && (typeof body.minWithdrawable !== "number" || !Number.isSafeInteger(body.minWithdrawable) || body.minWithdrawable < 0 || body.minWithdrawable > body.maxWithdrawable)) {
704
807
  throw new ProtocolError("Not a withdrawRequest (unexpected response).");
705
808
  }
809
+ if (requireMintPubkey && !isCompressedPubkey(body.mintPubkey)) {
810
+ throw new ProtocolError(
811
+ body.mintPubkey === void 0 ? "This service publishes no mintPubkey, so its notes cannot be verified offline (LUD-25 requires one)." : "This service published a mintPubkey that is not a 33-byte compressed secp256k1 key."
812
+ );
813
+ }
706
814
  };
707
815
  var fetchNoteInfo = async (url, options = {}) => {
708
816
  const opts = resolveOptions(options);
@@ -715,7 +823,10 @@ var fetchNoteInfo = async (url, options = {}) => {
715
823
  if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
716
824
  throw err;
717
825
  }
718
- assertWithdrawRequestShape(body, { requireK1: true });
826
+ assertWithdrawRequestShape(body, {
827
+ requireK1: true,
828
+ requireMintPubkey: opts.requireSignatures
829
+ });
719
830
  const queried = noteK1(url);
720
831
  if (queried && body.k1.toLowerCase() !== queried) {
721
832
  throw new ProtocolError(
@@ -738,7 +849,10 @@ var fetchNoteInfoByHash = async (withdrawLink, h, options = {}) => {
738
849
  if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
739
850
  throw err;
740
851
  }
741
- assertWithdrawRequestShape(body, { requireK1: false });
852
+ assertWithdrawRequestShape(body, {
853
+ requireK1: false,
854
+ requireMintPubkey: opts.requireSignatures
855
+ });
742
856
  const info = body;
743
857
  const payLink = sameOriginPayLink(body.payLink, reqUrl);
744
858
  if (payLink === void 0) delete info.payLink;
@@ -857,6 +971,28 @@ var callbackRequest = async (callback, params, options) => {
857
971
  }
858
972
  return body;
859
973
  };
974
+ var replayableCallbackRequest = async (callback, params, options) => {
975
+ const { mutationRetries } = resolveOptions(options);
976
+ let lastError;
977
+ for (let attempt = 0; ; attempt++) {
978
+ try {
979
+ return await callbackRequest(callback, params, options);
980
+ } catch (err) {
981
+ if (!(err instanceof AmbiguousMintError) || attempt >= mutationRetries) {
982
+ throw err;
983
+ }
984
+ lastError = err;
985
+ }
986
+ }
987
+ throw lastError;
988
+ };
989
+ var requireSignature = (value, options, what) => {
990
+ if (typeof value === "string" && value.length > 0) return value;
991
+ if (!resolveOptions(options).requireSignatures) return void 0;
992
+ throw new UnverifiableNoteError(
993
+ `The service confirmed the ${what} but returned no signature, so the note it just minted cannot be verified offline. The note exists - keep the secret.`
994
+ );
995
+ };
860
996
  var meltNote = async (callback, k1, pr, options = {}) => {
861
997
  const body = await callbackRequest(
862
998
  callback,
@@ -872,7 +1008,7 @@ var meltNote = async (callback, k1, pr, options = {}) => {
872
1008
  };
873
1009
  };
874
1010
  var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
875
- const body = await callbackRequest(
1011
+ const body = await replayableCallbackRequest(
876
1012
  callback,
877
1013
  [
878
1014
  ["k1", k1],
@@ -880,10 +1016,10 @@ var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
880
1016
  ],
881
1017
  options
882
1018
  );
883
- return { signature: body.sig };
1019
+ return { signature: requireSignature(body.sig, options, "rotate") };
884
1020
  };
885
1021
  var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) => {
886
- const body = await callbackRequest(
1022
+ const body = await replayableCallbackRequest(
887
1023
  callback,
888
1024
  [
889
1025
  ...k1s.map((k1) => ["k1", k1]),
@@ -893,20 +1029,26 @@ var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) =
893
1029
  ],
894
1030
  options
895
1031
  );
896
- return { signature: body.sig, changeSignature: body.sig2 };
1032
+ return {
1033
+ signature: requireSignature(body.sig, options, "split"),
1034
+ changeSignature: requireSignature(body.sig2, options, "split's change")
1035
+ };
897
1036
  };
898
1037
  var mergeNotesWithHash = async (callback, k1s, h, options = {}) => {
899
- const body = await callbackRequest(
1038
+ const body = await replayableCallbackRequest(
900
1039
  callback,
901
1040
  [...k1s.map((k1) => ["k1", k1]), ["h", h]],
902
1041
  options
903
1042
  );
904
- return { signature: body.sig };
1043
+ return { signature: requireSignature(body.sig, options, "merge") };
905
1044
  };
906
1045
  var keepingOutputs = (err, newSecrets) => {
907
1046
  if (err instanceof NoteSpentError || err instanceof NoteUnknownError) {
908
1047
  err.newSecrets = newSecrets;
909
1048
  }
1049
+ if (err instanceof UnverifiableNoteError) {
1050
+ err.newSecrets = newSecrets;
1051
+ }
910
1052
  return err;
911
1053
  };
912
1054
  var rotateNote = async (callback, k1, options = {}) => {
@@ -1012,6 +1154,10 @@ var foldNotes = async (callback, batches, opts, options) => {
1012
1154
  err.newSecrets = live;
1013
1155
  throw err;
1014
1156
  }
1157
+ if (err instanceof UnverifiableNoteError) {
1158
+ err.newSecrets = live;
1159
+ throw err;
1160
+ }
1015
1161
  throw new AmbiguousMutationError(
1016
1162
  err instanceof Error ? err.message : String(err),
1017
1163
  live
@@ -1196,7 +1342,7 @@ var claimMintedNote = async (withdrawLink, k1, options = {}) => {
1196
1342
 
1197
1343
  // src/settle.ts
1198
1344
  var normaliseHost = (value) => serverOf(value.trim().replace(/^@/, "")).toLowerCase();
1199
- var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = false }, options = {}) => {
1345
+ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature: requireSignature2 = false }, options = {}) => {
1200
1346
  const url = resolveNoteInput(noteUrl);
1201
1347
  if (!url) {
1202
1348
  throw new RequestRefusedError(
@@ -1212,7 +1358,7 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
1212
1358
  }
1213
1359
  const k1 = requireNoteK1(url);
1214
1360
  const info = await fetchNoteInfo(url, options);
1215
- if (requireSignature) {
1361
+ if (requireSignature2) {
1216
1362
  const signature = noteSignature(url);
1217
1363
  if (!signature) {
1218
1364
  throw new ServiceRejectedError("This note carries no signature.");
@@ -1242,12 +1388,67 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
1242
1388
  };
1243
1389
 
1244
1390
  // src/restore.ts
1245
- var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0, probeK1, allowSecretDisclosure = false } = {}, options = {}) => {
1391
+ var restoreNotes = async (baseUrl, root, host, {
1392
+ gap = 20,
1393
+ start = 0,
1394
+ probeK1,
1395
+ allowSecretDisclosure = false
1396
+ } = {}, options = {}) => {
1397
+ const walked = await restoreSchemes(
1398
+ baseUrl,
1399
+ [
1400
+ {
1401
+ scheme: "hmac",
1402
+ start,
1403
+ secretAt: (index) => deriveNoteSecret(root, host, index)
1404
+ }
1405
+ ],
1406
+ { gap, probeK1, allowSecretDisclosure },
1407
+ options
1408
+ );
1409
+ return { ...walked, next: walked.next.hmac };
1410
+ };
1411
+ var restoreFromSeed = async (baseUrl, seed, host, {
1412
+ gap = 20,
1413
+ start = {},
1414
+ probeK1,
1415
+ allowSecretDisclosure = false
1416
+ } = {}, options = {}) => {
1417
+ const domainNode = deriveCashDomainNode(deriveCashRoot(seed), host);
1418
+ const legacyRoot = deriveNoteRoot(seed);
1419
+ const walked = await restoreSchemes(
1420
+ baseUrl,
1421
+ [
1422
+ {
1423
+ scheme: "bip32",
1424
+ start: start.bip32 ?? 0,
1425
+ secretAt: (index) => cashSecretAt(domainNode, index)
1426
+ },
1427
+ {
1428
+ scheme: "hmac",
1429
+ start: start.hmac ?? 0,
1430
+ secretAt: (index) => deriveNoteSecret(legacyRoot, host, index)
1431
+ }
1432
+ ],
1433
+ { gap, probeK1, allowSecretDisclosure },
1434
+ options
1435
+ );
1436
+ return { ...walked, next: { bip32: walked.next.bip32, hmac: walked.next.hmac } };
1437
+ };
1438
+ var restoreSchemes = async (baseUrl, schemes, {
1439
+ gap,
1440
+ probeK1,
1441
+ allowSecretDisclosure
1442
+ }, options) => {
1246
1443
  if (!Number.isSafeInteger(gap) || gap < 1) {
1247
1444
  throw new RangeError(`The gap limit must be a positive integer, not ${gap}.`);
1248
1445
  }
1249
- if (!Number.isSafeInteger(start) || start < 0) {
1250
- throw new RangeError(`The start index must be a non-negative integer, not ${start}.`);
1446
+ for (const { scheme, start } of schemes) {
1447
+ if (!Number.isSafeInteger(start) || start < 0) {
1448
+ throw new RangeError(
1449
+ `The start index for the ${scheme} scheme must be a non-negative integer, not ${start}.`
1450
+ );
1451
+ }
1251
1452
  }
1252
1453
  let hashLookupsConfirmed = false;
1253
1454
  if (probeK1) {
@@ -1258,66 +1459,75 @@ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0, probeK1, a
1258
1459
  if (!(err instanceof ServiceRejectedError)) throw err;
1259
1460
  }
1260
1461
  }
1261
- const byHash = await walk(
1262
- start,
1263
- gap,
1264
- async (k1) => {
1265
- const info = await fetchNoteInfoByHash(baseUrl, hashK1(k1), options);
1462
+ const byHash = [];
1463
+ for (const scheme of schemes) {
1464
+ const outcome = await walk(
1465
+ scheme,
1466
+ gap,
1467
+ async (k1) => {
1468
+ const info = await fetchNoteInfoByHash(baseUrl, hashK1(k1), options);
1469
+ hashLookupsConfirmed = true;
1470
+ return info;
1471
+ }
1472
+ );
1473
+ if (outcome.found.length > 0 || outcome.unresolved.length > 0) {
1266
1474
  hashLookupsConfirmed = true;
1267
- return info;
1268
- },
1269
- root,
1270
- host
1271
- );
1272
- if (byHash.found.length > 0 || byHash.unresolved.length > 0) hashLookupsConfirmed = true;
1273
- if (hashLookupsConfirmed) {
1274
- return {
1275
- found: byHash.found,
1276
- unresolved: byHash.unresolved,
1277
- next: byHash.lastUsed === null ? start : byHash.lastUsed + 1,
1278
- hashLookupsConfirmed: true,
1279
- disclosesSecrets: false
1280
- };
1475
+ }
1476
+ byHash.push(outcome);
1281
1477
  }
1478
+ if (hashLookupsConfirmed) return collate(schemes, byHash, false);
1282
1479
  if (!allowSecretDisclosure) {
1283
1480
  throw new HashLookupUnsupportedError(
1284
1481
  "This service never answered a lookup by hash, so a restore cannot tell an empty wallet from a service that only accepts raw secrets. Pass a probeK1 for a note known to exist here, or allowSecretDisclosure to walk by secret instead."
1285
1482
  );
1286
1483
  }
1287
- const bySecret = await walk(
1288
- start,
1289
- gap,
1290
- (k1) => fetchNoteInfo(buildNoteUrl(baseUrl, k1), options),
1291
- root,
1292
- host
1293
- );
1294
- const walkedThrough = bySecret.highestWalked === null ? start - 1 : bySecret.highestWalked;
1295
- const used = bySecret.lastUsed === null ? start - 1 : bySecret.lastUsed;
1484
+ const bySecret = [];
1485
+ for (const scheme of schemes) {
1486
+ bySecret.push(
1487
+ await walk(
1488
+ scheme,
1489
+ gap,
1490
+ (k1) => fetchNoteInfo(buildNoteUrl(baseUrl, k1), options)
1491
+ )
1492
+ );
1493
+ }
1494
+ return collate(schemes, bySecret, true);
1495
+ };
1496
+ var collate = (schemes, outcomes, disclosesSecrets) => {
1497
+ const next = {};
1498
+ schemes.forEach(({ scheme, start }, at) => {
1499
+ const outcome = outcomes[at];
1500
+ if (!disclosesSecrets) {
1501
+ next[scheme] = outcome.lastUsed === null ? start : outcome.lastUsed + 1;
1502
+ return;
1503
+ }
1504
+ const used = outcome.lastUsed ?? start - 1;
1505
+ const walkedThrough = outcome.highestWalked ?? start - 1;
1506
+ next[scheme] = Math.max(used, walkedThrough) + 1;
1507
+ });
1296
1508
  return {
1297
- found: bySecret.found,
1298
- unresolved: bySecret.unresolved,
1299
- // Every index this walk touched is burned, whether or not a note was
1300
- // ever minted under it: its secret is in a log somewhere now, so
1301
- // minting into it later would be minting a note a stranger can spend.
1302
- next: Math.max(used, walkedThrough) + 1,
1303
- hashLookupsConfirmed: false,
1304
- disclosesSecrets: true
1509
+ found: outcomes.flatMap((outcome) => outcome.found),
1510
+ unresolved: outcomes.flatMap((outcome) => outcome.unresolved),
1511
+ next,
1512
+ hashLookupsConfirmed: !disclosesSecrets,
1513
+ disclosesSecrets
1305
1514
  };
1306
1515
  };
1307
- var walk = async (start, gap, lookup, root, host) => {
1516
+ var walk = async ({ scheme, start, secretAt }, gap, lookup) => {
1308
1517
  const found = [];
1309
1518
  const unresolved = [];
1310
1519
  let lastUsed = null;
1311
1520
  let highestWalked = null;
1312
1521
  let unknownRun = 0;
1313
1522
  for (let index = start; unknownRun < gap; index++) {
1314
- const k1 = deriveNoteSecret(root, host, index);
1523
+ const k1 = secretAt(index);
1315
1524
  highestWalked = index;
1316
1525
  try {
1317
1526
  const info = await lookup(k1);
1318
1527
  found.push({
1319
1528
  index,
1320
1529
  k1,
1530
+ scheme,
1321
1531
  amountMsat: info.maxWithdrawable,
1322
1532
  state: "live",
1323
1533
  callback: info.callback
@@ -1326,7 +1536,7 @@ var walk = async (start, gap, lookup, root, host) => {
1326
1536
  unknownRun = 0;
1327
1537
  } catch (err) {
1328
1538
  if (err instanceof PendingNoteError) {
1329
- found.push({ index, k1, amountMsat: null, state: "pending" });
1539
+ found.push({ index, k1, scheme, amountMsat: null, state: "pending" });
1330
1540
  lastUsed = index;
1331
1541
  unknownRun = 0;
1332
1542
  } else if (err instanceof NoteSpentError) {
@@ -1335,7 +1545,7 @@ var walk = async (start, gap, lookup, root, host) => {
1335
1545
  } else if (err instanceof NoteUnknownError) {
1336
1546
  unknownRun++;
1337
1547
  } else if (err instanceof ServiceRejectedError) {
1338
- unresolved.push({ index, k1, reason: err.reason });
1548
+ unresolved.push({ index, k1, scheme, reason: err.reason });
1339
1549
  lastUsed = index;
1340
1550
  unknownRun = 0;
1341
1551
  } else {
@@ -1368,7 +1578,8 @@ var createClient = (options = {}) => ({
1368
1578
  fetchInvoiceVerification: (verifyUrl) => fetchInvoiceVerification(verifyUrl, options),
1369
1579
  claimMintedNote: (withdrawLink, k1) => claimMintedNote(withdrawLink, k1, options),
1370
1580
  restoreNotes: (baseUrl, root, host, restoreOptions = {}) => restoreNotes(baseUrl, root, host, restoreOptions, options),
1581
+ restoreFromSeed: (baseUrl, seed, host, restoreOptions = {}) => restoreFromSeed(baseUrl, seed, host, restoreOptions, options),
1371
1582
  settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
1372
1583
  });
1373
1584
 
1374
- export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
1585
+ export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, UnverifiableNoteError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, cashDomainIndices, cashNodeFromHex, cashNodeToHex, cashSecretAt, cashSecretSource, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveCashChild, deriveCashDomainNode, deriveCashRoot, deriveCashSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreFromSeed, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
package/llms.txt CHANGED
@@ -18,10 +18,10 @@ Reference wallet: https://github.com/dni/lnurl-wallet
18
18
  ## Core API
19
19
 
20
20
  resolveNoteInput(text) -> url | null accepts bech32 LNURL, lnurlw://, https
21
- fetchNoteInfo(url, opts?) -> {callback, k1, maxWithdrawable, mintPubkey?}
22
- rotateNote(callback, k1, opts?) -> {k1, signature?}
23
- splitNote(callback, k1s, amountMsat, opts?) -> {k1, change, signature?, changeSignature?}
24
- mergeNotes(callback, k1s, opts?) -> {k1, signature?}
21
+ fetchNoteInfo(url, opts?) -> {callback, k1, maxWithdrawable, mintPubkey}
22
+ rotateNote(callback, k1, opts?) -> {k1, signature}
23
+ splitNote(callback, k1s, amountMsat, opts?) -> {k1, change, signature, changeSignature}
24
+ mergeNotes(callback, k1s, opts?) -> {k1, signature}
25
25
  meltNote(callback, k1, bolt11, opts?) -> {pr?, verify?}
26
26
  settleNote(baseUrl, k1, expectedMsat, sig?, opts?) -> {k1, amountMsat, signature?, callback}
27
27
  settleNoteForValue(noteUrl, {mints, minMsat, requireSignature?}, opts?)
@@ -35,10 +35,31 @@ fetchMintAddress(url, opts?) -> {mintPubkey?, payLink, name?, motd?, contact?,
35
35
  mintPubkey verifies note signatures. It is NOT the node key in nodeUri.
36
36
  nodePubkey is a deprecated alias for mintPubkey, dropped next breaking change.
37
37
  mintToHash is the fallback copy of the payRequest's flag - see 13.
38
- deriveNoteRoot(seedBytes) -> Uint8Array HMAC-SHA256("lnurlcash-note-v1", seed)
39
- deriveNoteSecret(root, host, index) -> k1 HMAC-SHA256(root, host + ":" + index)
38
+ deriveCashRoot(seedBytes) -> CashNode LUD-25's scheme: BIP-32 m/139'
39
+ deriveCashDomainNode(root, host) -> CashNode m/139'/d1/d2/d3/d4, d1..d4 =
40
+ first 16 bytes of HMAC-SHA256(privkey at m/139'/0, host) as 4 raw uint32.
41
+ RAW: BIP-32 reads >= 2^31 as hardened, so ~half the levels are, by the
42
+ host name alone. Never mask the top bit, never harden all four - either
43
+ derives a different tree and restores nothing, silently.
44
+ deriveCashSecret(root, host, index) -> k1 the hardened child i' of that node
45
+ cashSecretAt(domainNode, index) -> k1 no EC needed below the domain node,
46
+ which is why a hardware signer is provisioned with the node, not the seed
47
+ cashSecretSource(root, host, start) -> RandomSecret & {index()}
48
+ cashNodeToHex/cashNodeFromHex privateKey || chainCode, 64 bytes
49
+ deriveCashChild(node, rawUint32) -> CashNode BIP-32 CKDpriv, for checking
50
+ this library against BIP-32's own vectors
51
+ deriveNoteRoot(seedBytes) -> Uint8Array LEGACY, pre-spec. Do not mint under
52
+ deriveNoteSecret(root, host, index) -> k1 it; still scanned so old notes live.
40
53
  derivedSecretSource(root, host, start) -> RandomSecret & {index()}
54
+ restoreFromSeed(baseUrl, seed, host, {gap?, start?, probeK1?,
55
+ allowSecretDisclosure?}, opts?) -> {found[] (.scheme), next: {bip32, hmac}}
56
+ walks BOTH schemes. A by-hash walk CANNOT see a spent index (LUD-25 makes
57
+ spent and never-issued the same answer), and a rotate burns the old index,
58
+ so a wallet that rotated more than `gap` times scans as empty. The
59
+ persisted per-host counter is the real backup; the scan is the fallback.
60
+ The counter is not secret - back it up, and merge it upwards only.
41
61
  restoreNotes(baseUrl, root, host, {gap?, start?}, opts?) -> {found[], next}
62
+ legacy scheme only
42
63
  fetchPayRequest(url, opts?) -> {callback, minSendable, maxSendable, metadata,
43
64
  withdrawLink?, mintFee?, mintToHash?} mintToHash: mint accepts an `h`
44
65
  fetchInvoiceVerification(url, opts?)
@@ -53,7 +74,11 @@ paymentRequestAmountMsat(req) -> msat request amount is in SAT, this is the
53
74
  parseMintFee(metadata) / applyMintFee(gross, fee) / grossUpForMintFee(net, fee)
54
75
  createClient(opts) -> all of the above with opts bound
55
76
 
56
- Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
77
+ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?,
78
+ requireSignatures?, mutationRetries?}
79
+ requireSignatures defaults true: refuse a mint that publishes no
80
+ mintPubkey or returns an unsigned mutation. mutationRetries defaults 1:
81
+ re-send a rotate/split/merge whose answer was lost, never a melt.
57
82
 
58
83
  ## Rules an implementation MUST follow
59
84
 
@@ -64,12 +89,23 @@ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
64
89
  3. On ANY error from a mutation call newSecretsOf(err) FIRST, persist what
65
90
  it returns, then call probeBurnedNote to learn what happened. Treating a
66
91
  failure as a failure destroys money the service may already have minted.
67
- AmbiguousMutationError always carries secrets; so does a NoteSpentError
68
- or NoteUnknownError from a mutation, because that is also what a retried
69
- GET looks like once its first attempt burned the input.
92
+ AmbiguousMutationError always carries secrets; so does UnverifiableNoteError,
93
+ and so does a NoteSpentError or NoteUnknownError from a mutation, because
94
+ that is also what a retried GET looks like at a service that has not
95
+ implemented the replay rule.
70
96
  4. RequestRefusedError means nothing was sent - safe to treat as no-op.
71
97
  5. A melt's OK means IN FLIGHT, not spent. PendingNoteError means retry, not
72
98
  spent.
99
+ 5b. Offline verification is MANDATORY. A conforming service publishes
100
+ mintPubkey on every withdrawRequest and returns sig (and sig2 on a split)
101
+ from every rotate/split/merge. This library refuses a service that does
102
+ not; UnverifiableNoteError means the mutation LANDED unsigned, so keep its
103
+ secrets. Opt out per call with requireSignatures: false.
104
+ 5c. A retried rotate/split/merge MUST be answered by the service as a replay
105
+ of the original success, so re-sending one whose answer was lost is safe
106
+ and usually completes it. Re-send the IDENTICAL request: the replay is
107
+ matched on the k1 set, h, h2 and amount, so a fresh secret makes it a
108
+ different mutation and a second real burn. Never re-send a melt.
73
109
  6. Rotate immediately after claiming a minted note: the mint generated that
74
110
  preimage, and LUD-21 verify exposes it to anyone who saw the invoice.
75
111
  Does not apply to a note minted with `h` - see 13.
@@ -112,14 +148,18 @@ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
112
148
  RequestRefusedError nothing sent, note untouched
113
149
  ServiceRejectedError processed and refused (definitive)
114
150
  PendingNoteError a melt is in flight on this k1 - retry
115
- NoteSpentError authoritative: already burned - but from a MUTATION it
116
- may be a retry whose first attempt landed; read
151
+ NoteSpentError authoritative: already burned - but from a MUTATION at
152
+ a service that will not replay a retry, it may be a
153
+ retry whose first attempt landed; read
117
154
  newSecretsOf(err) before believing it
118
155
  NoteUnknownError service does not recognise it
119
156
  AmbiguousMintError outcome UNKNOWN - assume nothing
120
157
  AmbiguousMutationError carries .newSecrets - persist them
158
+ UnverifiableNoteError the mutation LANDED and came back unsigned. The note is
159
+ real; carries .newSecrets - persist them
121
160
  newSecretsOf(err) -> string[] the secrets any error is carrying, or none
122
- ProtocolError a non-mutating response did not match the spec
161
+ ProtocolError a non-mutating response did not match the spec, which
162
+ includes a withdrawRequest publishing no mintPubkey
123
163
 
124
164
  ## Conformance
125
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "LNURLcash (LUD-25) bearer note client for TypeScript - mint, rotate, split, merge, melt, and verify offline",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^26.2.0",
63
- "lnurlcash-conformance": "^0.5.0",
63
+ "lnurlcash-conformance": "^0.7.0",
64
64
  "tsup": "^8.5.0",
65
65
  "typescript": "^5.7.0",
66
66
  "vitest": "^3.0.0"