lnurlcash-kit 0.7.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,81 @@
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
+
5
80
  ## 0.7.0 - 2026-09-04
6
81
 
7
82
  **Offline verification is mandatory, and this library now insists on it.**
package/README.md CHANGED
@@ -245,36 +245,87 @@ information" and fall back to `fetchPayRequest`.
245
245
 
246
246
  ## Secrets
247
247
 
248
- A note's `k1` is generated by the wallet, and LUD-25 says nothing about how.
249
- Draw it from a CSPRNG and the note lives only in your wallet file: the mint
250
- holds `sha256(k1)` and cannot tell you apart from a stranger, so a lost file
251
- is lost money. Derive it from a seed instead and the wallet restores from
252
- 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:
253
255
 
254
256
  ```
255
- root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed)
256
- 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'
257
260
  ```
258
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
+
259
268
  `seed` is raw bytes. A 64-byte BIP39 seed is what wallets use in practice,
260
269
  but nothing here depends on BIP39, so a device with its own entropy store
261
270
  derives the same way and no consumer carries a wordlist it does not need.
262
271
  `host` is the mint host exactly as `serverOf` spells it, lowercase and with
263
272
  the port where there is one, so `127.0.0.1:8899` and `mint.example` never
264
- collide. `index` is decimal ASCII from 0. The output is 32 bytes of hex, the
265
- size of a payment preimage, and the mint sees nothing different: it only
266
- ever receives `sha256(k1)`.
267
-
268
- Because the scheme is written down here rather than invented per wallet, the
269
- same words restore the same notes in a *different* wallet. That
270
- cross-wallet portability is the point of putting it in the kit, with
271
- [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)
272
323
  for the ports.
273
324
 
274
325
  ```ts
275
326
  import {deriveNoteRoot, derivedSecretSource, restoreNotes} from 'lnurlcash-kit'
276
327
 
277
- const root = deriveNoteRoot(seed) // seed: Uint8Array, yours to keep safe
328
+ const root = deriveNoteRoot(seed) // legacy: existing notes only
278
329
  const source = derivedSecretSource(root, 'mint.example', counter)
279
330
 
280
331
  // hand it to any mutating call and the fresh secrets come from the seed
@@ -288,20 +339,37 @@ request wastes an index, which costs nothing. A crash the other way round
288
339
  re-derives a secret the mint has already seen, and the second note minted at
289
340
  it collides with the first. This is the rule wallets get wrong.
290
341
 
342
+ ### Restoring
343
+
291
344
  Restoring walks the indices and asks the mint what each derived secret is
292
- worth:
345
+ worth. From a seed it walks both schemes at once:
293
346
 
294
347
  ```ts
295
- 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}
296
350
  ```
297
351
 
298
- A live note is recorded, a spent index still counts as used (re-deriving it
299
- would mint a duplicate), an unknown one counts towards the gap, and the walk
300
- stops after 20 consecutive unknowns. `next` is the counter to resume from.
301
- 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
302
354
  signature, so rotate each one straight after: that closes the exposure and
303
355
  gets the signature in the same call.
304
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
+
305
373
  The seed is bearer material for every note the wallet will ever hold. Store
306
374
  it the way you store the notes, and never log it.
307
375
 
@@ -323,14 +391,14 @@ had it, and the preimage is ordinary payment proof.
323
391
  ```ts
324
392
  import {
325
393
  fetchPayRequest, requestInvoice, claimMintedNote,
326
- deriveNoteRoot, deriveNoteSecret, hashK1, namesMintOutput
394
+ deriveCashRoot, deriveCashSecret, hashK1, namesMintOutput
327
395
  } from 'lnurlcash-kit'
328
396
 
329
397
  const pay = await fetchPayRequest(payUrl) // a Lightning Address resolves here
330
398
  if (!namesMintOutput(pay)) throw new Error('mint lacks commentAllowed: 64')
331
399
 
332
- const root = deriveNoteRoot(seed)
333
- const k1 = deriveNoteSecret(root, 'mint.example', nextIndex)
400
+ const root = deriveCashRoot(seed)
401
+ const k1 = deriveCashSecret(root, 'mint.example', nextIndex)
334
402
  await persist({k1, index: nextIndex}) // BEFORE the invoice. always.
335
403
 
