bip-321 0.0.3 → 0.0.5

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/index.ts CHANGED
@@ -3,8 +3,17 @@ import { decode as decodeLightning } from "light-bolt11-decoder";
3
3
  import { sha256 } from "@noble/hashes/sha2.js";
4
4
  import { base58, bech32, bech32m } from "@scure/base";
5
5
 
6
+ // Type declaration for missing export in light-bolt12-decoder
7
+ declare module "light-bolt12-decoder" {
8
+ export function decode(offer: string): {
9
+ offerRequest: string;
10
+ sections: Array<{ name: string; value: unknown }>;
11
+ };
12
+ }
13
+ import { decode as decodeBolt12 } from "light-bolt12-decoder";
14
+
6
15
  export interface PaymentMethod {
7
- type: "onchain" | "lightning" | "lno" | "silent-payment" | "other";
16
+ type: "onchain" | "lightning" | "offer" | "silent-payment" | "ark";
8
17
  value: string;
9
18
  network?: "mainnet" | "testnet" | "regtest" | "signet";
10
19
  valid: boolean;
@@ -115,7 +124,7 @@ function detectAddressNetwork(
115
124
  else if (version === 0x6f || version === 0xc4) {
116
125
  return "testnet";
117
126
  }
118
- } catch (e) {
127
+ } catch {
119
128
  return undefined;
120
129
  }
121
130
  } catch {
@@ -174,12 +183,129 @@ function validateLightningInvoice(invoice: string): {
174
183
  }
175
184
  }
176
185
 
186
+ function validateBolt12Offer(offer: string): {
187
+ valid: boolean;
188
+ error?: string;
189
+ } {
190
+ try {
191
+ if (!offer || typeof offer !== "string") {
192
+ return { valid: false, error: "Empty or invalid offer" };
193
+ }
194
+
195
+ const lowerOffer = offer.toLowerCase();
196
+ if (!lowerOffer.startsWith("ln")) {
197
+ return { valid: false, error: "Invalid BOLT12 offer format" };
198
+ }
199
+
200
+ decodeBolt12(offer);
201
+ return { valid: true };
202
+ } catch (e) {
203
+ return {
204
+ valid: false,
205
+ error: `Invalid BOLT12 offer: ${e instanceof Error ? e.message : String(e)}`,
206
+ };
207
+ }
208
+ }
209
+
210
+ function validateSilentPaymentAddress(address: string): {
211
+ valid: boolean;
212
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
213
+ error?: string;
214
+ } {
215
+ try {
216
+ const lowerAddress = address.toLowerCase();
217
+
218
+ let network: "mainnet" | "testnet" | undefined;
219
+ if (lowerAddress.startsWith("sp1q")) {
220
+ network = "mainnet";
221
+ } else if (lowerAddress.startsWith("tsp1q")) {
222
+ network = "testnet";
223
+ } else {
224
+ return { valid: false, error: "Invalid silent payment address prefix" };
225
+ }
226
+
227
+ const decoded = bech32m.decode(address as `${string}1${string}`, 1023);
228
+
229
+ const expectedPrefix = network === "mainnet" ? "sp" : "tsp";
230
+ if (decoded.prefix !== expectedPrefix) {
231
+ return { valid: false, error: "Invalid silent payment address prefix" };
232
+ }
233
+
234
+ // Check version (first word should be 0 for v0, which encodes as 'q')
235
+ if (decoded.words.length === 0 || decoded.words[0] !== 0) {
236
+ return { valid: false, error: "Unsupported silent payment version" };
237
+ }
238
+
239
+ // Convert from 5-bit words to bytes
240
+ const dataWords = decoded.words.slice(1);
241
+ const data = bech32m.fromWordsUnsafe(dataWords);
242
+
243
+ if (!data) {
244
+ return { valid: false, error: "Invalid silent payment address data" };
245
+ }
246
+
247
+ // BIP-352: v0 addresses must be exactly 66 bytes (33-byte scan key + 33-byte spend key)
248
+ if (data.length !== 66) {
249
+ return { valid: false, error: "Invalid silent payment address length" };
250
+ }
251
+
252
+ // Validate both public keys are valid compressed keys (0x02 or 0x03 prefix)
253
+ const scanKey = data[0];
254
+ const spendKey = data[33];
255
+ if (
256
+ (scanKey !== 0x02 && scanKey !== 0x03) ||
257
+ (spendKey !== 0x02 && spendKey !== 0x03)
258
+ ) {
259
+ return {
260
+ valid: false,
261
+ error: "Invalid public key format in silent payment address",
262
+ };
263
+ }
264
+
265
+ return { valid: true, network };
266
+ } catch (e) {
267
+ return {
268
+ valid: false,
269
+ error: `Invalid silent payment address: ${e instanceof Error ? e.message : String(e)}`,
270
+ };
271
+ }
272
+ }
273
+
274
+ function validateArkAddress(address: string): {
275
+ valid: boolean;
276
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
277
+ error?: string;
278
+ } {
279
+ try {
280
+ const lowerAddress = address.toLowerCase();
281
+
282
+ if (lowerAddress.startsWith("ark1")) {
283
+ const decoded = bech32m.decode(address as `${string}1${string}`, 1023);
284
+ if (decoded.prefix === "ark") {
285
+ return { valid: true, network: "mainnet" };
286
+ }
287
+ } else if (lowerAddress.startsWith("tark1")) {
288
+ const decoded = bech32m.decode(address as `${string}1${string}`, 1023);
289
+ if (decoded.prefix === "tark") {
290
+ return { valid: true, network: "testnet" };
291
+ }
292
+ }
293
+
294
+ return { valid: false, error: "Invalid Ark address format" };
295
+ } catch (e) {
296
+ return {
297
+ valid: false,
298
+ error: `Invalid Ark address: ${e instanceof Error ? e.message : String(e)}`,
299
+ };
300
+ }
301
+ }
302
+
177
303
  function validatePopUri(popUri: string): { valid: boolean; error?: string } {
178
304
  try {
179
305
  const decoded = decodeURIComponent(popUri);
180
306
  const schemeMatch = decoded.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):?/);
