lnurlcash-kit 0.8.1 → 0.10.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
@@ -1,6 +1,45 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.10.0 - 2026-09-11
4
+
5
+ **LUD-25 Part 2 building blocks.** Notes keyed by a public key and spent by a
6
+ recoverable signature. The wire calls come next; this is the part every one of
7
+ them rests on.
8
+
9
+ - The four encodings: `cp1` (a note's public key), `ck1` (its bearer secret),
10
+ `cs1` (the mint's certificate) and `cx1` (a watch-only branch), with
11
+ `encode*`, `decode*` and `is*` for each. Decoders return null rather than
12
+ throw, and refuse mixed case as BIP-350 does.
13
+ - `deriveNotePubkey` and `deriveNoteSecretKey`, the per-note key tweak.
14
+ - `signNoteOwnership` and `recoverNoteOwnershipPubkey`.
15
+ - `deriveCashAddressNode` and `cashNodeToCx1`. The branch sits at
16
+ `m/139'/1'/d1..d4`, which is what lnurl-wallet derives, not the
17
+ `m/139'/d1..d4` the spec text gives.
18
+ - `verifyNoteSignature` now takes a `ck1` note and a `cs1` certificate, so a
19
+ Part 2 note verifies offline the same way a Part 1 note does.
20
+
21
+ Names and signatures match lnurl-wallet's `src/lib`. Every value is graded
22
+ against `test/vectors/part2.json`, generated from lnurl-wallet and checked
23
+ against lnurl-mint.
24
+
25
+ ## 0.9.0 - 2026-09-09
26
+
27
+ **`fetchMintAddress` reads three more fields.** The reference mint publishes
28
+ them and this dropped all three on the floor, because the parser maps field by
29
+ field and an unrecognised name is discarded by design.
30
+
31
+ - `nodeUris` - every address the SERVICE's node announces. `nodeUri` is the
32
+ first of them; a node behind Tor as well as clearnet has more, and a caller
33
+ that can only reach the other one needs the list. Undefined rather than `[]`
34
+ when there are none, so `nodeUris?.length` and `'nodeUris' in info` agree.
35
+ - `sunsetDate` - the day the SERVICE plans to close, ISO-8601. Advance warning
36
+ while there is still time to spend, deliberately not the same thing as a mint
37
+ that has already stopped minting. Validated as a real calendar day and
38
+ dropped otherwise: the one thing a WALLET does with this is put it in front
39
+ of a holder, and a wrong date there is worse than no date.
40
+ - `outstandingNotesMsat` - what the SERVICE says it owes. Its own claim about
41
+ its own database, with nothing to check it against, so read it next to what
42
+ the node holds rather than on its own.
4
43
 
5
44
  ## 0.8.1 - 2026-09-04
6
45
 
package/README.md CHANGED
@@ -117,8 +117,8 @@ fresh secret got discarded along with the note the service had just minted.
117
117
  Node's `fetch` does not retry on its own, but a browser resends an idempotent
118
118
  request that failed on a stale pooled connection, and Go and the JDK do the
119
119
  same by their own routes — the hazard broke the
120
- [Kotlin](https://github.com/TheCryptoDonkey/lnurlcash-kotlin) and
121
- [Go](https://github.com/TheCryptoDonkey/lnurlcash-go) siblings during
120
+ [Kotlin](https://github.com/lnurlcash/lnurlcash-kotlin) and
121
+ [Go](https://github.com/lnurlcash/lnurlcash-go) siblings during
122
122
  development, by two different mechanisms.
123
123
 
124
124
  LUD-25 closed it. A service MUST answer a byte-identical rotate, split or
@@ -319,7 +319,7 @@ still money. Do not mint under it. `restoreFromSeed` walks it alongside the
319
319
  specified scheme so none of those notes goes missing.
320
320
 
321
321
  Both schemes ship with
322
- [conformance vectors](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
322
+ [conformance vectors](https://github.com/lnurlcash/lnurlcash-conformance)
323
323
  for the ports.
324
324
 
325
325
  ```ts
@@ -373,6 +373,54 @@ them for you.
373
373
  The seed is bearer material for every note the wallet will ever hold. Store
374
374
  it the way you store the notes, and never log it.
375
375
 
376
+ ## Notes keyed by a public key (LUD-25 Part 2)
377
+
378
+ A Part 2 note swaps the hash for a key pair. The wallet keeps `sk`. The mint
379
+ only ever sees `pk`, written `cp1…`. To spend the note you hand over `ck1…`,
380
+ a recoverable signature by `sk` over the fixed message `LNURLcash`, and the
381
+ mint recovers `pk` from it to find the note. The mint's certificate, `cs1…`,
382
+ is the same signature mints already make, over `hex(pk)` instead of a hash.
383
+ So a recipient can check a note offline with nothing but `ck1` and `cs1`.
384
+
385
+ This release has the building blocks: the four encodings, key derivation and
386
+ ownership signatures. The wire calls that mint to `cp1` and spend with `ck1`
387
+ come next. The names match lnurl-wallet's `src/lib`, so moving to it later is
388
+ an import change.
389
+
390
+ ```ts
391
+ import {
392
+ deriveCashRoot, deriveCashAddressNode, cashNodeToCx1, encodeCx1,
393
+ deriveNotePubkey, deriveNoteSecretKey, signNoteOwnership, encodeCk1,
394
+ verifyNoteSignature
395
+ } from 'lnurlcash-kit'
396
+
397
+ const node = deriveCashAddressNode(deriveCashRoot(seed), 'mint.example')
398
+ const {pubkeyXOnly, chainCode} = cashNodeToCx1(node)
399
+ const cx1 = encodeCx1(pubkeyXOnly, chainCode) // watch-only
400
+
401
+ const pk = deriveNotePubkey(pubkeyXOnly, chainCode, i) // what a watcher derives
402
+ const sk = deriveNoteSecretKey(node.privateKey, node.chainCode, i)
403
+ const ck1 = encodeCk1(signNoteOwnership(sk)) // the bearer secret
404
+
405
+ verifyNoteSignature(ck1, amountMsat, cs1, mintPubkey) // offline
406
+ ```
407
+
408
+ Three things worth knowing:
409
+
410
+ - **The branch path follows the reference wallet, not the spec text.** It is
411
+ `m/139'/1'/d1/d2/d3/d4`, with the hashing key at `m/139'/1'/0`. The spec
412
+ says `m/139'/d1..d4`, which is the node the Part 1 ladder already uses, and
413
+ a wallet following it finds none of lnurl-wallet's notes.
414
+ - **A `cx1` links every note on its branch.** It cannot spend anything, but
415
+ whoever holds it can list every key on the branch and ask the mint about
416
+ each one. Register it with a mint and that mint sees everything paid to the
417
+ address. Use the branch for receiving and rotate off it.
418
+ - **`i` is any uint32**, serialised as 4 bytes big-endian, never hardened.
419
+ lnurl-wallet and lnurl-mint agree on that; the spec does not say.
420
+
421
+ `test/vectors/part2.json` was generated from lnurl-wallet and checked against
422
+ lnurl-mint. It moves into lnurlcash-conformance next.
423
+
376
424
  ## Minting a note you named yourself
377
425
 
378
426
  By default the secret of a freshly minted note is the invoice's payment
@@ -616,14 +664,14 @@ The reference implementations, both dni's, both MIT:
616
664
 
617
665
  Everything else built on LNURLcash — the other wallets and mints, the
618
666
  hardware vault, the sibling language ports — is indexed in
619
- [awesome-lnurlcash](https://github.com/TheCryptoDonkey/awesome-lnurlcash).
667
+ [awesome-lnurlcash](https://github.com/lnurlcash/awesome-lnurlcash).
620
668
 
621
669
  Changes made on extraction are listed in [CHANGELOG.md](CHANGELOG.md); two
622
670
  are behavioural fixes worth reading if you are porting from that code.
623
671
 
624
672
  ## Conformance
625
673
 
626
- Tested against [lnurlcash-conformance](https://github.com/TheCryptoDonkey/lnurlcash-conformance):
674
+ Tested against [lnurlcash-conformance](https://github.com/lnurlcash/lnurlcash-conformance):
627
675
  language-neutral vectors plus a mock mint that can be told to misbehave —
628
676
  drop a connection mid-mutation, sign in the wrong byte order, lie about a
629
677
  note's value, never settle a melt. If you are writing an LNURLcash
package/SECURITY.md CHANGED
@@ -9,7 +9,7 @@ Only the latest `0.x` release is supported. Pin an exact version.
9
9
 
10
10
  Report privately through GitHub's advisory form:
11
11
 
12
- <https://github.com/TheCryptoDonkey/lnurlcash-kit/security/advisories/new>
12
+ <https://github.com/lnurlcash/lnurlcash-kit/security/advisories/new>
13
13
 
14
14
  Please do not open a public issue for anything that could be used to take
15
15
  somebody's notes.
package/dist/index.d.ts CHANGED
@@ -69,6 +69,9 @@ type MintAddressInfo = {
69
69
  nodeCapacityMsat?: number;
70
70
  nodeNumChannels?: number;
71
71
  nodeNumPeers?: number;
72
+ nodeUris?: string[];
73
+ sunsetDate?: string;
74
+ outstandingNotesMsat?: number;
72
75
  name?: string;
73
76
  description?: string;
74
77
  contact?: MintContact;
@@ -270,6 +273,29 @@ declare const cashSecretSource: (root: CashNode, host: string, start?: number) =
270
273
  index: () => number;
271
274
  };
272
275
 
276
+ declare const encodeCp1: (pubkeyXOnly: Uint8Array) => string;
277
+ declare const decodeCp1: (value: string) => Uint8Array | null;
278
+ declare const isCp1: (value: string) => boolean;
279
+ declare const encodeCk1: (signature: Uint8Array) => string;
280
+ declare const decodeCk1: (value: string) => Uint8Array | null;
281
+ declare const isCk1: (value: string) => boolean;
282
+ declare const encodeCs1: (signature: Uint8Array) => string;
283
+ declare const decodeCs1: (value: string) => Uint8Array | null;
284
+ declare const isCs1: (value: string) => boolean;
285
+ type Cx1 = {
286
+ pubkeyXOnly: Uint8Array;
287
+ chainCode: Uint8Array;
288
+ };
289
+ declare const encodeCx1: (pubkeyXOnly: Uint8Array, chainCode: Uint8Array) => string;
290
+ declare const decodeCx1: (value: string) => Cx1 | null;
291
+ declare const isCx1: (value: string) => boolean;
292
+ declare const deriveNotePubkey: (branchPubkeyXOnly: Uint8Array, chainCode: Uint8Array, index: number) => Uint8Array;
293
+ declare const deriveNoteSecretKey: (branchPrivateKey: Uint8Array, chainCode: Uint8Array, index: number) => Uint8Array;
294
+ declare const signNoteOwnership: (secretKey: Uint8Array) => Uint8Array;
295
+ declare const recoverNoteOwnershipPubkey: (signature: Uint8Array) => Uint8Array | null;
296
+ declare const deriveCashAddressNode: (root: CashNode, host: string) => CashNode;
297
+ declare const cashNodeToCx1: (node: CashNode) => Cx1;
298
+
273
299
  declare const PAYMENT_REQUEST_PREFIX = "lnurlcashreq1";
274
300
  type PaymentRequestMethodDetails = {
275
301
  mints: string[];
@@ -377,4 +403,4 @@ declare const createClient: (options?: LnurlcashOptions) => {
377
403
  };
378
404
  type LnurlcashClient = ReturnType<typeof createClient>;
379
405
 
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 };
406
+ export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type CashNode, type Cx1, 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, cashNodeToCx1, cashNodeToHex, cashSecretAt, cashSecretSource, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodeCk1, decodeCp1, decodeCs1, decodeCx1, decodePaymentRequest, defaultRandomSecret, deriveCashAddressNode, deriveCashChild, deriveCashDomainNode, deriveCashRoot, deriveCashSecret, deriveNotePubkey, deriveNoteRoot, deriveNoteSecret, deriveNoteSecretKey, derivedSecretSource, describeMintFee, encodeCk1, encodeCp1, encodeCs1, encodeCx1, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isCk1, isCp1, isCs1, isCx1, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, recoverNoteOwnershipPubkey, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreFromSeed, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, signNoteOwnership, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { bech32, base64urlnopad } from '@scure/base';
1
+ import { bech32, base64urlnopad, bech32m } from '@scure/base';
2
2
  import { hmac } from '@noble/hashes/hmac.js';
3
3
  import { sha256, sha512 } from '@noble/hashes/sha2.js';
4
4
  import { utf8ToBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
@@ -301,6 +301,114 @@ var cashSecretSource = (root, host, start = 0) => {
301
301
  source.index = () => next;
302
302
  return source;
303
303
  };
304
+ var encodeFixed = (hrp, bytes, length) => {
305
+ if (bytes.length !== length) {
306
+ throw new RangeError(`A ${hrp}1 payload is ${length} bytes, not ${bytes.length}.`);
307
+ }
308
+ return bech32m.encode(hrp, bech32m.toWords(bytes), false);
309
+ };
310
+ var decodeFixed = (hrp, value, length) => {
311
+ if (typeof value !== "string") return null;
312
+ try {
313
+ const decoded = bech32m.decode(value.trim(), false);
314
+ if (decoded.prefix !== hrp) return null;
315
+ const bytes = bech32m.fromWords(decoded.words);
316
+ return bytes.length === length ? bytes : null;
317
+ } catch {
318
+ return null;
319
+ }
320
+ };
321
+ var encodeCp1 = (pubkeyXOnly) => encodeFixed("cp", pubkeyXOnly, 32);
322
+ var decodeCp1 = (value) => decodeFixed("cp", value, 32);
323
+ var isCp1 = (value) => decodeCp1(value) !== null;
324
+ var encodeCk1 = (signature) => encodeFixed("ck", signature, 65);
325
+ var decodeCk1 = (value) => decodeFixed("ck", value, 65);
326
+ var isCk1 = (value) => decodeCk1(value) !== null;
327
+ var encodeCs1 = (signature) => encodeFixed("cs", signature, 65);
328
+ var decodeCs1 = (value) => decodeFixed("cs", value, 65);
329
+ var isCs1 = (value) => decodeCs1(value) !== null;
330
+ var encodeCx1 = (pubkeyXOnly, chainCode) => {
331
+ if (pubkeyXOnly.length !== 32 || chainCode.length !== 32) {
332
+ throw new RangeError("A cx1 is a 32-byte x-only public key and a 32-byte chain code.");
333
+ }
334
+ return encodeFixed("cx", new Uint8Array([...pubkeyXOnly, ...chainCode]), 64);
335
+ };
336
+ var decodeCx1 = (value) => {
337
+ const bytes = decodeFixed("cx", value, 64);
338
+ return bytes ? { pubkeyXOnly: bytes.slice(0, 32), chainCode: bytes.slice(32) } : null;
339
+ };
340
+ var isCx1 = (value) => decodeCx1(value) !== null;
341
+ var CURVE_N2 = secp256k1.Point.Fn.ORDER;
342
+ var NOTE_DERIVE_TAG = sha256(utf8ToBytes("LNURLcash/derive"));
343
+ var numberOf2 = (bytes) => BigInt(`0x${bytesToHex(bytes)}`);
344
+ var to32Bytes2 = (value) => hexToBytes(value.toString(16).padStart(64, "0"));
345
+ var requireUint32 = (index) => {
346
+ if (!Number.isSafeInteger(index) || index < 0 || index > 4294967295) {
347
+ throw new RangeError(`A note index must be a uint32, not ${index}.`);
348
+ }
349
+ return index;
350
+ };
351
+ var tweakFor = (pubkeyXOnly, chainCode, index) => {
352
+ if (pubkeyXOnly.length !== 32 || chainCode.length !== 32) {
353
+ throw new RangeError("A branch is a 32-byte x-only public key and a 32-byte chain code.");
354
+ }
355
+ const i = requireUint32(index);
356
+ const ser = new Uint8Array([i >>> 24 & 255, i >>> 16 & 255, i >>> 8 & 255, i & 255]);
357
+ const t = numberOf2(
358
+ sha256(new Uint8Array([...NOTE_DERIVE_TAG, ...NOTE_DERIVE_TAG, ...pubkeyXOnly, ...chainCode, ...ser]))
359
+ );
360
+ if (t >= CURVE_N2) {
361
+ throw new Error(`Note index ${index} is unusable on this branch. Use the next index.`);
362
+ }
363
+ return t;
364
+ };
365
+ var deriveNotePubkey = (branchPubkeyXOnly, chainCode, index) => {
366
+ const t = tweakFor(branchPubkeyXOnly, chainCode, index);
367
+ const branch = secp256k1.Point.fromBytes(new Uint8Array([2, ...branchPubkeyXOnly]));
368
+ const note = t === 0n ? branch : branch.add(secp256k1.Point.BASE.multiply(t));
369
+ if (note.is0()) {
370
+ throw new Error(`Note index ${index} is unusable on this branch. Use the next index.`);
371
+ }
372
+ return note.toBytes(true).slice(1);
373
+ };
374
+ var deriveNoteSecretKey = (branchPrivateKey, chainCode, index) => {
375
+ const p = numberOf2(branchPrivateKey);
376
+ if (branchPrivateKey.length !== 32 || p === 0n || p >= CURVE_N2) {
377
+ throw new RangeError("A branch private key is a 32-byte scalar in [1, n).");
378
+ }
379
+ const branch = secp256k1.Point.BASE.multiply(p);
380
+ const t = tweakFor(branch.toBytes(true).slice(1), chainCode, index);
381
+ const even = branch.y % 2n === 0n ? p : CURVE_N2 - p;
382
+ const key = (even + t) % CURVE_N2;
383
+ if (key === 0n) {
384
+ throw new Error(`Note index ${index} is unusable on this branch. Use the next index.`);
385
+ }
386
+ return to32Bytes2(key);
387
+ };
388
+ var NOTE_OWNERSHIP_DIGEST = sha256(
389
+ sha256(new Uint8Array([...utf8ToBytes("Lightning Signed Message:"), ...utf8ToBytes("LNURLcash")]))
390
+ );
391
+ var signNoteOwnership = (secretKey) => {
392
+ const signature = secp256k1.sign(NOTE_OWNERSHIP_DIGEST, secretKey, {
393
+ format: "recovered",
394
+ prehash: false
395
+ });
396
+ return new Uint8Array([...signature.subarray(1), signature[0]]);
397
+ };
398
+ var recoverNoteOwnershipPubkey = (signature) => {
399
+ if (!(signature instanceof Uint8Array) || signature.length !== 65) return null;
400
+ try {
401
+ const recoveryIdFirst = new Uint8Array([signature[64], ...signature.subarray(0, 64)]);
402
+ return secp256k1.recoverPublicKey(recoveryIdFirst, NOTE_OWNERSHIP_DIGEST, { prehash: false }).slice(1);
403
+ } catch {
404
+ return null;
405
+ }
406
+ };
407
+ var deriveCashAddressNode = (root, host) => deriveCashDomainNode(deriveCashChild(root, 1 + 2147483648), host);
408
+ var cashNodeToCx1 = (node) => ({
409
+ pubkeyXOnly: secp256k1.getPublicKey(node.privateKey, true).slice(1),
410
+ chainCode: node.chainCode.slice()
411
+ });
304
412
 
305
413
  // src/errors.ts
306
414
  var LnurlcashError = class extends Error {
@@ -756,22 +864,35 @@ var noteSignatureDigestForHash = (h, amountMsat) => sha256(
756
864
  );
757
865
  var NO_MATCH = { valid: false, pubkey: null };
758
866
  var verifyNoteSignatureAgainst = (k1, amountMsat, signatureHex, mintPubkeys) => {
759
- let h;
867
+ const h = signedNoteId(k1);
868
+ if (h === null) return NO_MATCH;
869
+ return verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys);
870
+ };
871
+ var signedNoteId = (k1) => {
872
+ const ownership = decodeCk1(k1);
873
+ if (ownership) {
874
+ const pubkey = recoverNoteOwnershipPubkey(ownership);
875
+ return pubkey ? bytesToHex(pubkey) : null;
876
+ }
760
877
  try {
761
- h = hashK1(k1);
878
+ return hashK1(k1);
762
879
  } catch {
763
- return NO_MATCH;
880
+ return null;
764
881
  }
765
- return verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys);
766
882
  };
767
883
  var verifyNoteSignatureHashAgainst = (h, amountMsat, signatureHex, mintPubkeys) => {
768
884
  const targets = (Array.isArray(mintPubkeys) ? mintPubkeys : [mintPubkeys]).filter((key) => typeof key === "string").map((key) => key.trim().toLowerCase());
769
885
  if (targets.length === 0) return NO_MATCH;
770
886
  let wireSig;
771
- try {
772
- wireSig = hexToBytes(signatureHex);
773
- } catch {
774
- return NO_MATCH;
887
+ const certificate = decodeCs1(signatureHex);
888
+ if (certificate) {
889
+ wireSig = certificate;
890
+ } else {
891
+ try {
892
+ wireSig = hexToBytes(signatureHex);
893
+ } catch {
894
+ return NO_MATCH;
895
+ }
775
896
  }
776
897
  if (wireSig.length !== 65) return NO_MATCH;
777
898
  let digest;
@@ -904,6 +1025,16 @@ var asFees = (value) => {
904
1025
  };
905
1026
  var asBoolean = (value) => typeof value === "boolean" ? value : void 0;
906
1027
  var asPubkeyList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
1028
+ var asStringList = (value) => {
1029
+ if (!Array.isArray(value)) return void 0;
1030
+ const entries = value.filter((item) => typeof item === "string" && item.length > 0);
1031
+ return entries.length ? entries : void 0;
1032
+ };
1033
+ var asIsoDate = (value) => {
1034
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return void 0;
1035
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
1036
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value ? value : void 0;
1037
+ };
907
1038
  var fetchMintAddress = async (url, options = {}) => {
908
1039
  const body = await lnurlFetch(url, resolveOptions(options));
909
1040
  if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || typeof body.payLink !== "string" || typeof body.maxWithdrawable !== "number") {
@@ -929,6 +1060,9 @@ var fetchMintAddress = async (url, options = {}) => {
929
1060
  nodeCapacityMsat: asNumber(body.nodeCapacity) ?? asNumber(body.nodeCapacityMsat),
930
1061
  nodeNumChannels: asNumber(body.nodeNumChannels),
931
1062
  nodeNumPeers: asNumber(body.nodeNumPeers),
1063
+ nodeUris: asStringList(body.nodeUris),
1064
+ sunsetDate: asIsoDate(body.sunsetDate),
1065
+ outstandingNotesMsat: asNumber(body.outstandingNotesMsat),
932
1066
  name: asString(body.name),
933
1067
  description: asString(body.description),
934
1068
  contact: asContact(body.contact),
@@ -1588,4 +1722,4 @@ var createClient = (options = {}) => ({
1588
1722
  settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
1589
1723
  });
1590
1724
 
1591
- 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 };
1725
+ export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, UnverifiableNoteError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, cashDomainIndices, cashNodeFromHex, cashNodeToCx1, cashNodeToHex, cashSecretAt, cashSecretSource, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodeCk1, decodeCp1, decodeCs1, decodeCx1, decodePaymentRequest, defaultRandomSecret, deriveCashAddressNode, deriveCashChild, deriveCashDomainNode, deriveCashRoot, deriveCashSecret, deriveNotePubkey, deriveNoteRoot, deriveNoteSecret, deriveNoteSecretKey, derivedSecretSource, describeMintFee, encodeCk1, encodeCp1, encodeCs1, encodeCx1, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isCk1, isCp1, isCs1, isCx1, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, recoverNoteOwnershipPubkey, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreFromSeed, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, signNoteOwnership, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
package/llms.txt CHANGED
@@ -48,6 +48,22 @@ cashSecretSource(root, host, start) -> RandomSecret & {index()}
48
48
  cashNodeToHex/cashNodeFromHex privateKey || chainCode, 64 bytes
49
49
  deriveCashChild(node, rawUint32) -> CashNode BIP-32 CKDpriv, for checking
50
50
  this library against BIP-32's own vectors
51
+ LUD-25 Part 2: a note keyed by a public key, spent by a recoverable signature
52
+ encodeCp1/decodeCp1/isCp1 note pubkey, 32-byte x-only, bech32m "cp"
53
+ encodeCk1/decodeCk1/isCk1 note bearer secret, 65-byte r||s||recid, "ck"
54
+ encodeCs1/decodeCs1/isCs1 mint certificate over hex(pk), same layout, "cs"
55
+ encodeCx1/decodeCx1/isCx1 watch-only branch {pubkeyXOnly, chainCode}, "cx"
56
+ decoders return null, never throw; mixed case is refused (BIP-350)
57
+ deriveCashAddressNode(root, host) -> CashNode m/139'/1'/d1..d4, hashing key
58
+ m/139'/1'/0. The REFERENCE WALLET's path, not the spec text's m/139'/d1..d4
59
+ (that is the Part 1 ladder's node, and finds none of its notes).
60
+ cashNodeToCx1(node) -> Cx1
61
+ deriveNotePubkey(P, chainCode, i) -> pk watch-only, BIP-341 style tweak;
62
+ i any uint32, 4-byte big-endian, never hardened
63
+ deriveNoteSecretKey(p, chainCode, i) -> sk negates p first when P has odd y
64
+ signNoteOwnership(sk) -> 65 bytes RFC6979 over fixed "LNURLcash"
65
+ recoverNoteOwnershipPubkey(sig) -> pk | null
66
+ verifyNoteSignature also takes a ck1 as k1 and a cs1 as the signature
51
67
  deriveNoteRoot(seedBytes) -> Uint8Array LEGACY, pre-spec. Do not mint under
52
68
  deriveNoteSecret(root, host, index) -> k1 it; still scanned so old notes live.
53
69
  derivedSecretSource(root, host, start) -> RandomSecret & {index()}
@@ -164,4 +180,4 @@ ProtocolError a non-mutating response did not match the spec, which
164
180
  ## Conformance
165
181
 
166
182
  Vectors and an adversarial mock mint:
167
- https://github.com/TheCryptoDonkey/lnurlcash-conformance
183
+ https://github.com/lnurlcash/lnurlcash-conformance
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.8.1",
3
+ "version": "0.10.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",
@@ -9,11 +9,11 @@
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "git+https://github.com/TheCryptoDonkey/lnurlcash-kit.git"
12
+ "url": "git+https://github.com/lnurlcash/lnurlcash-kit.git"
13
13
  },
14
- "homepage": "https://github.com/TheCryptoDonkey/lnurlcash-kit#readme",
14
+ "homepage": "https://github.com/lnurlcash/lnurlcash-kit#readme",
15
15
  "bugs": {
16
- "url": "https://github.com/TheCryptoDonkey/lnurlcash-kit/issues"
16
+ "url": "https://github.com/lnurlcash/lnurlcash-kit/issues"
17
17
  },
18
18
  "type": "module",
19
19
  "sideEffects": false,
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^26.2.0",
63
- "lnurlcash-conformance": "^0.7.0",
63
+ "lnurlcash-conformance": "^0.8.0",
64
64
  "tsup": "^8.5.0",
65
65
  "typescript": "^5.7.0",
66
66
  "vitest": "^3.0.0"