336
404
  const {pr} = await requestInvoice(pay.callback, 21_000, {h: hashK1(k1)})
package/dist/index.d.ts CHANGED
@@ -191,9 +191,11 @@ type SettledForValue = {
191
191
  };
192
192
  declare const settleNoteForValue: (noteUrl: string, { mints, minMsat, requireSignature }: SettleForValueOptions, options?: LnurlcashOptions) => Promise<SettledForValue>;
193
193
 
194
+ type NoteScheme = 'bip32' | 'hmac';
194
195
  type RestoredNote = {
195
196
  index: number;
196
197
  k1: string;
198
+ scheme: NoteScheme;
197
199
  amountMsat: number | null;
198
200
  state: 'live' | 'pending';
199
201
  callback?: string;
@@ -201,6 +203,7 @@ type RestoredNote = {
201
203
  type UnresolvedIndex = {
202
204
  index: number;
203
205
  k1: string;
206
+ scheme: NoteScheme;
204
207
  reason: string;
205
208
  };
206
209
  type RestoreResult = {
@@ -217,6 +220,15 @@ type RestoreOptions = {
217
220
  allowSecretDisclosure?: boolean;
218
221
  };
219
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>;
220
232
 
221
233
  declare const isBech32Lnurl: (data: string) => boolean;
222
234
  declare const toBech32Lnurl: (url: string) => string;
@@ -242,6 +254,22 @@ declare const buildNoteUrl: (withdrawLink: string, k1: string, amountMsat?: numb
242
254
  declare const withNewK1: (url: string, k1: string, amountMsat: number, signature?: string) => string;
243
255
  declare const withoutK1: (url: string, amountMsat: number, signature?: string) => string;
244
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
+
245
273
  declare const PAYMENT_REQUEST_PREFIX = "lnurlcashreq1";
246
274
  type PaymentRequestMethodDetails = {
247
275
  mints: string[];
@@ -344,8 +372,9 @@ declare const createClient: (options?: LnurlcashOptions) => {
344
372
  fetchInvoiceVerification: (verifyUrl: string) => Promise<VerifyResult>;
345
373
  claimMintedNote: (withdrawLink: string, k1: string) => Promise<MintClaim>;
346
374
  restoreNotes: (baseUrl: string, root: Uint8Array, host: string, restoreOptions?: RestoreOptions) => Promise<RestoreResult>;
375
+ restoreFromSeed: (baseUrl: string, seed: Uint8Array, host: string, restoreOptions?: SeedRestoreOptions) => Promise<SeedRestoreResult>;
347
376
  settleNoteForValue: (noteUrl: string, terms: SettleForValueOptions) => Promise<SettledForValue>;
348
377
  };
349
378
  type LnurlcashClient = ReturnType<typeof createClient>;
350
379
 
351
- 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, UnverifiableNoteError, 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 {
@@ -1305,12 +1388,67 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature: req
1305
1388
  };
1306
1389
 
1307
1390
  // src/restore.ts
1308
- 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) => {
1309
1443
  if (!Number.isSafeInteger(gap) || gap < 1) {
1310
1444
  throw new RangeError(`The gap limit must be a positive integer, not ${gap}.`);
1311
1445
  }
1312
- if (!Number.isSafeInteger(start) || start < 0) {
1313
- 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
+ }
1314
1452
  }
1315
1453
  let hashLookupsConfirmed = false;
1316
1454
  if (probeK1) {
@@ -1321,66 +1459,75 @@ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0, probeK1, a
1321
1459
  if (!(err instanceof ServiceRejectedError)) throw err;
1322
1460
  }
1323
1461
  }
1324
- const byHash = await walk(
1325
- start,
1326
- gap,
1327
- async (k1) => {
1328
- 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) {
1329
1474
  hashLookupsConfirmed = true;
1330
- return info;
1331
- },
1332
- root,
1333
- host
1334
- );
1335
- if (byHash.found.length > 0 || byHash.unresolved.length > 0) hashLookupsConfirmed = true;
1336
- if (hashLookupsConfirmed) {
1337
- return {
1338
- found: byHash.found,
1339
- unresolved: byHash.unresolved,
1340
- next: byHash.lastUsed === null ? start : byHash.lastUsed + 1,
1341
- hashLookupsConfirmed: true,
1342
- disclosesSecrets: false
1343
- };
1475
+ }
1476
+ byHash.push(outcome);
1344
1477
  }
1478
+ if (hashLookupsConfirmed) return collate(schemes, byHash, false);
1345
1479
  if (!allowSecretDisclosure) {
1346
1480
  throw new HashLookupUnsupportedError(
1347
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."
1348
1482
  );
1349
1483
  }
1350
- const bySecret = await walk(
1351
- start,
1352
- gap,
1353
- (k1) => fetchNoteInfo(buildNoteUrl(baseUrl, k1), options),
1354
- root,
1355
- host
1356
- );
1357
- const walkedThrough = bySecret.highestWalked === null ? start - 1 : bySecret.highestWalked;
1358
- 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
+ });
1359
1508
  return {
1360
- found: bySecret.found,
1361
- unresolved: bySecret.unresolved,
1362
- // Every index this walk touched is burned, whether or not a note was
1363
- // ever minted under it: its secret is in a log somewhere now, so
1364
- // minting into it later would be minting a note a stranger can spend.
1365
- next: Math.max(used, walkedThrough) + 1,
1366
- hashLookupsConfirmed: false,
1367
- disclosesSecrets: true
1509
+ found: outcomes.flatMap((outcome) => outcome.found),
1510
+ unresolved: outcomes.flatMap((outcome) => outcome.unresolved),
1511
+ next,
1512
+ hashLookupsConfirmed: !disclosesSecrets,
1513
+ disclosesSecrets
1368
1514
  };