181
307
 
182
- if (schemeMatch && schemeMatch[1]) {
308
+ if (schemeMatch?.[1]) {
183
309
  const scheme = schemeMatch[1].toLowerCase();
184
310
  if (FORBIDDEN_POP_SCHEMES.includes(scheme)) {
185
311
  return { valid: false, error: `Forbidden pop scheme: ${scheme}` };
@@ -192,7 +318,10 @@ function validatePopUri(popUri: string): { valid: boolean; error?: string } {
192
318
  }
193
319
  }
194
320
 
195
- export function parseBIP321(uri: string): BIP321ParseResult {
321
+ export function parseBIP321(
322
+ uri: string,
323
+ expectedNetwork?: "mainnet" | "testnet" | "regtest" | "signet",
324
+ ): BIP321ParseResult {
196
325
  const result: BIP321ParseResult = {
197
326
  paymentMethods: [],
198
327
  requiredParams: [],
@@ -265,7 +394,7 @@ export function parseBIP321(uri: string): BIP321ParseResult {
265
394
  }
266
395
 
267
396
  const lowerKey = key.toLowerCase();
268
- const count = (seenKeys.get(lowerKey) || 0) + 1;
397
+ const count = (seenKeys.get(lowerKey) ?? 0) + 1;
269
398
  seenKeys.set(lowerKey, count);
270
399
 
271
400
  if (lowerKey === "label") {
@@ -305,7 +434,7 @@ export function parseBIP321(uri: string): BIP321ParseResult {
305
434
  // Keep pop value encoded as per spec
306
435
  const validation = validatePopUri(value);
307
436
  if (!validation.valid) {
308
- result.errors.push(validation.error || "Invalid pop URI");
437
+ result.errors.push(validation.error ?? "Invalid pop URI");
309
438
  if (lowerKey === "req-pop") {
310
439
  result.valid = false;
311
440
  }
@@ -324,28 +453,47 @@ export function parseBIP321(uri: string): BIP321ParseResult {
324
453
  error: validation.error,
325
454
  });
326
455
  if (!validation.valid) {
327
- result.errors.push(validation.error || "Invalid lightning invoice");
456
+ result.errors.push(validation.error ?? "Invalid lightning invoice");
328
457
  }
329
458
  } else if (lowerKey === "lno") {
330
459
  const decodedValue = decodeURIComponent(value);
460
+ const validation = validateBolt12Offer(decodedValue);
331
461
  result.paymentMethods.push({
332
- type: "lno",
462
+ type: "offer",
333
463
  value: decodedValue,
334
- valid: true,
464
+ valid: validation.valid,
465
+ error: validation.error,
335
466
  });
467
+ if (!validation.valid) {
468
+ result.errors.push(validation.error ?? "Invalid BOLT12 offer");
469
+ }
336
470
  } else if (lowerKey === "sp") {
337
471
  const decodedValue = decodeURIComponent(value);
338
- const isSilentPayment = decodedValue.toLowerCase().startsWith("sp1");
472
+ const validation = validateSilentPaymentAddress(decodedValue);
339
473
  result.paymentMethods.push({
340
474
  type: "silent-payment",
341
475
  value: decodedValue,
342
- valid: isSilentPayment,
343
- error: isSilentPayment
344
- ? undefined
345
- : "Invalid silent payment address format",
476
+ network: validation.network,
477
+ valid: validation.valid,
478
+ error: validation.error,
479
+ });
480
+ if (!validation.valid) {
481
+ result.errors.push(
482
+ validation.error ?? "Invalid silent payment address",
483
+ );
484
+ }
485
+ } else if (lowerKey === "ark") {
486
+ const decodedValue = decodeURIComponent(value);
487
+ const validation = validateArkAddress(decodedValue);
488
+ result.paymentMethods.push({
489
+ type: "ark",
490
+ value: decodedValue,
491
+ network: validation.network,
492
+ valid: validation.valid,
493
+ error: validation.error,
346
494
  });
347
- if (!isSilentPayment) {
348
- result.errors.push("Invalid silent payment address format");
495
+ if (!validation.valid) {
496
+ result.errors.push(validation.error ?? "Invalid Ark address");
349
497
  }
350
498
  } else if (
351
499
  lowerKey === "bc" ||
@@ -379,7 +527,7 @@ export function parseBIP321(uri: string): BIP321ParseResult {
379
527
  if (!validation.valid || !networkMatches) {
380
528
  result.errors.push(
381
529
  !validation.valid
382
- ? validation.error!
530
+ ? (validation.error ?? "Invalid address")
383
531
  : `Address network mismatch for ${lowerKey} parameter`,
384
532
  );
385
533
  result.valid = false;
@@ -389,9 +537,7 @@ export function parseBIP321(uri: string): BIP321ParseResult {
389
537
  result.errors.push(`Unknown required parameter: ${key}`);
390
538
  result.valid = false;
391
539
  } else {
392
- if (!result.optionalParams[lowerKey]) {
393
- result.optionalParams[lowerKey] = [];
394
- }
540
+ result.optionalParams[lowerKey] ??= [];
395
541
  result.optionalParams[lowerKey].push(decodeURIComponent(value));
396
542
  }
397
543
  }
@@ -402,6 +548,19 @@ export function parseBIP321(uri: string): BIP321ParseResult {
402
548
  result.valid = false;
403
549
  }
404
550
 
551
+ if (expectedNetwork) {
552
+ for (const method of result.paymentMethods) {
553
+ if (method.network && method.network !== expectedNetwork) {
554
+ result.errors.push(
555
+ `Payment method network mismatch: expected ${expectedNetwork}, got ${method.network}`,
556
+ );
557
+ result.valid = false;
558
+ method.valid = false;
559
+ method.error = `Network mismatch: expected ${expectedNetwork}`;
560
+ }
561
+ }
562
+ }
563
+
405
564
  if (result.popRequired && result.pop) {
406
565
  const hasValidPaymentMethod = result.paymentMethods.some((pm) => pm.valid);
407
566
  if (!hasValidPaymentMethod) {
@@ -426,10 +585,17 @@ export function getPaymentMethodsByNetwork(
426
585
  };
427
586
 
428
587
  for (const method of result.paymentMethods) {
429
- if (method.network && byNetwork[method.network]) {
430
- byNetwork[method.network]!.push(method);
588
+ const { network } = method;
589
+ if (network && network in byNetwork) {
590
+ const networkArray = byNetwork[network];
591
+ if (networkArray) {
592
+ networkArray.push(method);
593
+ }
431
594
  } else {
432
- byNetwork.unknown!.push(method);
595
+ const unknownArray = byNetwork.unknown;
596
+ if (unknownArray) {
597
+ unknownArray.push(method);
598
+ }
433
599
  }
434
600
  }
435
601
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bip-321",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "A TypeScript/JavaScript library for parsing BIP-321 Bitcoin URI scheme with support for multiple payment methods",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -16,7 +16,7 @@
16
16
  "test": "bun test",
17
17
  "example": "bun example.ts",
18
18
  "check": "tsc --noEmit",
19
- "lint": "oxlint"
19
+ "lint": "eslint ."
20
20
  },
21
21
  "keywords": [
22
22
  "bitcoin",
@@ -45,11 +45,15 @@
45
45
  "@noble/hashes": "^2.0.1",
46
46
  "@scure/base": "^2.0.0",
47
47
  "bitcoinjs-lib": "^7.0.0",
48
- "light-bolt11-decoder": "^3.2.0"
48
+ "light-bolt11-decoder": "^3.2.0",
49
+ "light-bolt12-decoder": "^1.0.3"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@types/bun": "latest",
52
- "oxlint": "^1.26.0"
53
+ "@typescript-eslint/eslint-plugin": "^8.46.3",
54
+ "@typescript-eslint/parser": "^8.46.3",
55
+ "eslint": "^9.39.1",
56
+ "eslint-plugin-import": "^2.32.0"
53
57
  },
54
58
  "peerDependencies": {
55
59
  "typescript": "^5"
package/oxlintrc.json DELETED
@@ -1,23 +0,0 @@
1
- {
2
- "categories": {
3
- "correctness": "warn",
4
- "suspicious": "warn",
5
- "pedantic": "warn",
6
- "perf": "warn",
7
- "style": "warn",
8
- "restriction": "warn"
9
- },
10
- "rules": {
11
- "typescript/no-explicit-any": "error",
12
- "typescript/no-non-null-assertion": "error",
13
- "no-console": "warn",
14
- "no-debugger": "error",
15
- "eqeqeq": "error"
16
- },
17
- "ignorePatterns": [
18
- "**/node_modules/**",
19
- "**/dist/**",
20
- "**/build/**",
21
- "**/*.min.js"
22
- ]
23
- }