lnurlcash-kit 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,84 @@
3
3
  Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
4
4
  may carry breaking changes; pin an exact version.
5
5
 
6
+ ## 0.4.0 - 2026-08-26
7
+
8
+ **Breaking: `restoreNotes` asks by hash, and no longer discloses note
9
+ secrets by default.**
10
+
11
+ The walk queries every index up to `gap` past the last one in use, and those
12
+ are exactly the indices the wallet is about to mint into next. Asking by
13
+ secret therefore published the next `gap` secrets the wallet would ever use,
14
+ in cleartext query strings, and the returned `next` then pointed straight at
15
+ one of them. A five-note wallet put twenty-five live-or-future secrets on the
16
+ wire and resumed at an index it had just disclosed.
17
+
18
+ - The walk now uses the informational GET's `h` parameter (LUD-25, "Checking
19
+ a note without exposing it"), so nothing spendable leaves the wallet.
20
+ `fetchNoteInfoByHash` and `buildNoteInfoUrlByHash` expose it directly, and
21
+ `RestoredNote.callback` carries the callback so a caller need not ask again
22
+ with the raw secret.
23
+ - `RestoreOptions.allowSecretDisclosure` (default `false`) permits the old
24
+ form as an explicit fallback, never automatically. When it is used,
25
+ `RestoreResult.disclosesSecrets` is `true` and `next` skips every index the
26
+ walk touched, because a disclosed secret is spent whether or not a note was
27
+ ever minted under it.
28
+ - `h` support is OPTIONAL in LUD-25 with no capability flag, so a SERVICE
29
+ that cannot answer by hash is indistinguishable from one holding none of
30
+ the notes asked about. A walk that never gets a positive answer now throws
31
+ `HashLookupUnsupportedError` rather than reporting an empty wallet.
32
+ `RestoreOptions.probeK1` supplies a positive control, and
33
+ `RestoreResult.hashLookupsConfirmed` reports what was established.
34
+
35
+ **`requestInvoice` names the mint output with a LUD-12 `comment`.**
36
+
37
+ LUD-25 specifies the mint-time output hash as `comment = hex(h)`, and that is
38
+ what a conforming SERVICE reads. The kit sent only `h`, a parameter one
39
+ implementation adopted before the comment form was written, so a wallet
40
+ naming an output against a conforming mint was silently ignored and its note
41
+ was keyed by the payment preimage instead.
42
+
43
+ - `comment` is now sent alongside `h`. A SERVICE reading either gets the same
44
+ hash; one reading neither behaves exactly as before.
45
+ - This matters beyond conformance: an unnamed mint's `k1` **is** the payment
46
+ preimage `P`, so a SERVICE offering LUD-21 `verify` on that payment hands
47
+ the note to whoever holds the verify URL. Naming the output is what makes
48
+ `verify` safe to offer at all.
49
+
50
+ - `PayRequestInfo.commentAllowed` is surfaced and `namesMintOutput()` decides
51
+ the capability from either spelling: LUD-25 advertises it as
52
+ `commentAllowed >= 64`, one mint shipped `mintToHash` first. One rule in
53
+ one place, because both directions of a wrong answer cost a note - read it
54
+ as no and the note is the payment preimage, published on the mint's verify
55
+ URL; read it as yes and the wallet waits for a note minted elsewhere.
56
+
57
+ **Also breaking: an unrecognised refusal no longer aborts a restore, and no
58
+ longer counts toward the gap.**
59
+
60
+ `classifyNoteError` falls through to a bare `ServiceRejectedError` for any
61
+ reason string it has no pattern for, and the walk rethrew it, so a single
62
+ unfamiliar reason from a SERVICE ended the whole restore. Any new note state
63
+ - expiry being the obvious one - would have done it.
64
+
65
+ - Only `NoteUnknownError` advances the gap counter now. Every other refusal
66
+ means the SERVICE knows the index, so it resets the counter and is reported
67
+ in the new `RestoreResult.unresolved`. Advancing it was how a walk could
68
+ terminate early and silently abandon live notes beyond the run.
69
+ - Transport and protocol failures still throw: a SERVICE being down is not a
70
+ statement about an index.
71
+
72
+ ## 0.3.0 - 2026-08-24
73
+
74
+ - Additive bound-mint receipt parsing and validation for sealed signers:
75
+ `InvoiceResult.mint`, `VerifyResult.mint`, `requireBoundMintQuote` and
76
+ `validateBoundMintReceipt`. The quote must commit the requested `h` and
77
+ exact net amount before payment; the settled LUD-21 response must match the
78
+ invoice and commitment and carry a valid ordinary LUD-25 note signature.
79
+ - `noteSignatureMessageForHash`, `noteSignatureDigestForHash`,
80
+ `verifyNoteSignatureHash` and `verifyNoteSignatureHashAgainst` expose the
81
+ existing signature construction when a signer deliberately retains `k1`.
82
+ Existing secret-based helpers delegate to them unchanged.
83
+
6
84
  ## 0.2.1 - 2026-08-22
