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/LICENSE +21 -0
- package/README.md +77 -12
- package/bun.lock +467 -11
- package/eslint.config.js +94 -0
- package/example.ts +3 -3
- package/index.test.ts +247 -41
- package/index.ts +189 -23
- package/package.json +8 -4
- package/oxlintrc.json +0 -23
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" | "
|
|
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
|
|
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
|
|
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(
|
|
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)
|
|
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
|
|
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
|
|
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: "
|
|
462
|
+
type: "offer",
|
|
333
463
|
value: decodedValue,
|
|
334
|
-
valid:
|
|
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
|
|
472
|
+
const validation = validateSilentPaymentAddress(decodedValue);
|
|
339
473
|
result.paymentMethods.push({
|
|
340
474
|
type: "silent-payment",
|
|
341
475
|
value: decodedValue,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 (!
|
|
348
|
-
result.errors.push("Invalid
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
|
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
|
+
"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": "
|
|
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
|
-
"
|
|
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
|
-
}
|