bip-321 0.0.1

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 ADDED
@@ -0,0 +1,471 @@
1
+ import * as bitcoin from "bitcoinjs-lib";
2
+ import { decode as decodeLightning } from "light-bolt11-decoder";
3
+ import bs58check from "bs58check";
4
+ import { bech32, bech32m } from "@scure/base";
5
+
6
+ export interface PaymentMethod {
7
+ type:
8
+ | "onchain"
9
+ | "lightning"
10
+ | "lno"
11
+ | "silent-payment"
12
+ | "private-payment"
13
+ | "other";
14
+ value: string;
15
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
16
+ valid: boolean;
17
+ error?: string;
18
+ }
19
+
20
+ export interface BIP321ParseResult {
21
+ address?: string;
22
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
23
+ amount?: number;
24
+ label?: string;
25
+ message?: string;
26
+ pop?: string;
27
+ popRequired?: boolean;
28
+ paymentMethods: PaymentMethod[];
29
+ requiredParams: string[];
30
+ optionalParams: Record<string, string[]>;
31
+ valid: boolean;
32
+ errors: string[];
33
+ }
34
+
35
+ const FORBIDDEN_POP_SCHEMES = ["http", "https", "file", "javascript", "mailto"];
36
+
37
+ function detectAddressNetwork(
38
+ address: string,
39
+ ): "mainnet" | "testnet" | "regtest" | "signet" | undefined {
40
+ try {
41
+ const lowerAddress = address.toLowerCase();
42
+
43
+ // Bech32/Bech32m addresses
44
+ if (lowerAddress.startsWith("bc1")) {
45
+ try {
46
+ // Try using bitcoinjs-lib first (works for non-taproot)
47
+ bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
48
+ return "mainnet";
49
+ } catch (e) {
50
+ // Fallback to manual bech32/bech32m validation for taproot
51
+ try {
52
+ const decoded = lowerAddress.startsWith("bc1p")
53
+ ? bech32m.decode(address as `${string}1${string}`, 90)
54
+ : bech32.decode(address as `${string}1${string}`, 90);
55
+ if (decoded.prefix === "bc") {
56
+ return "mainnet";
57
+ }
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ } else if (lowerAddress.startsWith("tb1")) {
63
+ try {
64
+ bitcoin.address.toOutputScript(address, bitcoin.networks.testnet);
65
+ return "testnet";
66
+ } catch (e) {
67
+ try {
68
+ const decoded = lowerAddress.startsWith("tb1p")
69
+ ? bech32m.decode(address as `${string}1${string}`, 90)
70
+ : bech32.decode(address as `${string}1${string}`, 90);
71
+ if (decoded.prefix === "tb") {
72
+ return "testnet";
73
+ }
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ } else if (lowerAddress.startsWith("bcrt1")) {
79
+ try {
80
+ bitcoin.address.toOutputScript(address, bitcoin.networks.regtest);
81
+ return "regtest";
82
+ } catch (e) {
83
+ try {
84
+ const decoded = lowerAddress.startsWith("bcrt1p")
85
+ ? bech32m.decode(address as `${string}1${string}`, 90)
86
+ : bech32.decode(address as `${string}1${string}`, 90);
87
+ if (decoded.prefix === "bcrt") {
88
+ return "regtest";
89
+ }
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ }
95
+
96
+ // Base58 addresses (P2PKH, P2SH)
97
+ const decoded = bs58check.decode(address);
98
+ const version = decoded[0];
99
+
100
+ // Mainnet: P2PKH (0x00), P2SH (0x05)
101
+ if (version === 0x00 || version === 0x05) {
102
+ return "mainnet";
103
+ }
104
+ // Testnet: P2PKH (0x6f), P2SH (0xc4)
105
+ else if (version === 0x6f || version === 0xc4) {
106
+ return "testnet";
107
+ }
108
+ } catch (e) {
109
+ return undefined;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ function validateBitcoinAddress(address: string): {
115
+ valid: boolean;
116
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
117
+ error?: string;
118
+ } {
119
+ if (!address) {
120
+ return { valid: false, error: "Empty address" };
121
+ }
122
+
123
+ const network = detectAddressNetwork(address);
124
+ if (!network) {
125
+ return { valid: false, error: "Invalid bitcoin address" };
126
+ }
127
+
128
+ return { valid: true, network };
129
+ }
130
+
131
+ function validateLightningInvoice(invoice: string): {
132
+ valid: boolean;
133
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
134
+ error?: string;
135
+ } {
136
+ try {
137
+ const decoded = decodeLightning(invoice);
138
+ let network: "mainnet" | "testnet" | "regtest" | "signet" | undefined;
139
+
140
+ const lowerInvoice = invoice.toLowerCase();
141
+ // Check order matters - lnbcrt before lnbc, lntbs before lntb
142
+ if (lowerInvoice.startsWith("lnbcrt")) {
143
+ network = "regtest";
144
+ } else if (lowerInvoice.startsWith("lnbc")) {
145
+ network = "mainnet";
146
+ } else if (lowerInvoice.startsWith("lntbs")) {
147
+ network = "signet";
148
+ } else if (
149
+ lowerInvoice.startsWith("lntb") ||
150
+ lowerInvoice.startsWith("lnbt")
151
+ ) {
152
+ network = "testnet";
153
+ }
154
+
155
+ return { valid: true, network };
156
+ } catch (e) {
157
+ return {
158
+ valid: false,
159
+ error: `Invalid lightning invoice: ${e instanceof Error ? e.message : String(e)}`,
160
+ };
161
+ }
162
+ }
163
+
164
+ function validatePopUri(popUri: string): { valid: boolean; error?: string } {
165
+ try {
166
+ const decoded = decodeURIComponent(popUri);
167
+ const schemeMatch = decoded.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):?/);
168
+
169
+ if (schemeMatch && schemeMatch[1]) {
170
+ const scheme = schemeMatch[1].toLowerCase();
171
+ if (FORBIDDEN_POP_SCHEMES.includes(scheme)) {
172
+ return { valid: false, error: `Forbidden pop scheme: ${scheme}` };
173
+ }
174
+ }
175
+
176
+ return { valid: true };
177
+ } catch (e) {
178
+ return { valid: false, error: "Invalid pop URI encoding" };
179
+ }
180
+ }
181
+
182
+ export function parseBIP321(uri: string): BIP321ParseResult {
183
+ const result: BIP321ParseResult = {
184
+ paymentMethods: [],
185
+ requiredParams: [],
186
+ optionalParams: {},
187
+ valid: true,
188
+ errors: [],
189
+ };
190
+
191
+ if (!uri || typeof uri !== "string") {
192
+ result.valid = false;
193
+ result.errors.push("Invalid URI: must be a non-empty string");
194
+ return result;
195
+ }
196
+
197
+ const lowerUri = uri.toLowerCase();
198
+ if (!lowerUri.startsWith("bitcoin:")) {
199
+ result.valid = false;
200
+ result.errors.push("Invalid URI: must start with bitcoin:");
201
+ return result;
202
+ }
203
+
204
+ const withoutScheme = uri.substring(8);
205
+ const questionMarkIndex = withoutScheme.indexOf("?");
206
+
207
+ let address = "";
208
+ let queryString = "";
209
+
210
+ if (questionMarkIndex === -1) {
211
+ address = withoutScheme;
212
+ } else {
213
+ address = withoutScheme.substring(0, questionMarkIndex);
214
+ queryString = withoutScheme.substring(questionMarkIndex + 1);
215
+ }
216
+
217
+ if (address) {
218
+ const validation = validateBitcoinAddress(address);
219
+ if (validation.valid) {
220
+ result.address = address;
221
+ result.network = validation.network;
222
+ result.paymentMethods.push({
223
+ type: "onchain",
224
+ value: address,
225
+ network: validation.network,
226
+ valid: true,
227
+ });
228
+ } else {
229
+ result.errors.push(`Invalid address: ${validation.error}`);
230
+ result.valid = false;
231
+ }
232
+ }
233
+
234
+ if (queryString) {
235
+ // Parse manually to preserve encoded values for pop parameter
236
+ const paramPairs = queryString.split("&");
237
+ const seenKeys = new Map<string, number>();
238
+
239
+ for (const pair of paramPairs) {
240
+ const eqIndex = pair.indexOf("=");
241
+ let key: string;
242
+ let value: string;
243
+
244
+ if (eqIndex === -1) {
245
+ key = decodeURIComponent(pair);
246
+ value = "";
247
+ } else {
248
+ key = decodeURIComponent(pair.substring(0, eqIndex));
249
+ // For pop parameters, keep encoded; for others, decode
250
+ const rawValue = pair.substring(eqIndex + 1);
251
+ value = rawValue;
252
+ }
253
+
254
+ const lowerKey = key.toLowerCase();
255
+ const count = (seenKeys.get(lowerKey) || 0) + 1;
256
+ seenKeys.set(lowerKey, count);
257
+
258
+ if (lowerKey === "label") {
259
+ if (count > 1) {
260
+ result.errors.push("Multiple label parameters not allowed");
261
+ result.valid = false;
262
+ } else {
263
+ result.label = decodeURIComponent(value);
264
+ }
265
+ } else if (lowerKey === "message") {
266
+ if (count > 1) {
267
+ result.errors.push("Multiple message parameters not allowed");
268
+ result.valid = false;
269
+ } else {
270
+ result.message = decodeURIComponent(value);
271
+ }
272
+ } else if (lowerKey === "amount") {
273
+ if (count > 1) {
274
+ result.errors.push("Multiple amount parameters not allowed");
275
+ result.valid = false;
276
+ } else {
277
+ const decodedValue = decodeURIComponent(value);
278
+ const amount = parseFloat(decodedValue);
279
+ if (isNaN(amount) || amount < 0 || decodedValue.includes(",")) {
280
+ result.errors.push("Invalid amount format");
281
+ result.valid = false;
282
+ } else {
283
+ result.amount = amount;
284
+ }
285
+ }
286
+ } else if (lowerKey === "pop" || lowerKey === "req-pop") {
287
+ const popKey = lowerKey === "req-pop" ? "req-pop" : "pop";
288
+ if (result.pop !== undefined || result.popRequired !== undefined) {
289
+ result.errors.push("Multiple pop/req-pop parameters not allowed");
290
+ result.valid = false;
291
+ } else {
292
+ // Keep pop value encoded as per spec
293
+ const validation = validatePopUri(value);
294
+ if (!validation.valid) {
295
+ result.errors.push(validation.error || "Invalid pop URI");
296
+ if (lowerKey === "req-pop") {
297
+ result.valid = false;
298
+ }
299
+ }
300
+ result.pop = value;
301
+ result.popRequired = lowerKey === "req-pop";
302
+ }
303
+ } else if (lowerKey === "lightning") {
304
+ const decodedValue = decodeURIComponent(value);
305
+ const validation = validateLightningInvoice(decodedValue);
306
+ result.paymentMethods.push({
307
+ type: "lightning",
308
+ value: decodedValue,
309
+ network: validation.network,
310
+ valid: validation.valid,
311
+ error: validation.error,
312
+ });
313
+ if (!validation.valid) {
314
+ result.errors.push(validation.error || "Invalid lightning invoice");
315
+ }
316
+ } else if (lowerKey === "lno") {
317
+ const decodedValue = decodeURIComponent(value);
318
+ result.paymentMethods.push({
319
+ type: "lno",
320
+ value: decodedValue,
321
+ valid: true,
322
+ });
323
+ } else if (lowerKey === "sp") {
324
+ const decodedValue = decodeURIComponent(value);
325
+ const isSilentPayment = decodedValue.toLowerCase().startsWith("sp1");
326
+ result.paymentMethods.push({
327
+ type: "silent-payment",
328
+ value: decodedValue,
329
+ valid: isSilentPayment,
330
+ error: isSilentPayment
331
+ ? undefined
332
+ : "Invalid silent payment address format",
333
+ });
334
+ if (!isSilentPayment) {
335
+ result.errors.push("Invalid silent payment address format");
336
+ }
337
+ } else if (lowerKey === "pay") {
338
+ const decodedValue = decodeURIComponent(value);
339
+ result.paymentMethods.push({
340
+ type: "private-payment",
341
+ value: decodedValue,
342
+ valid: true,
343
+ });
344
+ } else if (
345
+ lowerKey === "bc" ||
346
+ lowerKey === "tb" ||
347
+ lowerKey === "bcrt" ||
348
+ lowerKey === "tbs"
349
+ ) {
350
+ let expectedNetwork: "mainnet" | "testnet" | "regtest" | "signet";
351
+ if (lowerKey === "bc") expectedNetwork = "mainnet";
352
+ else if (lowerKey === "tb") expectedNetwork = "testnet";
353
+ else if (lowerKey === "tbs") expectedNetwork = "signet";
354
+ else expectedNetwork = "regtest";
355
+
356
+ const decodedValue = decodeURIComponent(value);
357
+ const validation = validateBitcoinAddress(decodedValue);
358
+ const networkMatches =
359
+ validation.valid && validation.network === expectedNetwork;
360
+
361
+ result.paymentMethods.push({
362
+ type: "onchain",
363
+ value: decodedValue,
364
+ network: validation.network,
365
+ valid: validation.valid && networkMatches,
366
+ error: !validation.valid
367
+ ? validation.error
368
+ : !networkMatches
369
+ ? `Address network mismatch: expected ${expectedNetwork}`
370
+ : undefined,
371
+ });
372
+
373
+ if (!validation.valid || !networkMatches) {
374
+ result.errors.push(
375
+ !validation.valid
376
+ ? validation.error!
377
+ : `Address network mismatch for ${lowerKey} parameter`,
378
+ );
379
+ result.valid = false;
380
+ }
381
+ } else if (lowerKey.startsWith("req-")) {
382
+ result.requiredParams.push(key);
383
+ result.errors.push(`Unknown required parameter: ${key}`);
384
+ result.valid = false;
385
+ } else {
386
+ if (!result.optionalParams[lowerKey]) {
387
+ result.optionalParams[lowerKey] = [];
388
+ }
389
+ result.optionalParams[lowerKey].push(decodeURIComponent(value));
390
+ }
391
+ }
392
+ }
393
+
394
+ if (result.paymentMethods.length === 0) {
395
+ result.errors.push("No valid payment methods found");
396
+ result.valid = false;
397
+ }
398
+
399
+ if (result.popRequired && result.pop) {
400
+ const hasValidPaymentMethod = result.paymentMethods.some((pm) => pm.valid);
401
+ if (!hasValidPaymentMethod) {
402
+ result.errors.push(
403
+ "req-pop specified but no valid payment method available",
404
+ );
405
+ }
406
+ }
407
+
408
+ return result;
409
+ }
410
+
411
+ export function getPaymentMethodsByNetwork(
412
+ result: BIP321ParseResult,
413
+ ): Record<string, PaymentMethod[]> {
414
+ const byNetwork: Record<string, PaymentMethod[]> = {
415
+ mainnet: [],
416
+ testnet: [],
417
+ regtest: [],
418
+ signet: [],
419
+ unknown: [],
420
+ };
421
+
422
+ for (const method of result.paymentMethods) {
423
+ if (method.network && byNetwork[method.network]) {
424
+ byNetwork[method.network]!.push(method);
425
+ } else {
426
+ byNetwork.unknown!.push(method);
427
+ }
428
+ }
429
+
430
+ return byNetwork;
431
+ }
432
+
433
+ export function getValidPaymentMethods(
434
+ result: BIP321ParseResult,
435
+ ): PaymentMethod[] {
436
+ return result.paymentMethods.filter((pm) => pm.valid);
437
+ }
438
+
439
+ export function formatPaymentMethodsSummary(result: BIP321ParseResult): string {
440
+ const lines: string[] = [];
441
+
442
+ lines.push(`Valid: ${result.valid}`);
443
+ if (result.errors.length > 0) {
444
+ lines.push(`Errors: ${result.errors.join(", ")}`);
445
+ }
446
+
447
+ if (result.address) {
448
+ lines.push(`Address: ${result.address} (${result.network})`);
449
+ }
450
+
451
+ if (result.amount !== undefined) {
452
+ lines.push(`Amount: ${result.amount} BTC`);
453
+ }
454
+
455
+ if (result.label) {
456
+ lines.push(`Label: ${result.label}`);
457
+ }
458
+
459
+ if (result.message) {
460
+ lines.push(`Message: ${result.message}`);
461
+ }
462
+
463
+ lines.push(`Payment Methods: ${result.paymentMethods.length}`);
464
+ for (const method of result.paymentMethods) {
465
+ const status = method.valid ? "✓" : "✗";
466
+ const network = method.network ? ` (${method.network})` : "";
467
+ lines.push(` ${status} ${method.type}${network}`);
468
+ }
469
+
470
+ return lines.join("\n");
471
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "bip-321",
3
+ "version": "0.0.1",
4
+ "description": "A TypeScript/JavaScript library for parsing BIP-321 Bitcoin URI scheme with support for multiple payment methods",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "module": "./index.ts",
8
+ "types": "./index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./index.ts",
12
+ "types": "./index.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "test": "bun test",
17
+ "example": "bun example.ts"
18
+ },
19
+ "keywords": [
20
+ "bitcoin",
21
+ "bip-321",
22
+ "bip321",
23
+ "uri",
24
+ "lightning",
25
+ "bolt11",
26
+ "bolt12",
27
+ "silent-payments",
28
+ "payment",
29
+ "cryptocurrency",
30
+ "parser",
31
+ "validator",
32
+ "bitcoin-uri",
33
+ "payment-request",
34
+ "taproot",
35
+ "segwit",
36
+ "mainnet",
37
+ "testnet",
38
+ "regtest"
39
+ ],
40
+ "author": "",
41
+ "license": "BSD-2-Clause",
42
+ "dependencies": {
43
+ "@scure/base": "^2.0.0",
44
+ "bitcoinjs-lib": "^7.0.0",
45
+ "bs58check": "^4.0.0",
46
+ "light-bolt11-decoder": "^3.2.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/bun": "latest"
50
+ },
51
+ "peerDependencies": {
52
+ "typescript": "^5"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "https://github.com/yourusername/bip-321.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/yourusername/bip-321/issues"
60
+ },
61
+ "homepage": "https://github.com/yourusername/bip-321#readme",
62
+ "engines": {
63
+ "node": ">=16.0.0"
64
+ }
65
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }