lnurlcash-kit 0.3.0 → 0.5.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,105 @@
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.5.0 - 2026-08-29
7
+
8
+ **A large merge is folded in batches rather than sent as one over-long
9
+ URL.** LUD-25 bounds a merge by ordinary URL length, not by anything in the
10
+ protocol: every repeated `k1=` costs about 68 characters, and browsers,
11
+ servers and proxies commonly cap a whole URL near 2000. Past roughly 28
12
+ notes `mergeNotes` built a request that something upstream truncates,
13
+ turning a large merge into a malformed one rather than a clean refusal. It
14
+ now measures the URL this SERVICE's own callback actually produces and
15
+ folds the inputs batch into batch, each merge's output carried into the
16
+ next, as the draft advises.
17
+
18
+ A fold can fail with value already moved, which a single merge never could:
19
+ once a batch has landed, the carried note is one the SERVICE has already
20
+ minted, worth every batch folded so far, and the fold holds its only copy.
21
+ It is returned with the failure whatever the class of failure - a network
22
+ drop, a policy refusal, a k1 count the mint caps at, an input mid-melt
23
+ elsewhere - so a caller that persists what `newSecretsOf` hands it cannot
24
+ lose the fold's own output. The error's class is preserved, so a caller
25
+ telling pending apart from spent still can.
26
+
27
+ Batches are bounded by note count as well as URL length. No LUD-25 field
28
+ advertises a SERVICE's own limit on how many `k1` one request may name, and
29
+ the limits in the wild are tighter than 2000 characters allows - moneyer
30
+ defaults to 21, lnurl-mint to 100 - so batching on length alone built
31
+ requests a conforming mint refuses outright. The default is 20, under the
32
+ tightest cap known.
33
+
34
+ `mergeBatches(callback, k1s, options?)` is exported for callers that want to
35
+ plan the batches themselves - `{budget, maxNotes}`, or a bare number for the
36
+ budget as before. A caller that has learned a SERVICE's real limit should
37
+ pass it.
38
+
39
+ ## 0.4.0 - 2026-08-26
40
+
41
+ **Breaking: `restoreNotes` asks by hash, and no longer discloses note
42
+ secrets by default.**
43
+
44
+ The walk queries every index up to `gap` past the last one in use, and those
45
+ are exactly the indices the wallet is about to mint into next. Asking by
46
+ secret therefore published the next `gap` secrets the wallet would ever use,
47
+ in cleartext query strings, and the returned `next` then pointed straight at
48
+ one of them. A five-note wallet put twenty-five live-or-future secrets on the
49
+ wire and resumed at an index it had just disclosed.
50
+
51
+ - The walk now uses the informational GET's `h` parameter (LUD-25, "Checking
52
+ a note without exposing it"), so nothing spendable leaves the wallet.
53
+ `fetchNoteInfoByHash` and `buildNoteInfoUrlByHash` expose it directly, and
54
+ `RestoredNote.callback` carries the callback so a caller need not ask again
55
+ with the raw secret.
56
+ - `RestoreOptions.allowSecretDisclosure` (default `false`) permits the old
57
+ form as an explicit fallback, never automatically. When it is used,
58
+ `RestoreResult.disclosesSecrets` is `true` and `next` skips every index the
59
+ walk touched, because a disclosed secret is spent whether or not a note was
60
+ ever minted under it.
61
+ - `h` support is OPTIONAL in LUD-25 with no capability flag, so a SERVICE
62
+ that cannot answer by hash is indistinguishable from one holding none of
63
+ the notes asked about. A walk that never gets a positive answer now throws
64
+ `HashLookupUnsupportedError` rather than reporting an empty wallet.
65
+ `RestoreOptions.probeK1` supplies a positive control, and
66
+ `RestoreResult.hashLookupsConfirmed` reports what was established.
67
+
68
+ **`requestInvoice` names the mint output with a LUD-12 `comment`.**
69
+
70
+ LUD-25 specifies the mint-time output hash as `comment = hex(h)`, and that is
71
+ what a conforming SERVICE reads. The kit sent only `h`, a parameter one
72
+ implementation adopted before the comment form was written, so a wallet
73
+ naming an output against a conforming mint was silently ignored and its note
74
+ was keyed by the payment preimage instead.
75
+
76
+ - `comment` is now sent alongside `h`. A SERVICE reading either gets the same
77
+ hash; one reading neither behaves exactly as before.
78
+ - This matters beyond conformance: an unnamed mint's `k1` **is** the payment
79
+ preimage `P`, so a SERVICE offering LUD-21 `verify` on that payment hands
80
+ the note to whoever holds the verify URL. Naming the output is what makes
81
+ `verify` safe to offer at all.
82
+
83
+ - `PayRequestInfo.commentAllowed` is surfaced and `namesMintOutput()` decides
84
+ the capability from either spelling: LUD-25 advertises it as
85
+ `commentAllowed >= 64`, one mint shipped `mintToHash` first. One rule in
86
+ one place, because both directions of a wrong answer cost a note - read it
87
+ as no and the note is the payment preimage, published on the mint's verify
88
+ URL; read it as yes and the wallet waits for a note minted elsewhere.
89
+
90
+ **Also breaking: an unrecognised refusal no longer aborts a restore, and no
91
+ longer counts toward the gap.**
92
+
93
+ `classifyNoteError` falls through to a bare `ServiceRejectedError` for any
94
+ reason string it has no pattern for, and the walk rethrew it, so a single
95
+ unfamiliar reason from a SERVICE ended the whole restore. Any new note state
96
+ - expiry being the obvious one - would have done it.
97
+
98
+ - Only `NoteUnknownError` advances the gap counter now. Every other refusal
99
+ means the SERVICE knows the index, so it resets the counter and is reported
100
+ in the new `RestoreResult.unresolved`. Advancing it was how a walk could
101
+ terminate early and silently abandon live notes beyond the run.
102
+ - Transport and protocol failures still throw: a SERVICE being down is not a
103
+ statement about an index.
104
+
6
105
  ## 0.3.0 - 2026-08-24
