lnurlcash-kit 0.1.2 → 0.2.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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { bech32 } from '@scure/base';
1
+ import { bech32, base64urlnopad } from '@scure/base';
2
+ import { hmac } from '@noble/hashes/hmac.js';
2
3
  import { sha256 } from '@noble/hashes/sha2.js';
3
4
  import { utf8ToBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
4
5
  import { secp256k1 } from '@noble/curves/secp256k1.js';
@@ -122,6 +123,23 @@ var serverOf = (url) => {
122
123
  var hashK1 = (k1) => bytesToHex(sha256(hexToBytes(k1)));
123
124
  var defaultRandomSecret = () => bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
124
125
  var isPreimage = (value) => /^[0-9a-fA-F]{64}$/.test(value.trim());
126
+ var NOTE_DERIVATION_DOMAIN = utf8ToBytes("lnurlcash-note-v1");
127
+ var deriveNoteRoot = (seed) => hmac(sha256, NOTE_DERIVATION_DOMAIN, seed);
128
+ var requireIndex = (index) => {
129
+ if (!Number.isSafeInteger(index) || index < 0) {
130
+ throw new RangeError(
131
+ `A note index must be a non-negative integer, not ${index}.`
132
+ );
133
+ }
134
+ return index;
135
+ };
136
+ var deriveNoteSecret = (root, host, index) => bytesToHex(hmac(sha256, root, utf8ToBytes(`${host}:${requireIndex(index)}`)));
137
+ var derivedSecretSource = (root, host, start = 0) => {
138
+ let next = requireIndex(start);
139
+ const source = (() => deriveNoteSecret(root, host, next++));
140
+ source.index = () => next;
141
+ return source;
142
+ };
125
143
 
126
144
  // src/note.ts
127
145
  var noteK1 = (url) => {
@@ -188,125 +206,6 @@ var withoutK1 = (url, amountMsat, signature) => {
188
206
  else newUrl.searchParams.delete("sig");
189
207
  return newUrl.toString();
190
208
  };
191
- var LIGHTNING_SIGNED_MESSAGE_PREFIX = utf8ToBytes("Lightning Signed Message:");
192
- var noteSignatureMessage = (k1, amountMsat) => `LNURLcash:${amountMsat}:${hashK1(k1)}`;
193
- var noteSignatureDigest = (k1, amountMsat) => sha256(
194
- sha256(
195
- new Uint8Array([
196
- ...LIGHTNING_SIGNED_MESSAGE_PREFIX,
197
- ...utf8ToBytes(noteSignatureMessage(k1, amountMsat))
198
- ])
199
- )
200
- );
201
- var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeyHex) => {
202
- let wireSig;
203
- try {
204
- wireSig = hexToBytes(signatureHex);
205
- } catch {
206
- return false;
207
- }
208
- if (wireSig.length !== 65) return false;
209
- let digest;
210
- try {
211
- digest = noteSignatureDigest(k1, amountMsat);
212
- } catch {
213
- return false;
214
- }
215
- const target = mintPubkeyHex.trim().toLowerCase();
216
- const recoveryIdFirst = new Uint8Array([
217
- wireSig[64],
218
- ...wireSig.subarray(0, 64)
219
- ]);
220
- for (const candidate of [recoveryIdFirst, wireSig]) {
221
- try {
222
- const recovered = secp256k1.recoverPublicKey(candidate, digest, {
223
- prehash: false
224
- });
225
- if (bytesToHex(recovered) === target) return true;
226
- } catch {
227
- }
228
- }
229
- return false;
230
- };
231
-
232
- // src/fees.ts
233
- var parseMintFee = (metadata) => {
234
- let entries;
235
- try {
236
- entries = JSON.parse(metadata);
237
- } catch {
238
- return null;
239
- }
240
- if (!Array.isArray(entries)) return null;
241
- for (const entry of entries) {
242
- if (!Array.isArray(entry) || entry[0] !== "text/plain") continue;
243
- const match = typeof entry[1] === "string" && entry[1].match(/^Mint fees:\s*(\d+)\s*,\s*(\d+)\s*$/);
244
- if (!match) continue;
245
- const baseFeeMsat = Number(match[1]);
246
- const feePpm = Number(match[2]);
247
- if (!Number.isSafeInteger(baseFeeMsat) || !Number.isSafeInteger(feePpm)) continue;
248
- if (feePpm >= 1e6) continue;
249
- if (baseFeeMsat === 0 && feePpm === 0) return null;
250
- return { baseFeeMsat, feePpm };
251
- }
252
- return null;
253
- };
254
- var proportionalFee = (grossMsat, feePpm) => Math.floor(grossMsat / 1e6) * feePpm + Math.floor(grossMsat % 1e6 * feePpm / 1e6);
255
- var applyMintFee = (grossMsat, fee) => Math.max(0, grossMsat - fee.baseFeeMsat - proportionalFee(grossMsat, fee.feePpm));
256
- var mintFeeBand = (grossMsat, fee) => {
257
- const exactFee = fee.baseFeeMsat + proportionalFee(grossMsat, fee.feePpm);
258
- const satCeilinged = Math.ceil(exactFee / 1e3) * 1e3;
259
- return {
260
- minNetMsat: Math.max(0, grossMsat - satCeilinged),
261
- maxNetMsat: Math.max(0, grossMsat - exactFee)
262
- };
263
- };
264
- var withinMintFeeBand = (grossMsat, netMsat, fee) => {
265
- const { minNetMsat, maxNetMsat } = mintFeeBand(grossMsat, fee);
266
- return netMsat >= minNetMsat && netMsat <= maxNetMsat;
267
- };
268
- var grossUpForMintFee = (netMsat, fee) => {
269
- if (netMsat <= 0) return 0;
270
- let hi = netMsat + fee.baseFeeMsat;
271
- while (applyMintFee(hi, fee) < netMsat) hi *= 2;
272
- let lo = 0;
273
- while (lo < hi) {
274
- const mid = Math.floor((lo + hi) / 2);
275
- if (applyMintFee(mid, fee) >= netMsat) hi = mid;
276
- else lo = mid + 1;
277
- }
278
- return lo;
279
- };
280
- var formatFeePercent = (ppm) => (ppm / 1e4).toFixed(4).replace(/\.?0+$/, "");
281
- var describeMintFee = (fee) => [
282
- fee.baseFeeMsat > 0 ? `${Math.round(fee.baseFeeMsat / 1e3)} sat flat` : null,
283
- fee.feePpm > 0 ? `${formatFeePercent(fee.feePpm)}% of the amount paid` : null
284
- ].filter(Boolean).join(" + ");
285
-
286
- // src/bolt11.ts
287
- var isBolt11Invoice = (value) => /^ln(bc|tb|bcrt|tbs|sb)[0-9]*[munp]?1[a-z0-9]+$/.test(
288
- value.trim().toLowerCase()
289
- );
290
- var sameInvoice = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
291
- var BOLT11_AMOUNT_MSAT_PER_UNIT = {
292
- "": 1e11,
293
- m: 1e8,
294
- u: 1e5,
295
- n: 100,
296
- p: 0.1
297
- };
298
- var decodeBolt11AmountMsat = (pr) => {
299
- const trimmed = pr.trim().toLowerCase();
300
- const sep = trimmed.lastIndexOf("1");
301
- if (sep < 2) return null;
302
- const hrp = trimmed.slice(0, sep);
303
- const match = hrp.match(/^ln(?:bc|tb|bcrt|tbs|sb)(\d+)?([munp])?$/);
304
- if (!match) return null;
305
- const [, digits, multiplier] = match;
306
- if (!digits) return null;
307
- const msat = Number(digits) * BOLT11_AMOUNT_MSAT_PER_UNIT[multiplier || ""];
308
- return Number.isInteger(msat) ? msat : null;
309
- };
310
209
 
311
210
  // src/errors.ts
312
211
  var LnurlcashError = class extends Error {
@@ -321,6 +220,11 @@ var ProtocolError = class extends LnurlcashError {
321
220
  };
322
221
  var ServiceRejectedError = class extends LnurlcashError {
323
222
  reason;
223
+ // The WALLET-generated secrets a MUTATION disclosed the hashes of, when
224
+ // this refusal is one that could describe a mutation the SERVICE had
225
+ // already applied - see newSecretsOf. Absent on every other refusal, and
226
+ // absent on every non-mutating call.
227
+ newSecrets;
324
228
  constructor(reason) {
325
229
  super(reason || "The service rejected the request.");
326
230
  this.reason = reason;
@@ -353,12 +257,190 @@ var AmbiguousMutationError = class extends AmbiguousMintError {
353
257
  this.newSecrets = newSecrets;
354
258
  }
355
259
  };
260
+ var InsufficientValueError = class extends ServiceRejectedError {
261
+ amountMsat;
262
+ minMsat;
263
+ constructor(amountMsat, minMsat) {
264
+ super(`worth ${amountMsat} msat, needs ${minMsat} msat`);
265
+ this.amountMsat = amountMsat;
266
+ this.minMsat = minMsat;
267
+ this.message = `This note is worth ${amountMsat} msat, and ${minMsat} msat is required.`;
268
+ }
269
+ };
270
+ var newSecretsOf = (err) => {
271
+ if (err instanceof AmbiguousMutationError) return err.newSecrets;
272
+ if (err instanceof ServiceRejectedError) return err.newSecrets ?? [];
273
+ return [];
274
+ };
356
275
  var classifyNoteError = (reason) => {
276
+ if (reason === "pending") return new PendingNoteError(reason);
357
277
  if (/spent/i.test(reason)) return new NoteSpentError(reason);
358
278
  if (/unknown|not found/i.test(reason)) return new NoteUnknownError(reason);
359
279
  return new ServiceRejectedError(reason);
360
280
  };
361
281
 
282
+ // src/request.ts
283
+ var PAYMENT_REQUEST_PREFIX = "lnurlcashreq1";
284
+ var MAX_ENCODED_LENGTH = 4096;
285
+ var AMOUNT_RE = /^(?:0|[1-9][0-9]*)$/;
286
+ var ID_RE = /^[0-9a-f]{16}$/;
287
+ var NPUB_RE = /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/;
288
+ var isNpub = (value) => {
289
+ if (!NPUB_RE.test(value)) return false;
290
+ try {
291
+ const { prefix, words } = bech32.decode(value);
292
+ return prefix === "npub" && bech32.fromWords(words).length === 32;
293
+ } catch {
294
+ return false;
295
+ }
296
+ };
297
+ var reject = (why) => {
298
+ throw new ProtocolError(`Not a valid payment request: ${why}`);
299
+ };
300
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
301
+ var canonicalise = (value) => {
302
+ if (Array.isArray(value)) return `[${value.map(canonicalise).join(",")}]`;
303
+ if (isPlainObject(value)) {
304
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0);
305
+ entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
306
+ const body = entries.map(([key, v]) => `${JSON.stringify(key)}:${canonicalise(v)}`).join(",");
307
+ return `{${body}}`;
308
+ }
309
+ if (typeof value === "number") {
310
+ if (!Number.isSafeInteger(value)) reject("a number that is not a safe integer");
311
+ return String(value);
312
+ }
313
+ if (typeof value === "string" || typeof value === "boolean" || value === null) {
314
+ return JSON.stringify(value);
315
+ }
316
+ return reject(`a value of type ${typeof value}`);
317
+ };
318
+ var validate = (value) => {
319
+ if (!isPlainObject(value)) reject("not an object");
320
+ const raw = value;
321
+ const known = /* @__PURE__ */ new Set([
322
+ "v",
323
+ "id",
324
+ "amount",
325
+ "currency",
326
+ "methodDetails",
327
+ "to",
328
+ "memo",
329
+ "expires"
330
+ ]);
331
+ for (const key of Object.keys(raw)) {
332
+ if (!known.has(key)) reject(`unrecognised field "${key}"`);
333
+ }
334
+ if (raw.v !== 1) reject("unsupported version");
335
+ if (typeof raw.id !== "string" || !ID_RE.test(raw.id)) {
336
+ reject("id must be 16 lowercase hex characters");
337
+ }
338
+ if (typeof raw.amount !== "string" || !AMOUNT_RE.test(raw.amount)) {
339
+ reject("amount must be a whole number of sats, as a decimal string");
340
+ }
341
+ const sats = Number(raw.amount);
342
+ if (!Number.isSafeInteger(sats) || sats < 1) {
343
+ reject("amount must be at least 1 sat");
344
+ }
345
+ if (raw.currency !== "sat") reject('currency must be "sat"');
346
+ if (!isPlainObject(raw.methodDetails)) reject("methodDetails must be an object");
347
+ const details = raw.methodDetails;
348
+ for (const key of Object.keys(details)) {
349
+ if (key !== "mints" && key !== "mintPubkeys") {
350
+ reject(`unrecognised methodDetails field "${key}"`);
351
+ }
352
+ }
353
+ if (!Array.isArray(details.mints) || details.mints.length === 0 || !details.mints.every((mint) => typeof mint === "string" && mint.trim() !== "")) {
354
+ reject("methodDetails.mints must list at least one mint");
355
+ }
356
+ if (details.mintPubkeys !== void 0 && (!Array.isArray(details.mintPubkeys) || !details.mintPubkeys.every((key) => typeof key === "string"))) {
357
+ reject("methodDetails.mintPubkeys must be a list of strings");
358
+ }
359
+ if (raw.to !== void 0) {
360
+ if (typeof raw.to !== "string") reject("to must be a string");
361
+ const to = raw.to;
362
+ if (!isNpub(to) && !isLightningAddress(to)) {
363
+ reject("to must be an npub or a Lightning Address");
364
+ }
365
+ }
366
+ if (raw.memo !== void 0 && typeof raw.memo !== "string") {
367
+ reject("memo must be a string");
368
+ }
369
+ if (raw.expires !== void 0 && (typeof raw.expires !== "number" || !Number.isSafeInteger(raw.expires) || raw.expires < 0)) {
370
+ reject("expires must be a unix timestamp in whole seconds");
371
+ }
372
+ const request = {
373
+ v: 1,
374
+ id: raw.id,
375
+ amount: raw.amount,
376
+ currency: "sat",
377
+ methodDetails: {
378
+ mints: [...details.mints],
379
+ ...details.mintPubkeys === void 0 ? {} : { mintPubkeys: [...details.mintPubkeys] }
380
+ },
381
+ ...raw.to === void 0 ? {} : { to: raw.to },
382
+ ...raw.memo === void 0 ? {} : { memo: raw.memo },
383
+ ...raw.expires === void 0 ? {} : { expires: raw.expires }
384
+ };
385
+ return request;
386
+ };
387
+ var paymentRequestAmountMsat = (request) => Number(request.amount) * 1e3;
388
+ var SHORT_FORM_KEYS = /* @__PURE__ */ new Set(["a", "m", "u"]);
389
+ var fromShortForm = (value) => {
390
+ if (!isPlainObject(value)) return value;
391
+ const raw = value;
392
+ if (raw.v !== void 0 || raw.amount !== void 0) return value;
393
+ const keys = Object.keys(raw);
394
+ if (keys.length === 0 || !keys.every((key) => SHORT_FORM_KEYS.has(key))) {
395
+ return value;
396
+ }
397
+ if (typeof raw.a !== "number" || !Number.isSafeInteger(raw.a)) return value;
398
+ if (!Array.isArray(raw.m)) return value;
399
+ const canonical = canonicalise(raw);
400
+ return {
401
+ v: 1,
402
+ id: bytesToHex(sha256(utf8ToBytes(canonical))).slice(0, 16),
403
+ amount: String(raw.a),
404
+ currency: raw.u === void 0 ? "sat" : raw.u,
405
+ methodDetails: { mints: raw.m }
406
+ };
407
+ };
408
+ var encodePaymentRequest = (request) => PAYMENT_REQUEST_PREFIX + base64urlnopad.encode(new TextEncoder().encode(canonicalise(validate(request))));
409
+ var decodePaymentRequest = (value, { now = Math.floor(Date.now() / 1e3) } = {}) => {
410
+ const trimmed = value.trim();
411
+ if (trimmed.length > MAX_ENCODED_LENGTH) reject("far too long to be one");
412
+ if (trimmed.slice(0, PAYMENT_REQUEST_PREFIX.length).toLowerCase() !== PAYMENT_REQUEST_PREFIX) {
413
+ reject(`it does not start with ${PAYMENT_REQUEST_PREFIX}`);
414
+ }
415
+ let json;
416
+ try {
417
+ json = new TextDecoder().decode(
418
+ base64urlnopad.decode(trimmed.slice(PAYMENT_REQUEST_PREFIX.length))
419
+ );
420
+ } catch {
421
+ return reject("the body is not base64url");
422
+ }
423
+ let parsed;
424
+ try {
425
+ parsed = JSON.parse(json);
426
+ } catch {
427
+ return reject("the body is not JSON");
428
+ }
429
+ const request = validate(fromShortForm(parsed));
430
+ if (request.expires !== void 0 && now > 0 && request.expires <= now) {
431
+ reject("it expired");
432
+ }
433
+ return request;
434
+ };
435
+ var isPaymentRequest = (value) => {
436
+ try {
437
+ decodePaymentRequest(value, { now: 0 });
438
+ return true;
439
+ } catch {
440
+ return false;
441
+ }
442
+ };
443
+
362
444
  // src/transport.ts
363
445
  var resolveOptions = (options = {}) => ({
364
446
  // Wrapped, never referenced bare: in a browser window.fetch is a method
@@ -467,6 +549,85 @@ var lnurlFetch = async (url, options) => {
467
549
  return body;
468
550
  };
469
551
 
552
+ // src/bolt11.ts
553
+ var isBolt11Invoice = (value) => /^ln(bc|tb|bcrt|tbs|sb)[0-9]*[munp]?1[a-z0-9]+$/.test(
554
+ value.trim().toLowerCase()
555
+ );
556
+ var sameInvoice = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
557
+ var BOLT11_AMOUNT_MSAT_PER_UNIT = {
558
+ "": 1e11,
559
+ m: 1e8,
560
+ u: 1e5,
561
+ n: 100,
562
+ p: 0.1
563
+ };
564
+ var decodeBolt11AmountMsat = (pr) => {
565
+ const trimmed = pr.trim().toLowerCase();
566
+ const sep = trimmed.lastIndexOf("1");
567
+ if (sep < 2) return null;
568
+ const hrp = trimmed.slice(0, sep);
569
+ const match = hrp.match(/^ln(?:bc|tb|bcrt|tbs|sb)(\d+)?([munp])?$/);
570
+ if (!match) return null;
571
+ const [, digits, multiplier] = match;
572
+ if (!digits) return null;
573
+ const msat = Number(digits) * BOLT11_AMOUNT_MSAT_PER_UNIT[multiplier || ""];
574
+ return Number.isInteger(msat) ? msat : null;
575
+ };
576
+
577
+ // src/fees.ts
578
+ var parseMintFee = (metadata) => {
579
+ let entries;
580
+ try {
581
+ entries = JSON.parse(metadata);
582
+ } catch {
583
+ return null;
584
+ }
585
+ if (!Array.isArray(entries)) return null;
586
+ for (const entry of entries) {
587
+ if (!Array.isArray(entry) || entry[0] !== "text/plain") continue;
588
+ const match = typeof entry[1] === "string" && entry[1].match(/^Mint fees:\s*(\d+)\s*,\s*(\d+)\s*$/);
589
+ if (!match) continue;
590
+ const baseFeeMsat = Number(match[1]);
591
+ const feePpm = Number(match[2]);
592
+ if (!Number.isSafeInteger(baseFeeMsat) || !Number.isSafeInteger(feePpm)) continue;
593
+ if (feePpm >= 1e6) continue;
594
+ if (baseFeeMsat === 0 && feePpm === 0) return null;
595
+ return { baseFeeMsat, feePpm };
596
+ }
597
+ return null;
598
+ };
599
+ var proportionalFee = (grossMsat, feePpm) => Math.floor(grossMsat / 1e6) * feePpm + Math.floor(grossMsat % 1e6 * feePpm / 1e6);
600
+ var applyMintFee = (grossMsat, fee) => Math.max(0, grossMsat - fee.baseFeeMsat - proportionalFee(grossMsat, fee.feePpm));
601
+ var mintFeeBand = (grossMsat, fee) => {
602
+ const exactFee = fee.baseFeeMsat + proportionalFee(grossMsat, fee.feePpm);
603
+ const satCeilinged = Math.ceil(exactFee / 1e3) * 1e3;
604
+ return {
605
+ minNetMsat: Math.max(0, grossMsat - satCeilinged),
606
+ maxNetMsat: Math.max(0, grossMsat - exactFee)
607
+ };
608
+ };
609
+ var withinMintFeeBand = (grossMsat, netMsat, fee) => {
610
+ const { minNetMsat, maxNetMsat } = mintFeeBand(grossMsat, fee);
611
+ return netMsat >= minNetMsat && netMsat <= maxNetMsat;
612
+ };
613
+ var grossUpForMintFee = (netMsat, fee) => {
614
+ if (netMsat <= 0) return 0;
615
+ let hi = netMsat + fee.baseFeeMsat;
616
+ while (applyMintFee(hi, fee) < netMsat) hi *= 2;
617
+ let lo = 0;
618
+ while (lo < hi) {
619
+ const mid = Math.floor((lo + hi) / 2);
620
+ if (applyMintFee(mid, fee) >= netMsat) hi = mid;
621
+ else lo = mid + 1;
622
+ }
623
+ return lo;
624
+ };
625
+ var formatFeePercent = (ppm) => (ppm / 1e4).toFixed(4).replace(/\.?0+$/, "");
626
+ var describeMintFee = (fee) => [
627
+ fee.baseFeeMsat > 0 ? `${Math.round(fee.baseFeeMsat / 1e3)} sat flat` : null,
628
+ fee.feePpm > 0 ? `${formatFeePercent(fee.feePpm)}% of the amount paid` : null
629
+ ].filter(Boolean).join(" + ");
630
+
470
631
  // src/client.ts
471
632
  var fetchNoteInfo = async (url, options = {}) => {
472
633
  const opts = resolveOptions(options);
@@ -501,18 +662,63 @@ var probeBurnedNote = async (url, options = {}) => {
501
662
  return "unknown";
502
663
  }
503
664
  };
665
+ var asString = (value) => typeof value === "string" ? value : void 0;
666
+ var asNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
667
+ var asContact = (value) => {
668
+ if (!value || typeof value !== "object") return void 0;
669
+ const raw = value;
670
+ const contact = {
671
+ nostr: asString(raw.nostr),
672
+ email: asString(raw.email),
673
+ url: asString(raw.url)
674
+ };
675
+ const any = contact.nostr ?? contact.email ?? contact.url;
676
+ return any === void 0 ? void 0 : contact;
677
+ };
678
+ var asFees = (value) => {
679
+ if (!value || typeof value !== "object") return void 0;
680
+ const raw = value;
681
+ const baseFeeMsat = asNumber(raw.baseFeeMsat);
682
+ const feePpm = asNumber(raw.feePpm);
683
+ if (baseFeeMsat === void 0 && feePpm === void 0) return void 0;
684
+ return { baseFeeMsat: baseFeeMsat ?? 0, feePpm: feePpm ?? 0 };
685
+ };
686
+ var asBoolean = (value) => typeof value === "boolean" ? value : void 0;
687
+ var asPubkeyList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
504
688
  var fetchMintAddress = async (url, options = {}) => {
505
689
  const body = await lnurlFetch(url, resolveOptions(options));
506
690
  if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || typeof body.payLink !== "string" || typeof body.maxWithdrawable !== "number") {
507
691
  throw new ProtocolError("Not a mint address response (unexpected shape).");
508
692
  }
509
- const { mintPubkey, nodeCapacity, ...rest } = body;
510
693
  return {
511
- ...rest,
512
- nodePubkey: mintPubkey,
513
- // Renamed fields have to be mapped, not spread: the spread carries the
514
- // wire name through, so the typed one would read undefined forever.
515
- nodeCapacityMsat: typeof nodeCapacity === "number" ? nodeCapacity : void 0
694
+ tag: "withdrawRequest",
695
+ callback: body.callback,
696
+ minWithdrawable: asNumber(body.minWithdrawable) ?? 0,
697
+ maxWithdrawable: body.maxWithdrawable,
698
+ defaultDescription: asString(body.defaultDescription),
699
+ mintPubkey: asString(body.mintPubkey),
700
+ // the same value under both names for one release, so nothing breaks
701
+ nodePubkey: asString(body.mintPubkey),
702
+ payLink: body.payLink,
703
+ nodeAlias: asString(body.nodeAlias),
704
+ nodeUri: asString(body.nodeUri),
705
+ nodeColor: asString(body.nodeColor),
706
+ // `nodeCapacity` is the wire name the reference mint, the mock and
707
+ // every implementation that copied them use. One live mint emits
708
+ // `nodeCapacityMsat` instead, so both are accepted and the bare name
709
+ // wins where a SERVICE sends both.
710
+ nodeCapacityMsat: asNumber(body.nodeCapacity) ?? asNumber(body.nodeCapacityMsat),
711
+ nodeNumChannels: asNumber(body.nodeNumChannels),
712
+ nodeNumPeers: asNumber(body.nodeNumPeers),
713
+ name: asString(body.name),
714
+ description: asString(body.description),
715
+ contact: asContact(body.contact),
716
+ tosUrl: asString(body.tosUrl),
717
+ motd: asString(body.motd),
718
+ fees: asFees(body.fees),
719
+ version: asString(body.version),
720
+ previousPubkeys: asPubkeyList(body.previousPubkeys),
721
+ mintToHash: asBoolean(body.mintToHash)
516
722
  };
517
723
  };
518
724
  var callbackRequest = async (callback, params, options) => {
@@ -592,6 +798,12 @@ var mergeNotesWithHash = async (callback, k1s, h, options = {}) => {
592
798
  );
593
799
  return { signature: body.sig };
594
800
  };
801
+ var keepingOutputs = (err, newSecrets) => {
802
+ if (err instanceof NoteSpentError || err instanceof NoteUnknownError) {
803
+ err.newSecrets = newSecrets;
804
+ }
805
+ return err;
806
+ };
595
807
  var rotateNote = async (callback, k1, options = {}) => {
596
808
  const opts = resolveOptions(options);
597
809
  const newK1 = opts.randomSecret();
@@ -602,7 +814,7 @@ var rotateNote = async (callback, k1, options = {}) => {
602
814
  if (err instanceof AmbiguousMintError) {
603
815
  throw new AmbiguousMutationError(err.message, [newK1]);
604
816
  }
605
- throw err;
817
+ throw keepingOutputs(err, [newK1]);
606
818
  }
607
819
  };
608
820
  var splitNote = async (callback, k1s, amountMsat, options = {}) => {
@@ -628,7 +840,7 @@ var splitNote = async (callback, k1s, amountMsat, options = {}) => {
628
840
  if (err instanceof AmbiguousMintError) {
629
841
  throw new AmbiguousMutationError(err.message, [newK1, changeK1]);
630
842
  }
631
- throw err;
843
+ throw keepingOutputs(err, [newK1, changeK1]);
632
844
  }
633
845
  };
634
846
  var mergeNotes = async (callback, k1s, options = {}) => {
@@ -641,7 +853,7 @@ var mergeNotes = async (callback, k1s, options = {}) => {
641
853
  if (err instanceof AmbiguousMintError) {
642
854
  throw new AmbiguousMutationError(err.message, [newK1]);
643
855
  }
644
- throw err;
856
+ throw keepingOutputs(err, [newK1]);
645
857
  }
646
858
  };
647
859
  var settleNote = async (baseUrl, k1, expectedAmountMsat, signature, options = {}) => {
@@ -672,11 +884,23 @@ var fetchPayRequest = async (url, options = {}) => {
672
884
  throw new ProtocolError("Not a payRequest (unexpected response).");
673
885
  }
674
886
  const mintFee = typeof body.metadata === "string" ? parseMintFee(body.metadata) : null;
675
- return { ...body, mintFee: mintFee ?? void 0 };
887
+ return {
888
+ ...body,
889
+ mintFee: mintFee ?? void 0,
890
+ mintToHash: asBoolean(body.mintToHash)
891
+ };
676
892
  };
677
893
  var requestInvoice = async (payCallback, amountMsat, options = {}) => {
678
894
  const cbUrl = new URL(payCallback);
679
895
  cbUrl.searchParams.set("amount", String(amountMsat));
896
+ if (options.h !== void 0) {
897
+ if (!isPreimage(options.h)) {
898
+ throw new RequestRefusedError(
899
+ "An output hash must be 32 bytes of hex - no invoice was requested."
900
+ );
901
+ }
902
+ cbUrl.searchParams.set("h", options.h.trim().toLowerCase());
903
+ }
680
904
  const body = await lnurlFetch(cbUrl, resolveOptions(options));
681
905
  if (typeof body?.pr !== "string") {
682
906
  throw new ProtocolError("The service did not return an invoice.");
@@ -690,7 +914,8 @@ var requestInvoice = async (payCallback, amountMsat, options = {}) => {
690
914
  return {
691
915
  pr: body.pr,
692
916
  verify: typeof body.verify === "string" ? body.verify : void 0,
693
- disposable: body.disposable !== false
917
+ disposable: body.disposable !== false,
918
+ mintToHash: body.mintToHash === true
694
919
  };
695
920
  };
696
921
  var fetchInvoiceVerification = async (verifyUrl, options = {}) => {
@@ -704,6 +929,156 @@ var fetchInvoiceVerification = async (verifyUrl, options = {}) => {
704
929
  pr: body.pr
705
930
  };
706
931
  };
932
+ var claimMintedNote = async (withdrawLink, k1, options = {}) => {
933
+ const secret = k1.trim().toLowerCase();
934
+ if (!isPreimage(secret)) {
935
+ throw new RequestRefusedError(
936
+ "A note secret must be 32 bytes of hex - nothing was sent."
937
+ );
938
+ }
939
+ const blank = {
940
+ k1: secret,
941
+ amountMsat: null,
942
+ callback: null
943
+ };
944
+ try {
945
+ const info = await fetchNoteInfo(buildNoteUrl(withdrawLink, secret), options);
946
+ return {
947
+ state: "minted",
948
+ k1: secret,
949
+ amountMsat: info.maxWithdrawable,
950
+ callback: info.callback
951
+ };
952
+ } catch (err) {
953
+ if (err instanceof PendingNoteError) return { ...blank, state: "pending" };
954
+ if (err instanceof NoteSpentError) return { ...blank, state: "spent" };
955
+ if (err instanceof NoteUnknownError) return { ...blank, state: "unminted" };
956
+ throw err;
957
+ }
958
+ };
959
+ var LIGHTNING_SIGNED_MESSAGE_PREFIX = utf8ToBytes("Lightning Signed Message:");
960
+ var noteSignatureMessage = (k1, amountMsat) => `LNURLcash:${amountMsat}:${hashK1(k1)}`;
961
+ var noteSignatureDigest = (k1, amountMsat) => sha256(
962
+ sha256(
963
+ new Uint8Array([
964
+ ...LIGHTNING_SIGNED_MESSAGE_PREFIX,
965
+ ...utf8ToBytes(noteSignatureMessage(k1, amountMsat))
966
+ ])
967
+ )
968
+ );
969
+ var NO_MATCH = { valid: false, pubkey: null };
970
+ var verifyNoteSignatureAgainst = (k1, amountMsat, signatureHex, mintPubkeys) => {
971
+ const targets = (Array.isArray(mintPubkeys) ? mintPubkeys : [mintPubkeys]).filter((key) => typeof key === "string").map((key) => key.trim().toLowerCase());
972
+ if (targets.length === 0) return NO_MATCH;
973
+ let wireSig;
974
+ try {
975
+ wireSig = hexToBytes(signatureHex);
976
+ } catch {
977
+ return NO_MATCH;
978
+ }
979
+ if (wireSig.length !== 65) return NO_MATCH;
980
+ let digest;
981
+ try {
982
+ digest = noteSignatureDigest(k1, amountMsat);
983
+ } catch {
984
+ return NO_MATCH;
985
+ }
986
+ const recoveryIdFirst = new Uint8Array([
987
+ wireSig[64],
988
+ ...wireSig.subarray(0, 64)
989
+ ]);
990
+ for (const candidate of [recoveryIdFirst, wireSig]) {
991
+ try {
992
+ const recovered = bytesToHex(
993
+ secp256k1.recoverPublicKey(candidate, digest, { prehash: false })
994
+ );
995
+ if (targets.includes(recovered)) return { valid: true, pubkey: recovered };
996
+ } catch {
997
+ }
998
+ }
999
+ return NO_MATCH;
1000
+ };
1001
+ var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureAgainst(k1, amountMsat, signatureHex, mintPubkeys).valid;
1002
+
1003
+ // src/settle.ts
1004
+ var normaliseHost = (value) => serverOf(value.trim().replace(/^@/, "")).toLowerCase();
1005
+ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = false }, options = {}) => {
1006
+ const url = resolveNoteInput(noteUrl);
1007
+ if (!url) {
1008
+ throw new RequestRefusedError(
1009
+ "That is not a bearer note this library will fetch."
1010
+ );
1011
+ }
1012
+ const host = serverOf(url).toLowerCase();
1013
+ const accepted = mints.map(normaliseHost);
1014
+ if (!accepted.includes(host)) {
1015
+ throw new ServiceRejectedError(
1016
+ accepted.length === 0 ? "This server accepts no mints." : `Notes from ${host} are not accepted here.`
1017
+ );
1018
+ }
1019
+ const k1 = requireNoteK1(url);
1020
+ const info = await fetchNoteInfo(url, options);
1021
+ if (requireSignature) {
1022
+ const signature = noteSignature(url);
1023
+ if (!signature) {
1024
+ throw new ServiceRejectedError("This note carries no signature.");
1025
+ }
1026
+ if (!info.mintPubkey) {
1027
+ throw new ServiceRejectedError(
1028
+ "The mint published no signing key to check this note against."
1029
+ );
1030
+ }
1031
+ if (!verifyNoteSignature(k1, info.maxWithdrawable, signature, info.mintPubkey)) {
1032
+ throw new ServiceRejectedError("This note's signature does not verify.");
1033
+ }
1034
+ }
1035
+ if (info.maxWithdrawable < minMsat) {
1036
+ throw new InsufficientValueError(info.maxWithdrawable, minMsat);
1037
+ }
1038
+ const rotated = await rotateNote(info.callback, k1, options);
1039
+ return {
1040
+ note: {
1041
+ k1: rotated.k1,
1042
+ amountMsat: info.maxWithdrawable,
1043
+ signature: rotated.signature,
1044
+ callback: info.callback
1045
+ },
1046
+ newUrl: withNewK1(url, rotated.k1, info.maxWithdrawable, rotated.signature)
1047
+ };
1048
+ };
1049
+
1050
+ // src/restore.ts
1051
+ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0 } = {}, options = {}) => {
1052
+ if (!Number.isSafeInteger(gap) || gap < 1) {
1053
+ throw new RangeError(`The gap limit must be a positive integer, not ${gap}.`);
1054
+ }
1055
+ const found = [];
1056
+ let lastUsed = null;
1057
+ let unknownRun = 0;
1058
+ for (let index = start; unknownRun < gap; index++) {
1059
+ const k1 = deriveNoteSecret(root, host, index);
1060
+ try {
1061
+ const info = await fetchNoteInfo(buildNoteUrl(baseUrl, k1), options);
1062
+ found.push({ index, k1, amountMsat: info.maxWithdrawable, state: "live" });
1063
+ lastUsed = index;
1064
+ unknownRun = 0;
1065
+ } catch (err) {
1066
+ if (err instanceof PendingNoteError) {
1067
+ found.push({ index, k1, amountMsat: null, state: "pending" });
1068
+ lastUsed = index;
1069
+ unknownRun = 0;
1070
+ } else if (err instanceof NoteSpentError) {
1071
+ lastUsed = index;
1072
+ unknownRun = 0;
1073
+ } else if (err instanceof NoteUnknownError) {
1074
+ unknownRun++;
1075
+ } else {
1076
+ throw err;
1077
+ }
1078
+ }
1079
+ }
1080
+ return { found, next: lastUsed === null ? start : lastUsed + 1 };
1081
+ };
707
1082
 
708
1083
  // src/index.ts
709
1084
  var createClient = (options = {}) => ({
@@ -719,8 +1094,14 @@ var createClient = (options = {}) => ({
719
1094
  mergeNotesWithHash: (callback, k1s, h) => mergeNotesWithHash(callback, k1s, h, options),
720
1095
  settleNote: (baseUrl, k1, expectedAmountMsat, signature) => settleNote(baseUrl, k1, expectedAmountMsat, signature, options),
721
1096
  fetchPayRequest: (url) => fetchPayRequest(url, options),
722
- requestInvoice: (payCallback, amountMsat) => requestInvoice(payCallback, amountMsat, options),
723
- fetchInvoiceVerification: (verifyUrl) => fetchInvoiceVerification(verifyUrl, options)
1097
+ // `h` names the note the invoice will mint - see requestInvoice. The
1098
+ // bound transport options are merged under it, so a caller can still
1099
+ // reach for a one-off override.
1100
+ requestInvoice: (payCallback, amountMsat, h) => requestInvoice(payCallback, amountMsat, { ...options, h }),
1101
+ fetchInvoiceVerification: (verifyUrl) => fetchInvoiceVerification(verifyUrl, options),
1102
+ claimMintedNote: (withdrawLink, k1) => claimMintedNote(withdrawLink, k1, options),
1103
+ restoreNotes: (baseUrl, root, host, restoreOptions = {}) => restoreNotes(baseUrl, root, host, restoreOptions, options),
1104
+ settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
724
1105
  });
725
1106
 
726
- export { AmbiguousMintError, AmbiguousMutationError, LnurlcashError, NoteSpentError, NoteUnknownError, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, applyMintFee, buildNoteUrl, classifyNoteError, createClient, decodeBolt11AmountMsat, defaultRandomSecret, describeMintFee, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureMessage, parseMintFee, probeBurnedNote, requestInvoice, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, verifyNoteSignature, withNewK1, withinMintFeeBand, withoutK1 };
1107
+ 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 };