7
85
 
8
86
  - `WithdrawRequestInfo` carries `payLink`, the way home a SERVICE may
package/README.md CHANGED
@@ -358,6 +358,47 @@ is yours from the moment it exists. The claim GET does show the secret to the
358
358
  mint it is a claim on, which is a different thing from showing it to whoever
359
359
  scanned the QR, and you can still rotate if you want the offline signature.
360
360
 
361
+ ### A sealed signer: confirm without exporting the secret
362
+
363
+ A hardware vault cannot use the claim GET above without handing `k1` to its
364
+ companion. A receipt-capable mint can instead commit the quote to the requested
365
+ `h` and exact net amount, then place the ordinary LUD-25 note signature on its
366
+ settled LUD-21 response. The extension is optional; absence means use the
367
+ unchanged preimage-import-and-rotate flow before showing an invoice.
368
+
369
+ ```ts
370
+ import {
371
+ requestInvoice, fetchInvoiceVerification,
372
+ requireBoundMintQuote, validateBoundMintReceipt
373
+ } from 'lnurlcash-kit'
374
+
375
+ const staged = await vault.newSecret() // {id, h}; k1 stays in the vault
376
+ const expectedNetMsat = 21_000
377
+ const quote = await requestInvoice(pay.callback, 21_000, {h: staged.h})
378
+
379
+ // Do this before displaying or paying quote.pr.
380
+ requireBoundMintQuote(quote, staged.h, expectedNetMsat)
381
+ if (!quote.verify) throw new Error('No settlement receipt offered')
382
+
383
+ // After payment, poll quote.verify until settled.
384
+ const verification = await fetchInvoiceVerification(quote.verify)
385
+ const receipt = validateBoundMintReceipt(
386
+ quote,
387
+ verification,
388
+ staged.h,
389
+ expectedNetMsat,
390
+ pinnedMintPubkeys
391
+ )
392
+ await vault.confirm(staged.id, receipt.amountMsat, mintHost, receipt.signature)
393
+ ```
394
+
395
+ `quote.mint` is `{h, amountMsat}` in the typed API (`amount` on the wire).
396
+ The settled response must repeat that commitment and add `signature` (`sig`
397
+ on the wire). Validation matches the invoice, output and amount, refuses a
398
+ pre-settlement signature, and recovers the signer against the pinned current
399
+ or previous mint keys. The payment preimage remains proof of payment; it never
400
+ replaces the vault's staged secret.
401
+
361
402
  ## Asking to be paid
362
403
 
363
404
  "Send me 500 sat" today means handing over a Lightning Address, which is a
package/dist/index.d.ts CHANGED
@@ -31,6 +31,9 @@ declare const grossUpForMintFee: (netMsat: number, fee: MintFee) => number;
31
31
  declare const formatFeePercent: (ppm: number) => string;
32
32
  declare const describeMintFee: (fee: MintFee) => string;
33
33
 