7
106
 
8
107
  - Additive bound-mint receipt parsing and validation for sealed signers:
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;
@@ -108,6 +112,11 @@ type SplitResult = {
108
112
  changeSignature?: string;
109
113
  };
110
114
  declare const splitNote: (callback: string, k1s: string[], amountMsat: number, options?: LnurlcashOptions) => Promise<SplitResult>;
115
+ type MergeBatchOptions = {
116
+ budget?: number;
117
+ maxNotes?: number;
118
+ };
119
+ declare const mergeBatches: (callback: string, k1s: string[], options?: MergeBatchOptions | number) => string[][];
111
120
  declare const mergeNotes: (callback: string, k1s: string[], options?: LnurlcashOptions) => Promise<RotateResult>;
112
121
  type SettledNote = {
113
122
  k1: string;
@@ -126,8 +135,13 @@ type PayRequestInfo = {
126
135
  mintPubkey?: string;
127
136
  mintFee?: MintFee;
128
137
  mintToHash?: boolean;
138
+ commentAllowed?: number;
129
139
  };
130
140
  declare const fetchPayRequest: (url: string, options?: LnurlcashOptions) => Promise<PayRequestInfo>;
141
+ declare const namesMintOutput: (info: {
142
+ mintToHash?: boolean;
143
+ commentAllowed?: number;
144
+ }) => boolean;
131
145
  type InvoiceResult = {
132
146
  pr: string;
133
147
  verify?: string;
@@ -180,16 +194,27 @@ type RestoredNote = {
180
194
  k1: string;
181
195
  amountMsat: number | null;
182
196
  state: 'live' | 'pending';
197
+ callback?: string;
198
+ };
199
+ type UnresolvedIndex = {
200
+ index: number;
201
+ k1: string;
202
+ reason: string;
183
203
  };
184
204
  type RestoreResult = {
185
205
  found: RestoredNote[];
206
+ unresolved: UnresolvedIndex[];
186
207
  next: number;
208
+ hashLookupsConfirmed: boolean;
209
+ disclosesSecrets: boolean;
187
210
  };
188
211
  type RestoreOptions = {
189
212
  gap?: number;
190
213
  start?: number;
214
+ probeK1?: string;
215
+ allowSecretDisclosure?: boolean;
191
216
  };
192
- declare const restoreNotes: (baseUrl: string, root: Uint8Array, host: string, { gap, start }?: RestoreOptions, options?: LnurlcashOptions) => Promise<RestoreResult>;
217
+ declare const restoreNotes: (baseUrl: string, root: Uint8Array, host: string, { gap, start, probeK1, allowSecretDisclosure }?: RestoreOptions, options?: LnurlcashOptions) => Promise<RestoreResult>;
193
218
 
194
219
  declare const isBech32Lnurl: (data: string) => boolean;
195
220
  declare const toBech32Lnurl: (url: string) => string;
@@ -210,6 +235,7 @@ declare const noteDeclaredAmount: (url: string) => number | null;
210
235
  declare const noteSignature: (url: string) => string | null;
211
236
  declare const resolveNoteInput: (value: string) => string | null;
212
237
  declare const isValidNoteInput: (value: string) => boolean;
238
+ declare const buildNoteInfoUrlByHash: (withdrawLink: string, h: string) => string;
213
239
  declare const buildNoteUrl: (withdrawLink: string, k1: string, amountMsat?: number) => string;
214
240
  declare const withNewK1: (url: string, k1: string, amountMsat: number, signature?: string) => string;
215
241
  declare const withoutK1: (url: string, amountMsat: number, signature?: string) => string;
@@ -278,6 +304,8 @@ declare class NoteSpentError extends ServiceRejectedError {
278
304
  declare class NoteUnknownError extends ServiceRejectedError {
279
305
  constructor(reason: string);
280
306
  }
307
+ declare class HashLookupUnsupportedError extends LnurlcashError {
308
+ }
281
309
  declare class AmbiguousMintError extends LnurlcashError {
282
310
  }
283
311
  declare class AmbiguousMutationError extends AmbiguousMintError {
@@ -294,6 +322,7 @@ declare const classifyNoteError: (reason: string) => ServiceRejectedError;
294
322
 
295
323
  declare const createClient: (options?: LnurlcashOptions) => {
296
324
  fetchNoteInfo: (url: string) => Promise<WithdrawRequestInfo>;
325
+ fetchNoteInfoByHash: (withdrawLink: string, h: string) => Promise<NoteInfoByHash>;
297
326
  probeBurnedNote: (url: string) => Promise<"live" | "gone" | "unknown">;
298
327
  fetchMintAddress: (url: string) => Promise<MintAddressInfo>;
299
328
  meltNote: (callback: string, k1: string, pr: string) => Promise<MeltResult>;
@@ -313,4 +342,4 @@ declare const createClient: (options?: LnurlcashOptions) => {
313
342
  };
314
343
  type LnurlcashClient = ReturnType<typeof createClient>;
315
344
 
316
- export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, 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 ValidatedBoundMintReceipt, 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, 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 };
345
+ export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type DecodeOptions, HashLookupUnsupportedError, type HashedMutationResult, type HashedSplitResult, InsufficientValueError, type InvoiceRequestOptions, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintClaim, type MintContact, type MintFee, type MintFeeBand, type NoteInfoByHash, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, type PayRequestInfo, type PaymentRequest, type PaymentRequestMethodDetails, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RestoreOptions, type RestoreResult, type RestoredNote, type RotateResult, ServiceRejectedError, type SettleForValueOptions, type SettledForValue, type SettledNote, type SignatureCheck, type SplitResult, type UnresolvedIndex, type ValidatedBoundMintReceipt, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
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 {
@@ -685,6 +699,11 @@ var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeys) => verifyN
685
699
  var verifyNoteSignatureHash = (h, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys).valid;
686
700
 
687
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
+ };
688
707
  var fetchNoteInfo = async (url, options = {}) => {
689
708
  const opts = resolveOptions(options);
690
709
  const reqUrl = new URL(url);
@@ -696,9 +715,7 @@ var fetchNoteInfo = async (url, options = {}) => {
696
715
  if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
697
716
  throw err;
698
717
  }
699
- 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)) {
700
- throw new ProtocolError("Not a withdrawRequest (unexpected response).");
701
- }
718
+ assertWithdrawRequestShape(body, { requireK1: true });
702
719
  const queried = noteK1(url);
703
720
  if (queried && body.k1.toLowerCase() !== queried) {
704
721
  throw new ProtocolError(
@@ -711,6 +728,23 @@ var fetchNoteInfo = async (url, options = {}) => {
711
728
  else info.payLink = payLink;
712
729
  return info;
713
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
+ };
714
748
  var sameOriginPayLink = (value, noteUrl) => {
715
749
  if (typeof value !== "string" || value.length === 0) return void 0;
716
750
  let candidate;
@@ -914,8 +948,39 @@ var splitNote = async (callback, k1s, amountMsat, options = {}) => {
914
948
  throw keepingOutputs(err, [newK1, changeK1]);
915
949
  }
916
950
  };
951
+ var MAX_URL_CHARS = 2e3;
952
+ var MAX_NOTES_PER_BATCH = 20;
953
+ var mergeBatches = (callback, k1s, options = {}) => {
954
+ const { budget = MAX_URL_CHARS, maxNotes = MAX_NOTES_PER_BATCH } = typeof options === "number" ? { budget: options } : options;
955
+ const placeholder = "0".repeat(64);
956
+ try {
957
+ new URL(callback);
958
+ } catch {
959
+ throw new RequestRefusedError("The service provided an invalid callback URL.");
960
+ }
961
+ const fits = (candidate, carried) => {
962
+ const url = new URL(callback);
963
+ if (carried) url.searchParams.append("k1", placeholder);
964
+ for (const k1 of candidate) url.searchParams.append("k1", k1);
965
+ url.searchParams.append("h", placeholder);
966
+ return url.href.length <= budget;
967
+ };
968
+ const batches = [];
969
+ let batch = [];
970
+ for (const k1 of k1s) {
971
+ const next = [...batch, k1];
972
+ if (batch.length > 0 && (next.length > maxNotes || !fits(next, batches.length > 0))) {
973
+ batches.push(batch);
974
+ batch = [k1];
975
+ } else batch = next;
976
+ }
977
+ if (batch.length > 0) batches.push(batch);
978
+ return batches;
979
+ };
917
980
  var mergeNotes = async (callback, k1s, options = {}) => {
918
981
  const opts = resolveOptions(options);
982
+ const batches = mergeBatches(callback, k1s);
983
+ if (batches.length > 1) return foldNotes(callback, batches, opts, options);
919
984
  const newK1 = opts.randomSecret();
920
985
  try {
921
986
  const result = await mergeNotesWithHash(callback, k1s, hashK1(newK1), options);
@@ -927,6 +992,34 @@ var mergeNotes = async (callback, k1s, options = {}) => {
927
992
  throw keepingOutputs(err, [newK1]);
928
993
  }
929
994
  };
995
+ var foldNotes = async (callback, batches, opts, options) => {
996
+ let carried = null;
997
+ let signature;
998
+ for (const batch of batches) {
999
+ const inputs = carried === null ? batch : [carried, ...batch];
1000
+ const newK1 = opts.randomSecret();
1001
+ try {
1002
+ const result = await mergeNotesWithHash(callback, inputs, hashK1(newK1), options);
1003
+ carried = newK1;
1004
+ signature = result.signature;
1005
+ } catch (err) {
1006
+ const live = carried === null ? [newK1] : [carried, newK1];
1007
+ if (err instanceof AmbiguousMintError) {
1008
+ throw new AmbiguousMutationError(err.message, live);
1009
+ }
1010
+ if (carried === null) throw keepingOutputs(err, live);
1011
+ if (err instanceof ServiceRejectedError) {
1012
+ err.newSecrets = live;
1013
+ throw err;
1014
+ }
1015
+ throw new AmbiguousMutationError(
1016
+ err instanceof Error ? err.message : String(err),
1017
+ live
1018
+ );
1019
+ }
1020
+ }
1021
+ return { k1: carried, signature };
1022
+ };
930
1023
  var settleNote = async (baseUrl, k1, expectedAmountMsat, signature, options = {}) => {
931
1024
  const info = await fetchNoteInfo(
932
1025
  withNewK1(baseUrl, k1, expectedAmountMsat, signature),
@@ -958,9 +1051,11 @@ var fetchPayRequest = async (url, options = {}) => {
958
1051
  return {
959
1052
  ...body,
960
1053
  mintFee: mintFee ?? void 0,
961
- mintToHash: asBoolean(body.mintToHash)
1054
+ mintToHash: asBoolean(body.mintToHash),
1055
+ commentAllowed: asNumber(body.commentAllowed)
962
1056
  };
963
1057
  };
1058
+ var namesMintOutput = (info) => info.mintToHash === true || typeof info.commentAllowed === "number" && info.commentAllowed >= 64;
964
1059
  var asBoundMintCommitment = (value) => {
965
1060
  if (!value || typeof value !== "object") return void 0;
966
1061
  const raw = value;
@@ -982,7 +1077,9 @@ var requestInvoice = async (payCallback, amountMsat, options = {}) => {
982
1077
  "An output hash must be 32 bytes of hex - no invoice was requested."
983
1078
  );
984
1079
  }
985
- cbUrl.searchParams.set("h", options.h.trim().toLowerCase());
1080
+ const h = options.h.trim().toLowerCase();
1081
+ cbUrl.searchParams.set("comment", h);
1082
+ cbUrl.searchParams.set("h", h);
986
1083
  }
987
1084
  const body = await lnurlFetch(cbUrl, resolveOptions(options));
988
1085
  if (typeof body?.pr !== "string") {
@@ -1136,18 +1233,86 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
1136
1233
  };
1137
1234
 
1138
1235
  // src/restore.ts
1139
- var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0 } = {}, options = {}) => {
1236
+ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0, probeK1, allowSecretDisclosure = false } = {}, options = {}) => {
1140
1237
  if (!Number.isSafeInteger(gap) || gap < 1) {
1141
1238
  throw new RangeError(`The gap limit must be a positive integer, not ${gap}.`);
1142
1239
  }
1240
+ if (!Number.isSafeInteger(start) || start < 0) {
1241
+ throw new RangeError(`The start index must be a non-negative integer, not ${start}.`);
1242
+ }
1243
+ let hashLookupsConfirmed = false;
1244
+ if (probeK1) {
1245
+ try {
1246
+ await fetchNoteInfoByHash(baseUrl, hashK1(probeK1), options);
1247
+ hashLookupsConfirmed = true;
1248
+ } catch (err) {
1249
+ if (!(err instanceof ServiceRejectedError)) throw err;
1250
+ }
1251
+ }
1252
+ const byHash = await walk(
1253
+ start,
1254
+ gap,
1255
+ async (k1) => {
1256
+ const info = await fetchNoteInfoByHash(baseUrl, hashK1(k1), options);
1257
+ hashLookupsConfirmed = true;
1258
+ return info;
1259
+ },
1260
+ root,
1261
+ host
1262
+ );
1263
+ if (byHash.found.length > 0 || byHash.unresolved.length > 0) hashLookupsConfirmed = true;
1264
+ if (hashLookupsConfirmed) {
1265
+ return {
1266
+ found: byHash.found,
1267
+ unresolved: byHash.unresolved,
1268
+ next: byHash.lastUsed === null ? start : byHash.lastUsed + 1,
1269
+ hashLookupsConfirmed: true,
1270
+ disclosesSecrets: false
1271
+ };
1272
+ }
1273
+ if (!allowSecretDisclosure) {
1274
+ throw new HashLookupUnsupportedError(
1275
+ "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."
1276
+ );
1277
+ }
1278
+ const bySecret = await walk(
1279
+ start,
1280
+ gap,
1281
+ (k1) => fetchNoteInfo(buildNoteUrl(baseUrl, k1), options),
1282
+ root,
1283
+ host
1284
+ );
1285
+ const walkedThrough = bySecret.highestWalked === null ? start - 1 : bySecret.highestWalked;
1286
+ const used = bySecret.lastUsed === null ? start - 1 : bySecret.lastUsed;
1287
+ return {
1288
+ found: bySecret.found,
1289
+ unresolved: bySecret.unresolved,
1290
+ // Every index this walk touched is burned, whether or not a note was
1291
+ // ever minted under it: its secret is in a log somewhere now, so
1292
+ // minting into it later would be minting a note a stranger can spend.
1293
+ next: Math.max(used, walkedThrough) + 1,
1294
+ hashLookupsConfirmed: false,
1295
+ disclosesSecrets: true
1296
+ };
1297
+ };
1298
+ var walk = async (start, gap, lookup, root, host) => {
1143
1299
  const found = [];
1300
+ const unresolved = [];
1144
1301
  let lastUsed = null;
1302
+ let highestWalked = null;
1145
1303
  let unknownRun = 0;
1146
1304
  for (let index = start; unknownRun < gap; index++) {
1147
1305
  const k1 = deriveNoteSecret(root, host, index);
1306
+ highestWalked = index;
1148
1307
  try {
1149
- const info = await fetchNoteInfo(buildNoteUrl(baseUrl, k1), options);
1150
- found.push({ index, k1, amountMsat: info.maxWithdrawable, state: "live" });
1308
+ const info = await lookup(k1);
1309
+ found.push({
1310
+ index,
1311
+ k1,
1312
+ amountMsat: info.maxWithdrawable,
1313
+ state: "live",
1314
+ callback: info.callback
1315
+ });
1151
1316
  lastUsed = index;
1152
1317
  unknownRun = 0;
1153
1318
  } catch (err) {
@@ -1160,17 +1325,22 @@ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0 } = {}, opt
1160
1325
  unknownRun = 0;
1161
1326
  } else if (err instanceof NoteUnknownError) {
1162
1327
  unknownRun++;
1328
+ } else if (err instanceof ServiceRejectedError) {
1329
+ unresolved.push({ index, k1, reason: err.reason });
1330
+ lastUsed = index;
1331
+ unknownRun = 0;
1163
1332
  } else {
1164
1333
  throw err;
1165
1334
  }
1166
1335
  }
1167
1336
  }
1168
- return { found, next: lastUsed === null ? start : lastUsed + 1 };
1337
+ return { found, unresolved, lastUsed, highestWalked };
1169
1338
  };
1170
1339
 
1171
1340
  // src/index.ts
1172
1341
  var createClient = (options = {}) => ({
1173
1342
  fetchNoteInfo: (url) => fetchNoteInfo(url, options),
1343
+ fetchNoteInfoByHash: (withdrawLink, h) => fetchNoteInfoByHash(withdrawLink, h, options),
1174
1344
  probeBurnedNote: (url) => probeBurnedNote(url, options),
1175
1345
  fetchMintAddress: (url) => fetchMintAddress(url, options),
1176
1346
  meltNote: (callback, k1, pr) => meltNote(callback, k1, pr, options),
@@ -1192,4 +1362,4 @@ var createClient = (options = {}) => ({
1192
1362
  settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
1193
1363
  });
1194
1364
 
1195
- 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, 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 };
1365
+ export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",