1369
1515
  };
1370
- var walk = async (start, gap, lookup, root, host) => {
1516
+ var walk = async ({ scheme, start, secretAt }, gap, lookup) => {
1371
1517
  const found = [];
1372
1518
  const unresolved = [];
1373
1519
  let lastUsed = null;
1374
1520
  let highestWalked = null;
1375
1521
  let unknownRun = 0;
1376
1522
  for (let index = start; unknownRun < gap; index++) {
1377
- const k1 = deriveNoteSecret(root, host, index);
1523
+ const k1 = secretAt(index);
1378
1524
  highestWalked = index;
1379
1525
  try {
1380
1526
  const info = await lookup(k1);
1381
1527
  found.push({
1382
1528
  index,
1383
1529
  k1,
1530
+ scheme,
1384
1531
  amountMsat: info.maxWithdrawable,
1385
1532
  state: "live",
1386
1533
  callback: info.callback
@@ -1389,7 +1536,7 @@ var walk = async (start, gap, lookup, root, host) => {
1389
1536
  unknownRun = 0;
1390
1537
  } catch (err) {
1391
1538
  if (err instanceof PendingNoteError) {
1392
- found.push({ index, k1, amountMsat: null, state: "pending" });
1539
+ found.push({ index, k1, scheme, amountMsat: null, state: "pending" });
1393
1540
  lastUsed = index;
1394
1541
  unknownRun = 0;
1395
1542
  } else if (err instanceof NoteSpentError) {
@@ -1398,7 +1545,7 @@ var walk = async (start, gap, lookup, root, host) => {
1398
1545
  } else if (err instanceof NoteUnknownError) {
1399
1546
  unknownRun++;
1400
1547
  } else if (err instanceof ServiceRejectedError) {
1401
- unresolved.push({ index, k1, reason: err.reason });
1548
+ unresolved.push({ index, k1, scheme, reason: err.reason });
1402
1549
  lastUsed = index;
1403
1550
  unknownRun = 0;
1404
1551
  } else {
@@ -1431,7 +1578,8 @@ var createClient = (options = {}) => ({
1431
1578
  fetchInvoiceVerification: (verifyUrl) => fetchInvoiceVerification(verifyUrl, options),
1432
1579
  claimMintedNote: (withdrawLink, k1) => claimMintedNote(withdrawLink, k1, options),
1433
1580
  restoreNotes: (baseUrl, root, host, restoreOptions = {}) => restoreNotes(baseUrl, root, host, restoreOptions, options),
1581
+ restoreFromSeed: (baseUrl, seed, host, restoreOptions = {}) => restoreFromSeed(baseUrl, seed, host, restoreOptions, options),
1434
1582
  settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
1435
1583
  });
1436
1584
 
1437
- export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, UnverifiableNoteError, 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
@@ -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?)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.7.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.6.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"