34
+ type NoteInfoByHash = Omit<WithdrawRequestInfo, 'k1'> & {
35
+ k1?: string;
36
+ };
34
37
  type WithdrawRequestInfo = {
35
38
  tag: 'withdrawRequest';
36
39
  callback: string;
@@ -42,6 +45,7 @@ type WithdrawRequestInfo = {
42
45
  payLink?: string;
43
46
  };
44
47
  declare const fetchNoteInfo: (url: string, options?: LnurlcashOptions) => Promise<WithdrawRequestInfo>;
48
+ declare const fetchNoteInfoByHash: (withdrawLink: string, h: string, options?: LnurlcashOptions) => Promise<NoteInfoByHash>;
45
49
  declare const probeBurnedNote: (url: string, options?: LnurlcashOptions) => Promise<"live" | "gone" | "unknown">;
46
50
  type MintContact = {
47
51
  nostr?: string;
@@ -126,13 +130,27 @@ type PayRequestInfo = {
126
130
  mintPubkey?: string;
127
131
  mintFee?: MintFee;
128
132
  mintToHash?: boolean;
133
+ commentAllowed?: number;
129
134
  };
130
135
  declare const fetchPayRequest: (url: string, options?: LnurlcashOptions) => Promise<PayRequestInfo>;
136
+ declare const namesMintOutput: (info: {
137
+ mintToHash?: boolean;
138
+ commentAllowed?: number;
139
+ }) => boolean;
131
140
  type InvoiceResult = {
132
141
  pr: string;
133
142
  verify?: string;
134
143
  disposable: boolean;
135
144
  mintToHash: boolean;
145
+ mint?: BoundMintCommitment;
146
+ };
147
+ type BoundMintCommitment = {
148
+ h: string;
149
+ amountMsat: number;
150
+ signature?: string;
151
+ };
152
+ type ValidatedBoundMintReceipt = Required<BoundMintCommitment> & {
153
+ pubkey: string;
136
154
  };
137
155
  type InvoiceRequestOptions = LnurlcashOptions & {
138
156
  h?: string;
@@ -142,8 +160,11 @@ type VerifyResult = {
142
160
  settled: boolean;
143
161
  preimage: string | null;
144
162
  pr: string;
163
+ mint?: BoundMintCommitment;
145
164
  };
146
165
  declare const fetchInvoiceVerification: (verifyUrl: string, options?: LnurlcashOptions) => Promise<VerifyResult>;
166
+ declare const requireBoundMintQuote: (invoice: InvoiceResult, expectedH: string, expectedAmountMsat: number) => BoundMintCommitment;
167
+ declare const validateBoundMintReceipt: (invoice: InvoiceResult, verification: VerifyResult, expectedH: string, expectedAmountMsat: number, mintPubkeys: string | string[]) => ValidatedBoundMintReceipt;
147
168
  type MintClaim = {
148
169
  state: 'minted' | 'unminted' | 'pending' | 'spent';
149
170
  k1: string;
@@ -168,16 +189,27 @@ type RestoredNote = {
168
189
  k1: string;
169
190
  amountMsat: number | null;
170
191
  state: 'live' | 'pending';
192
+ callback?: string;
193
+ };
194
+ type UnresolvedIndex = {
195
+ index: number;
196
+ k1: string;
197
+ reason: string;
171
198
  };
172
199
  type RestoreResult = {
173
200
  found: RestoredNote[];
201
+ unresolved: UnresolvedIndex[];
174
202
  next: number;
203
+ hashLookupsConfirmed: boolean;
204
+ disclosesSecrets: boolean;
175
205
  };
176
206
  type RestoreOptions = {
177
207
  gap?: number;
178
208
  start?: number;
209
+ probeK1?: string;
210
+ allowSecretDisclosure?: boolean;
179
211
  };
180
- declare const restoreNotes: (baseUrl: string, root: Uint8Array, host: string, { gap, start }?: RestoreOptions, options?: LnurlcashOptions) => Promise<RestoreResult>;
212
+ declare const restoreNotes: (baseUrl: string, root: Uint8Array, host: string, { gap, start, probeK1, allowSecretDisclosure }?: RestoreOptions, options?: LnurlcashOptions) => Promise<RestoreResult>;
181
213
 
182
214
  declare const isBech32Lnurl: (data: string) => boolean;
183
215
  declare const toBech32Lnurl: (url: string) => string;
@@ -198,6 +230,7 @@ declare const noteDeclaredAmount: (url: string) => number | null;
198
230
  declare const noteSignature: (url: string) => string | null;
199
231
  declare const resolveNoteInput: (value: string) => string | null;
200
232
  declare const isValidNoteInput: (value: string) => boolean;
233
+ declare const buildNoteInfoUrlByHash: (withdrawLink: string, h: string) => string;
201
234
  declare const buildNoteUrl: (withdrawLink: string, k1: string, amountMsat?: number) => string;
202
235
  declare const withNewK1: (url: string, k1: string, amountMsat: number, signature?: string) => string;
203
236
  declare const withoutK1: (url: string, amountMsat: number, signature?: string) => string;
@@ -226,7 +259,9 @@ declare const decodePaymentRequest: (value: string, { now }?: DecodeOptions) =>
226
259
  declare const isPaymentRequest: (value: string) => boolean;
227
260
 
228
261
  declare const noteSignatureMessage: (k1: string, amountMsat: number) => string;
262
+ declare const noteSignatureMessageForHash: (h: string, amountMsat: number) => string;
229
263
  declare const noteSignatureDigest: (k1: string, amountMsat: number) => Uint8Array;
264
+ declare const noteSignatureDigestForHash: (h: string, amountMsat: number) => Uint8Array;
230
265
  type SignatureCheck = {
231
266
  valid: true;
232
267
  pubkey: string;
@@ -235,7 +270,9 @@ type SignatureCheck = {
235
270
  pubkey: null;
236
271
  };
237
272
  declare const verifyNoteSignatureAgainst: (k1: string, amountMsat: number, signatureHex: string, mintPubkeys: string | string[]) => SignatureCheck;
273
+ declare const verifyNoteSignatureHashAgainst: (h: string, amountMsat: number, signatureHex: string, mintPubkeys: string | string[]) => SignatureCheck;
238
274
  declare const verifyNoteSignature: (k1: string, amountMsat: number, signatureHex: string, mintPubkeys: string | string[]) => boolean;
275
+ declare const verifyNoteSignatureHash: (h: string, amountMsat: number, signatureHex: string, mintPubkeys: string | string[]) => boolean;
239
276
 
240
277
  declare const isBolt11Invoice: (value: string) => boolean;
241
278
  declare const sameInvoice: (a: string, b: string) => boolean;
@@ -262,6 +299,8 @@ declare class NoteSpentError extends ServiceRejectedError {
262
299
  declare class NoteUnknownError extends ServiceRejectedError {
263
300
  constructor(reason: string);
264
301
  }
302
+ declare class HashLookupUnsupportedError extends LnurlcashError {
303
+ }
265
304
  declare class AmbiguousMintError extends LnurlcashError {
266
305
  }
267
306
  declare class AmbiguousMutationError extends AmbiguousMintError {
@@ -278,6 +317,7 @@ declare const classifyNoteError: (reason: string) => ServiceRejectedError;
278
317
 
279
318
  declare const createClient: (options?: LnurlcashOptions) => {
280
319
  fetchNoteInfo: (url: string) => Promise<WithdrawRequestInfo>;
320
+ fetchNoteInfoByHash: (withdrawLink: string, h: string) => Promise<NoteInfoByHash>;
281
321
  probeBurnedNote: (url: string) => Promise<"live" | "gone" | "unknown">;
282
322
  fetchMintAddress: (url: string) => Promise<MintAddressInfo>;
283
323
  meltNote: (callback: string, k1: string, pr: string) => Promise<MeltResult>;
@@ -297,4 +337,4 @@ declare const createClient: (options?: LnurlcashOptions) => {
297
337
  };
298
338
  type LnurlcashClient = ReturnType<typeof createClient>;
299
339
 
300
- export { AmbiguousMintError, AmbiguousMutationError, type DecodeOptions, 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, 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 VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureMessage, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, verifyNoteSignature, verifyNoteSignatureAgainst, withNewK1, withinMintFeeBand, withoutK1 };
340
+ 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, 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 };
package/dist/index.js CHANGED
@@ -182,6 +182,18 @@ var resolveNoteInput = (value) => {
182
182
  return url;
183
183
  };
184
184
  var isValidNoteInput = (value) => resolveNoteInput(value) !== null;
185
+ var buildNoteInfoUrlByHash = (withdrawLink, h) => {
186
+ const hex = h.trim().toLowerCase();
187
+ if (!/^[0-9a-f]{64}$/.test(hex)) {
188
+ throw new Error("A note hash must be 32 bytes of hex.");
189
+ }
190
+ const url = new URL(fromLud17(withdrawLink.trim()));
191
+ url.searchParams.delete("k1");
192
+ url.searchParams.delete("amount");
193
+ url.searchParams.delete("sig");
194
+ url.searchParams.set("h", hex);
195
+ return url.toString();
196
+ };
185
197
  var buildNoteUrl = (withdrawLink, k1, amountMsat) => {
186
198
  const url = new URL(fromLud17(withdrawLink.trim()));
187
199
  url.searchParams.set("k1", k1.trim().toLowerCase());
@@ -248,6 +260,8 @@ var NoteUnknownError = class extends ServiceRejectedError {
248
260
  this.message = `The service doesn't recognise this note (service says: "${reason}").`;
249
261
  }
250
262
  };
263
+ var HashLookupUnsupportedError = class extends LnurlcashError {
264
+ };
251
265
  var AmbiguousMintError = class extends LnurlcashError {
252
266
  };
253
267
  var AmbiguousMutationError = class extends AmbiguousMintError {
@@ -627,8 +641,69 @@ var describeMintFee = (fee) => [
627
641
  fee.baseFeeMsat > 0 ? `${Math.round(fee.baseFeeMsat / 1e3)} sat flat` : null,
628
642
  fee.feePpm > 0 ? `${formatFeePercent(fee.feePpm)}% of the amount paid` : null
629
643
  ].filter(Boolean).join(" + ");
644
+ var LIGHTNING_SIGNED_MESSAGE_PREFIX = utf8ToBytes("Lightning Signed Message:");
645
+ var noteSignatureMessage = (k1, amountMsat) => noteSignatureMessageForHash(hashK1(k1), amountMsat);
646
+ var noteSignatureMessageForHash = (h, amountMsat) => `LNURLcash:${amountMsat}:${h.trim().toLowerCase()}`;
647
+ var noteSignatureDigest = (k1, amountMsat) => noteSignatureDigestForHash(hashK1(k1), amountMsat);
648
+ var noteSignatureDigestForHash = (h, amountMsat) => sha256(
649
+ sha256(
650
+ new Uint8Array([
651
+ ...LIGHTNING_SIGNED_MESSAGE_PREFIX,
652
+ ...utf8ToBytes(noteSignatureMessageForHash(h, amountMsat))
653
+ ])
654
+ )
655
+ );
656
+ var NO_MATCH = { valid: false, pubkey: null };
657
+ var verifyNoteSignatureAgainst = (k1, amountMsat, signatureHex, mintPubkeys) => {
658
+ let h;
659
+ try {
660
+ h = hashK1(k1);
661
+ } catch {
662
+ return NO_MATCH;
663
+ }
664
+ return verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys);
665
+ };
666
+ var verifyNoteSignatureHashAgainst = (h, amountMsat, signatureHex, mintPubkeys) => {
667
+ const targets = (Array.isArray(mintPubkeys) ? mintPubkeys : [mintPubkeys]).filter((key) => typeof key === "string").map((key) => key.trim().toLowerCase());
668
+ if (targets.length === 0) return NO_MATCH;
669
+ let wireSig;
670
+ try {
671
+ wireSig = hexToBytes(signatureHex);
672
+ } catch {
673
+ return NO_MATCH;
674
+ }
675
+ if (wireSig.length !== 65) return NO_MATCH;
676
+ let digest;
677
+ try {
678
+ if (!/^[0-9a-fA-F]{64}$/.test(h.trim())) return NO_MATCH;
679
+ digest = noteSignatureDigestForHash(h, amountMsat);
680
+ } catch {
681
+ return NO_MATCH;
682
+ }
683
+ const recoveryIdFirst = new Uint8Array([
684
+ wireSig[64],
685
+ ...wireSig.subarray(0, 64)
686
+ ]);
687
+ for (const candidate of [recoveryIdFirst, wireSig]) {
688
+ try {
689
+ const recovered = bytesToHex(
690
+ secp256k1.recoverPublicKey(candidate, digest, { prehash: false })
691
+ );
692
+ if (targets.includes(recovered)) return { valid: true, pubkey: recovered };
693
+ } catch {
694
+ }
695
+ }
696
+ return NO_MATCH;
697
+ };
698
+ var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureAgainst(k1, amountMsat, signatureHex, mintPubkeys).valid;
699
+ var verifyNoteSignatureHash = (h, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys).valid;
630
700
 
631
701
  // src/client.ts
702
+ var assertWithdrawRequestShape = (body, { requireK1 }) => {
703
+ 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
+ throw new ProtocolError("Not a withdrawRequest (unexpected response).");
705
+ }
706
+ };
632
707
  var fetchNoteInfo = async (url, options = {}) => {
633
708
  const opts = resolveOptions(options);
634
709
  const reqUrl = new URL(url);
@@ -640,9 +715,7 @@ var fetchNoteInfo = async (url, options = {}) => {
640
715
  if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
641
716
  throw err;
642
717
  }
643
- if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || 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)) {
644
- throw new ProtocolError("Not a withdrawRequest (unexpected response).");
645
- }
718
+ assertWithdrawRequestShape(body, { requireK1: true });
646
719
  const queried = noteK1(url);
647
720
  if (queried && body.k1.toLowerCase() !== queried) {
648
721
  throw new ProtocolError(
@@ -655,6 +728,23 @@ var fetchNoteInfo = async (url, options = {}) => {
655
728
  else info.payLink = payLink;
656
729
  return info;
657
730
  };
731
+ var fetchNoteInfoByHash = async (withdrawLink, h, options = {}) => {
732
+ const opts = resolveOptions(options);
733
+ const reqUrl = new URL(buildNoteInfoUrlByHash(withdrawLink, h));
734
+ let body;
735
+ try {
736
+ body = await lnurlFetch(reqUrl, opts);
737
+ } catch (err) {
738
+ if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
739
+ throw err;
740
+ }
741
+ assertWithdrawRequestShape(body, { requireK1: false });
742
+ const info = body;
743
+ const payLink = sameOriginPayLink(body.payLink, reqUrl);
744
+ if (payLink === void 0) delete info.payLink;
745
+ else info.payLink = payLink;
746
+ return info;
747
+ };
658
748
  var sameOriginPayLink = (value, noteUrl) => {
659
749
  if (typeof value !== "string" || value.length === 0) return void 0;
660
750
  let candidate;
@@ -902,7 +992,21 @@ var fetchPayRequest = async (url, options = {}) => {
902
992
  return {
903
993
  ...body,
904
994
  mintFee: mintFee ?? void 0,
905
- mintToHash: asBoolean(body.mintToHash)
995
+ mintToHash: asBoolean(body.mintToHash),
996
+ commentAllowed: asNumber(body.commentAllowed)
997
+ };
998
+ };
999
+ var namesMintOutput = (info) => info.mintToHash === true || typeof info.commentAllowed === "number" && info.commentAllowed >= 64;
1000
+ var asBoundMintCommitment = (value) => {
1001
+ if (!value || typeof value !== "object") return void 0;
1002
+ const raw = value;
1003
+ if (typeof raw.h !== "string" || !/^[0-9a-fA-F]{64}$/.test(raw.h) || typeof raw.amount !== "number" || !Number.isSafeInteger(raw.amount) || raw.amount <= 0 || raw.sig !== void 0 && typeof raw.sig !== "string") {
1004
+ return void 0;
1005
+ }
1006
+ return {
1007
+ h: raw.h.toLowerCase(),
1008
+ amountMsat: raw.amount,
1009
+ ...typeof raw.sig === "string" ? { signature: raw.sig } : {}
906
1010
  };
907
1011
  };
908
1012
  var requestInvoice = async (payCallback, amountMsat, options = {}) => {
@@ -914,7 +1018,9 @@ var requestInvoice = async (payCallback, amountMsat, options = {}) => {
914
1018
  "An output hash must be 32 bytes of hex - no invoice was requested."
915
1019
  );
916
1020
  }
917
- cbUrl.searchParams.set("h", options.h.trim().toLowerCase());
1021
+ const h = options.h.trim().toLowerCase();
1022
+ cbUrl.searchParams.set("comment", h);
1023
+ cbUrl.searchParams.set("h", h);
918
1024
  }
919
1025
  const body = await lnurlFetch(cbUrl, resolveOptions(options));
920
1026
  if (typeof body?.pr !== "string") {
@@ -930,7 +1036,8 @@ var requestInvoice = async (payCallback, amountMsat, options = {}) => {
930
1036
  pr: body.pr,
931
1037
  verify: typeof body.verify === "string" ? body.verify : void 0,
932
1038
  disposable: body.disposable !== false,
933
- mintToHash: body.mintToHash === true
1039
+ mintToHash: body.mintToHash === true,
1040
+ mint: asBoundMintCommitment(body.mint)
934
1041
  };
935
1042
  };
936
1043
  var fetchInvoiceVerification = async (verifyUrl, options = {}) => {
@@ -941,9 +1048,56 @@ var fetchInvoiceVerification = async (verifyUrl, options = {}) => {
941
1048
  return {
942
1049
  settled: body.settled,
943
1050
  preimage: typeof body.preimage === "string" ? body.preimage : null,
944
- pr: body.pr
1051
+ pr: body.pr,
1052
+ mint: asBoundMintCommitment(body.mint)
945
1053
  };
946
1054
  };
1055
+ var requireBoundMintQuote = (invoice, expectedH, expectedAmountMsat) => {
1056
+ const h = expectedH.trim().toLowerCase();
1057
+ if (!isPreimage(h)) throw new ProtocolError("The expected mint output hash is malformed.");
1058
+ if (!Number.isSafeInteger(expectedAmountMsat) || expectedAmountMsat <= 0) {
1059
+ throw new ProtocolError("The expected mint amount must be positive integer millisatoshis.");
1060
+ }
1061
+ if (!invoice.mintToHash || !invoice.mint) {
1062
+ throw new ProtocolError("The service did not commit this quote to a bound mint output.");
1063
+ }
1064
+ if (invoice.mint.h !== h) {
1065
+ throw new ProtocolError("The service committed the quote to a different mint output.");
1066
+ }
1067
+ if (invoice.mint.amountMsat !== expectedAmountMsat) {
1068
+ throw new ProtocolError("The service committed the quote to a different mint amount.");
1069
+ }
1070
+ if (invoice.mint.signature !== void 0) {
1071
+ throw new ProtocolError("The service signed a mint output before the invoice settled.");
1072
+ }
1073
+ return invoice.mint;
1074
+ };
1075
+ var validateBoundMintReceipt = (invoice, verification, expectedH, expectedAmountMsat, mintPubkeys) => {
1076
+ const quote = requireBoundMintQuote(invoice, expectedH, expectedAmountMsat);
1077
+ if (!verification.settled) {
1078
+ throw new ProtocolError("The invoice has not settled.");
1079
+ }
1080
+ if (!sameInvoice(invoice.pr, verification.pr)) {
1081
+ throw new ProtocolError("The settlement receipt names a different invoice.");
1082
+ }
1083
+ const receipt = verification.mint;
1084
+ if (!receipt || receipt.h !== quote.h || receipt.amountMsat !== quote.amountMsat) {
1085
+ throw new ProtocolError("The settlement receipt does not match the mint quote.");
1086
+ }
1087
+ if (!receipt.signature) {
1088
+ throw new ProtocolError("The settled mint receipt has no note signature.");
1089
+ }
1090
+ const checked = verifyNoteSignatureHashAgainst(
1091
+ receipt.h,
1092
+ receipt.amountMsat,
1093
+ receipt.signature,
1094
+ mintPubkeys
1095
+ );
1096
+ if (!checked.valid) {
1097
+ throw new ProtocolError("The settled mint receipt has an invalid note signature.");
1098
+ }
1099
+ return { ...receipt, signature: receipt.signature, pubkey: checked.pubkey };
1100
+ };
947
1101
  var claimMintedNote = async (withdrawLink, k1, options = {}) => {
948
1102
  const secret = k1.trim().toLowerCase();
949
1103
  if (!isPreimage(secret)) {
@@ -971,49 +1125,6 @@ var claimMintedNote = async (withdrawLink, k1, options = {}) => {
971
1125
  throw err;
972
1126
  }
973
1127
  };
974
- var LIGHTNING_SIGNED_MESSAGE_PREFIX = utf8ToBytes("Lightning Signed Message:");
975
- var noteSignatureMessage = (k1, amountMsat) => `LNURLcash:${amountMsat}:${hashK1(k1)}`;
976
- var noteSignatureDigest = (k1, amountMsat) => sha256(
977
- sha256(
978
- new Uint8Array([
979
- ...LIGHTNING_SIGNED_MESSAGE_PREFIX,
980
- ...utf8ToBytes(noteSignatureMessage(k1, amountMsat))
981
- ])
982
- )
983
- );
984
- var NO_MATCH = { valid: false, pubkey: null };
985
- var verifyNoteSignatureAgainst = (k1, amountMsat, signatureHex, mintPubkeys) => {
986
- const targets = (Array.isArray(mintPubkeys) ? mintPubkeys : [mintPubkeys]).filter((key) => typeof key === "string").map((key) => key.trim().toLowerCase());
987
- if (targets.length === 0) return NO_MATCH;
988
- let wireSig;
989
- try {
990
- wireSig = hexToBytes(signatureHex);
991
- } catch {
992
- return NO_MATCH;
993
- }
994
- if (wireSig.length !== 65) return NO_MATCH;
995
- let digest;
996
- try {
997
- digest = noteSignatureDigest(k1, amountMsat);
998
- } catch {
999
- return NO_MATCH;
1000
- }
1001
- const recoveryIdFirst = new Uint8Array([
1002
- wireSig[64],
1003
- ...wireSig.subarray(0, 64)
1004
- ]);
1005
- for (const candidate of [recoveryIdFirst, wireSig]) {
1006
- try {
1007
- const recovered = bytesToHex(
1008
- secp256k1.recoverPublicKey(candidate, digest, { prehash: false })
1009
- );
1010
- if (targets.includes(recovered)) return { valid: true, pubkey: recovered };
1011
- } catch {
1012
- }
1013
- }
1014
- return NO_MATCH;
1015
- };
1016
- var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureAgainst(k1, amountMsat, signatureHex, mintPubkeys).valid;
1017
1128
 
1018
1129
  // src/settle.ts
1019
1130
  var normaliseHost = (value) => serverOf(value.trim().replace(/^@/, "")).toLowerCase();
@@ -1063,18 +1174,86 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
1063
1174
  };
1064
1175
 
1065
1176
  // src/restore.ts
1066
- var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0 } = {}, options = {}) => {
1177
+ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0, probeK1, allowSecretDisclosure = false } = {}, options = {}) => {
1067
1178
  if (!Number.isSafeInteger(gap) || gap < 1) {
1068
1179
  throw new RangeError(`The gap limit must be a positive integer, not ${gap}.`);
1069
1180
  }
1181
+ if (!Number.isSafeInteger(start) || start < 0) {
1182
+ throw new RangeError(`The start index must be a non-negative integer, not ${start}.`);
1183
+ }
1184
+ let hashLookupsConfirmed = false;
1185
+ if (probeK1) {
1186
+ try {
1187
+ await fetchNoteInfoByHash(baseUrl, hashK1(probeK1), options);
1188
+ hashLookupsConfirmed = true;
1189
+ } catch (err) {
1190
+ if (!(err instanceof ServiceRejectedError)) throw err;
1191
+ }
1192
+ }
1193
+ const byHash = await walk(
1194
+ start,
1195
+ gap,
1196
+ async (k1) => {
1197
+ const info = await fetchNoteInfoByHash(baseUrl, hashK1(k1), options);
1198
+ hashLookupsConfirmed = true;
1199
+ return info;
1200
+ },
1201
+ root,
1202
+ host
1203
+ );
1204
+ if (byHash.found.length > 0 || byHash.unresolved.length > 0) hashLookupsConfirmed = true;
1205
+ if (hashLookupsConfirmed) {
1206
+ return {
1207
+ found: byHash.found,
1208
+ unresolved: byHash.unresolved,
1209
+ next: byHash.lastUsed === null ? start : byHash.lastUsed + 1,
1210
+ hashLookupsConfirmed: true,
1211
+ disclosesSecrets: false
1212
+ };
1213
+ }
1214
+ if (!allowSecretDisclosure) {
1215
+ throw new HashLookupUnsupportedError(
1216
+ "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."
1217
+ );
1218
+ }
1219
+ const bySecret = await walk(
1220
+ start,
1221
+ gap,
1222
+ (k1) => fetchNoteInfo(buildNoteUrl(baseUrl, k1), options),
1223
+ root,
1224
+ host
1225
+ );
1226
+ const walkedThrough = bySecret.highestWalked === null ? start - 1 : bySecret.highestWalked;
1227
+ const used = bySecret.lastUsed === null ? start - 1 : bySecret.lastUsed;
1228
+ return {
1229
+ found: bySecret.found,
1230
+ unresolved: bySecret.unresolved,
1231
+ // Every index this walk touched is burned, whether or not a note was
1232
+ // ever minted under it: its secret is in a log somewhere now, so
1233
+ // minting into it later would be minting a note a stranger can spend.
1234
+ next: Math.max(used, walkedThrough) + 1,
1235
+ hashLookupsConfirmed: false,
1236
+ disclosesSecrets: true
1237
+ };
1238
+ };
1239
+ var walk = async (start, gap, lookup, root, host) => {
1070
1240
  const found = [];
1241
+ const unresolved = [];
1071
1242
  let lastUsed = null;
1243
+ let highestWalked = null;
1072
1244
  let unknownRun = 0;
1073
1245
  for (let index = start; unknownRun < gap; index++) {
1074
1246
  const k1 = deriveNoteSecret(root, host, index);
1247
+ highestWalked = index;
1075
1248
  try {
1076
- const info = await fetchNoteInfo(buildNoteUrl(baseUrl, k1), options);
1077
- found.push({ index, k1, amountMsat: info.maxWithdrawable, state: "live" });
1249
+ const info = await lookup(k1);
1250
+ found.push({
1251
+ index,
1252
+ k1,
1253
+ amountMsat: info.maxWithdrawable,
1254
+ state: "live",
1255
+ callback: info.callback
1256
+ });
1078
1257
  lastUsed = index;
1079
1258
  unknownRun = 0;
1080
1259
  } catch (err) {
@@ -1087,17 +1266,22 @@ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0 } = {}, opt
1087
1266
  unknownRun = 0;
1088
1267
  } else if (err instanceof NoteUnknownError) {
1089
1268
  unknownRun++;
1269
+ } else if (err instanceof ServiceRejectedError) {
1270
+ unresolved.push({ index, k1, reason: err.reason });
1271
+ lastUsed = index;
1272
+ unknownRun = 0;
1090
1273
  } else {
1091
1274
  throw err;
1092
1275
  }
1093
1276
  }
1094
1277
  }
1095
- return { found, next: lastUsed === null ? start : lastUsed + 1 };
1278
+ return { found, unresolved, lastUsed, highestWalked };
1096
1279
  };
1097
1280
 
1098
1281
  // src/index.ts
1099
1282
  var createClient = (options = {}) => ({
1100
1283
  fetchNoteInfo: (url) => fetchNoteInfo(url, options),
1284
+ fetchNoteInfoByHash: (withdrawLink, h) => fetchNoteInfoByHash(withdrawLink, h, options),
1101
1285
  probeBurnedNote: (url) => probeBurnedNote(url, options),
1102
1286
  fetchMintAddress: (url) => fetchMintAddress(url, options),
1103
1287
  meltNote: (callback, k1, pr) => meltNote(callback, k1, pr, options),
@@ -1119,4 +1303,4 @@ var createClient = (options = {}) => ({
1119
1303
  settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
1120
1304
  });
1121
1305
 
1122
- export { AmbiguousMintError, AmbiguousMutationError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, applyMintFee, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureMessage, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, verifyNoteSignature, verifyNoteSignatureAgainst, withNewK1, withinMintFeeBand, withoutK1 };
1306
+ 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, 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.2.1",
3
+ "version": "0.4.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.2.2",
63
+ "lnurlcash-conformance": "^0.3.0",
64
64
  "tsup": "^8.5.0",
65
65
  "typescript": "^5.7.0",
66
66
  "vitest": "^3.0.0"