bip-321 0.0.5 → 0.0.7

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/README.md CHANGED
@@ -53,6 +53,49 @@ console.log(result.address); // "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
53
53
  console.log(result.paymentMethods); // Array of payment methods
54
54
  ```
55
55
 
56
+ ## Validation Functions
57
+
58
+ The library also exports standalone validation functions that can be used independently:
59
+
60
+ ```typescript
61
+ import {
62
+ validateBitcoinAddress,
63
+ validateLightningInvoice,
64
+ validateBolt12Offer,
65
+ validateSilentPaymentAddress,
66
+ validateArkAddress,
67
+ validatePopUri,
68
+ } from "bip-321";
69
+
70
+ // Validate a Bitcoin address
71
+ const btcResult = validateBitcoinAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa");
72
+ console.log(btcResult.valid); // true
73
+ console.log(btcResult.network); // "mainnet"
74
+
75
+ // Validate a Lightning invoice
76
+ const lnResult = validateLightningInvoice("lnbc15u1p3xnhl2pp5...");
77
+ console.log(lnResult.valid); // true
78
+ console.log(lnResult.network); // "mainnet"
79
+
80
+ // Validate a BOLT12 offer
81
+ const offerResult = validateBolt12Offer("lno1qqqq02k20d");
82
+ console.log(offerResult.valid); // true
83
+
84
+ // Validate a Silent Payment address
85
+ const spResult = validateSilentPaymentAddress("sp1qq...");
86
+ console.log(spResult.valid); // true
87
+ console.log(spResult.network); // "mainnet"
88
+
89
+ // Validate an Ark address
90
+ const arkResult = validateArkAddress("ark1p...");
91
+ console.log(arkResult.valid); // true
92
+ console.log(arkResult.network); // "mainnet"
93
+
94
+ // Validate a pop URI
95
+ const popResult = validatePopUri("myapp://callback");
96
+ console.log(popResult.valid); // true
97
+ ```
98
+
56
99
  ## Usage Examples
57
100
 
58
101
  ### Basic On-Chain Payment
@@ -192,23 +235,98 @@ Parses a BIP-321 URI and returns detailed information about the payment request.
192
235
  **Parameters:**
193
236
  - `uri` - The Bitcoin URI string to parse
194
237
  - `expectedNetwork` (optional) - Expected network for all payment methods. If specified, all payment methods must match this network or the URI will be marked invalid.
195
- </text>
196
238
 
197
- <old_text line=240>
198
- ## Validation Rules
239
+ **Returns:** `BIP321ParseResult` object
199
240
 
200
- The parser enforces BIP-321 validation rules:
241
+ ### Validation Functions
201
242
 
202
- 1. URI must start with `bitcoin:` (case-insensitive)
203
- 2. ✅ Address in URI path must be valid or empty
204
- 3. `amount` must be decimal BTC (no commas)
205
- 4. ✅ `label`, `message`, and `amount` cannot appear multiple times
206
- 5. `pop` and `req-pop` cannot both be present
207
- 6. ✅ Required parameters (`req-*`) must be understood or URI is invalid
208
- 7. ✅ Network-specific parameters (`bc`, `tb`, etc.) must match address network
209
- 8. ✅ `pop` URI scheme must not be forbidden (http, https, file, javascript, mailto)
243
+ The library exports individual validation functions for each payment method type:
244
+
245
+ #### `validateBitcoinAddress(address: string)`
246
+
247
+ Validates a Bitcoin address and returns network information.
248
+
249
+ **Returns:**
250
+ ```typescript
251
+ {
252
+ valid: boolean;
253
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
254
+ error?: string;
255
+ }
256
+ ```
257
+
258
+ #### `validateLightningInvoice(invoice: string)`
259
+
260
+ Validates a BOLT11 Lightning invoice and detects the network.
261
+
262
+ **Returns:**
263
+ ```typescript
264
+ {
265
+ valid: boolean;
266
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
267
+ error?: string;
268
+ }
269
+ ```
210
270
 
211
- **Returns:** `BIP321ParseResult` object containing:
271
+ #### `validateBolt12Offer(offer: string)`
272
+
273
+ Validates a BOLT12 offer. Note: BOLT12 offers are network-agnostic.
274
+
275
+ **Returns:**
276
+ ```typescript
277
+ {
278
+ valid: boolean;
279
+ error?: string;
280
+ }
281
+ ```
282
+
283
+ #### `validateSilentPaymentAddress(address: string)`
284
+
285
+ Validates a BIP-352 Silent Payment address.
286
+
287
+ **Returns:**
288
+ ```typescript
289
+ {
290
+ valid: boolean;
291
+ network?: "mainnet" | "testnet";
292
+ error?: string;
293
+ }
294
+ ```
295
+
296
+ **Note:** For Silent Payments, `testnet` covers testnet, signet, and regtest.
297
+
298
+ #### `validateArkAddress(address: string)`
299
+
300
+ Validates an Ark address (BOAT-0001).
301
+
302
+ **Returns:**
303
+ ```typescript
304
+ {
305
+ valid: boolean;
306
+ network?: "mainnet" | "testnet";
307
+ error?: string;
308
+ }
309
+ ```
310
+
311
+ **Note:** For Ark, `testnet` covers testnet, signet, and regtest.
312
+
313
+ #### `validatePopUri(uri: string)`
314
+
315
+ Validates a proof-of-payment URI and checks for forbidden schemes.
316
+
317
+ **Returns:**
318
+ ```typescript
319
+ {
320
+ valid: boolean;
321
+ error?: string;
322
+ }
323
+ ```
324
+ </text>
325
+
326
+ <old_text line=240>
327
+ ### BIP321ParseResult
328
+
329
+ The `parseBIP321` function returns a `BIP321ParseResult` object containing:
212
330
 
213
331
  ```typescript
214
332
  interface BIP321ParseResult {
@@ -309,9 +427,16 @@ The library automatically detects the network from:
309
427
  - **Regtest**: `lnbcrt...`
310
428
  - **Signet**: `lntbs...`
311
429
 
430
+ ### Silent Payment Addresses
431
+ - **Mainnet**: `sp1q...`
432
+ - **Testnet**: `tsp1q...` (covers testnet, signet, and regtest)
433
+
312
434
  ### Ark Addresses
313
435
  - **Mainnet**: `ark1...`
314
- - **Testnet**: `tark1...`
436
+ - **Testnet**: `tark1...` (covers testnet, signet, and regtest)
437
+
438
+ ### BOLT12 Offers
439
+ - **Network-agnostic**: `lno...` (no network-specific prefix)
315
440
 
316
441
  ## Validation Rules
317
442
 
package/index.test.ts CHANGED
@@ -557,5 +557,45 @@ describe("BIP-321 Parser", () => {
557
557
  expect(result.paymentMethods.length).toBe(2);
558
558
  expect(result.paymentMethods.every((pm) => pm.valid)).toBe(true);
559
559
  });
560
+
561
+ test("accepts Ark testnet address when expecting regtest", () => {
562
+ const result = parseBIP321(
563
+ `bitcoin:?ark=${TEST_DATA.ark.testnet}`,
564
+ "regtest",
565
+ );
566
+ expect(result.valid).toBe(true);
567
+ expect(result.paymentMethods[0]!.type).toBe("ark");
568
+ expect(result.paymentMethods[0]!.valid).toBe(true);
569
+ });
570
+
571
+ test("accepts Ark testnet address when expecting signet", () => {
572
+ const result = parseBIP321(
573
+ `bitcoin:?ark=${TEST_DATA.ark.testnet}`,
574
+ "signet",
575
+ );
576
+ expect(result.valid).toBe(true);
577
+ expect(result.paymentMethods[0]!.type).toBe("ark");
578
+ expect(result.paymentMethods[0]!.valid).toBe(true);
579
+ });
580
+
581
+ test("accepts silent payment testnet address when expecting regtest", () => {
582
+ const result = parseBIP321(
583
+ `bitcoin:?sp=${TEST_DATA.silentPayment.testnet}`,
584
+ "regtest",
585
+ );
586
+ expect(result.valid).toBe(true);
587
+ expect(result.paymentMethods[0]!.type).toBe("silent-payment");
588
+ expect(result.paymentMethods[0]!.valid).toBe(true);
589
+ });
590
+
591
+ test("accepts silent payment testnet address when expecting signet", () => {
592
+ const result = parseBIP321(
593
+ `bitcoin:?sp=${TEST_DATA.silentPayment.testnet}`,
594
+ "signet",
595
+ );
596
+ expect(result.valid).toBe(true);
597
+ expect(result.paymentMethods[0]!.type).toBe("silent-payment");
598
+ expect(result.paymentMethods[0]!.valid).toBe(true);
599
+ });
560
600
  });
561
601
  });
package/index.ts CHANGED
@@ -1,16 +1,21 @@
1
- import * as bitcoin from "bitcoinjs-lib";
2
- import { decode as decodeLightning } from "light-bolt11-decoder";
3
- import { sha256 } from "@noble/hashes/sha2.js";
4
- import { base58, bech32, bech32m } from "@scure/base";
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";
1
+ import {
2
+ validateBitcoinAddress,
3
+ validateLightningInvoice,
4
+ validateBolt12Offer,
5
+ validateSilentPaymentAddress,
6
+ validateArkAddress,
7
+ validatePopUri,
8
+ } from "./validators";
9
+
10
+ // Re-export validation functions for public API
11
+ export {
12
+ validateBitcoinAddress,
13
+ validateLightningInvoice,
14
+ validateBolt12Offer,
15
+ validateSilentPaymentAddress,
16
+ validateArkAddress,
17
+ validatePopUri,
18
+ };
14
19
 
15
20
  export interface PaymentMethod {
16
21
  type: "onchain" | "lightning" | "offer" | "silent-payment" | "ark";
@@ -35,289 +40,6 @@ export interface BIP321ParseResult {
35
40
  errors: string[];
36
41
  }
37
42
 
38
- const FORBIDDEN_POP_SCHEMES = ["http", "https", "file", "javascript", "mailto"];
39
-
40
- function detectAddressNetwork(
41
- address: string,
42
- ): "mainnet" | "testnet" | "regtest" | "signet" | undefined {
43
- try {
44
- const lowerAddress = address.toLowerCase();
45
-
46
- // Bech32/Bech32m addresses
47
- if (lowerAddress.startsWith("bc1")) {
48
- try {
49
- // Try using bitcoinjs-lib first (works for non-taproot)
50
- bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
51
- return "mainnet";
52
- } catch {
53
- // Fallback to manual bech32/bech32m validation for taproot
54
- try {
55
- const decoded = lowerAddress.startsWith("bc1p")
56
- ? bech32m.decode(address as `${string}1${string}`, 90)
57
- : bech32.decode(address as `${string}1${string}`, 90);
58
- if (decoded.prefix === "bc") {
59
- return "mainnet";
60
- }
61
- } catch {
62
- return undefined;
63
- }
64
- }
65
- } else if (lowerAddress.startsWith("tb1")) {
66
- try {
67
- bitcoin.address.toOutputScript(address, bitcoin.networks.testnet);
68
- return "testnet";
69
- } catch {
70
- try {
71
- const decoded = lowerAddress.startsWith("tb1p")
72
- ? bech32m.decode(address as `${string}1${string}`, 90)
73
- : bech32.decode(address as `${string}1${string}`, 90);
74
- if (decoded.prefix === "tb") {
75
- return "testnet";
76
- }
77
- } catch {
78
- return undefined;
79
- }
80
- }
81
- } else if (lowerAddress.startsWith("bcrt1")) {
82
- try {
83
- bitcoin.address.toOutputScript(address, bitcoin.networks.regtest);
84
- return "regtest";
85
- } catch {
86
- try {
87
- const decoded = lowerAddress.startsWith("bcrt1p")
88
- ? bech32m.decode(address as `${string}1${string}`, 90)
89
- : bech32.decode(address as `${string}1${string}`, 90);
90
- if (decoded.prefix === "bcrt") {
91
- return "regtest";
92
- }
93
- } catch {
94
- return undefined;
95
- }
96
- }
97
- }
98
-
99
- // Base58 addresses (P2PKH, P2SH) - manual validation with checksum
100
- try {
101
- const decoded = base58.decode(address);
102
- if (decoded.length < 25) {
103
- return undefined;
104
- }
105
-
106
- // Verify checksum
107
- const payload = decoded.slice(0, -4);
108
- const checksum = decoded.slice(-4);
109
- const hash = sha256(sha256(payload));
110
-
111
- for (let i = 0; i < 4; i++) {
112
- if (checksum[i] !== hash[i]) {
113
- return undefined;
114
- }
115
- }
116
-
117
- const version = payload[0];
118
-
119
- // Mainnet: P2PKH (0x00), P2SH (0x05)
120
- if (version === 0x00 || version === 0x05) {
121
- return "mainnet";
122
- }
123
- // Testnet: P2PKH (0x6f), P2SH (0xc4)
124
- else if (version === 0x6f || version === 0xc4) {
125
- return "testnet";
126
- }
127
- } catch {
128
- return undefined;
129
- }
130
- } catch {
131
- return undefined;
132
- }
133
- return undefined;
134
- }
135
-
136
- function validateBitcoinAddress(address: string): {
137
- valid: boolean;
138
- network?: "mainnet" | "testnet" | "regtest" | "signet";
139
- error?: string;
140
- } {
141
- if (!address) {
142
- return { valid: false, error: "Empty address" };
143
- }
144
-
145
- const network = detectAddressNetwork(address);
146
- if (!network) {
147
- return { valid: false, error: "Invalid bitcoin address" };
148
- }
149
-
150
- return { valid: true, network };
151
- }
152
-
153
- function validateLightningInvoice(invoice: string): {
154
- valid: boolean;
155
- network?: "mainnet" | "testnet" | "regtest" | "signet";
156
- error?: string;
157
- } {
158
- try {
159
- const _decoded = decodeLightning(invoice);
160
- let network: "mainnet" | "testnet" | "regtest" | "signet" | undefined;
161
-
162
- const lowerInvoice = invoice.toLowerCase();
163
- // Check order matters - lnbcrt before lnbc, lntbs before lntb
164
- if (lowerInvoice.startsWith("lnbcrt")) {
165
- network = "regtest";
166
- } else if (lowerInvoice.startsWith("lnbc")) {
167
- network = "mainnet";
168
- } else if (lowerInvoice.startsWith("lntbs")) {
169
- network = "signet";
170
- } else if (
171
- lowerInvoice.startsWith("lntb") ||
172
- lowerInvoice.startsWith("lnbt")
173
- ) {
174
- network = "testnet";
175
- }
176
-
177
- return { valid: true, network };
178
- } catch (e) {
179
- return {
180
- valid: false,
181
- error: `Invalid lightning invoice: ${e instanceof Error ? e.message : String(e)}`,
182
- };
183
- }
184
- }
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
-
303
- function validatePopUri(popUri: string): { valid: boolean; error?: string } {
304
- try {
305
- const decoded = decodeURIComponent(popUri);
306
- const schemeMatch = decoded.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):?/);
307
-
308
- if (schemeMatch?.[1]) {
309
- const scheme = schemeMatch[1].toLowerCase();
310
- if (FORBIDDEN_POP_SCHEMES.includes(scheme)) {
311
- return { valid: false, error: `Forbidden pop scheme: ${scheme}` };
312
- }
313
- }
314
-
315
- return { valid: true };
316
- } catch {
317
- return { valid: false, error: "Invalid pop URI encoding" };
318
- }
319
- }
320
-
321
43
  export function parseBIP321(
322
44
  uri: string,
323
45
  expectedNetwork?: "mainnet" | "testnet" | "regtest" | "signet",
@@ -551,12 +273,22 @@ export function parseBIP321(
551
273
  if (expectedNetwork) {
552
274
  for (const method of result.paymentMethods) {
553
275
  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}`;
276
+ // For Ark and Silent Payments, testnet covers testnet/signet/regtest
277
+ const isTestnetCompatible =
278
+ (method.type === "ark" || method.type === "silent-payment") &&
279
+ method.network === "testnet" &&
280
+ (expectedNetwork === "testnet" ||
281
+ expectedNetwork === "signet" ||
282
+ expectedNetwork === "regtest");
283
+
284
+ if (!isTestnetCompatible) {
285
+ result.errors.push(
286
+ `Payment method network mismatch: expected ${expectedNetwork}, got ${method.network}`,
287
+ );
288
+ result.valid = false;
289
+ method.valid = false;
290
+ method.error = `Network mismatch: expected ${expectedNetwork}`;
291
+ }
560
292
  }
561
293
  }
562
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bip-321",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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",
package/validators.ts ADDED
@@ -0,0 +1,311 @@
1
+ import * as bitcoin from "bitcoinjs-lib";
2
+ import { decode as decodeLightning } from "light-bolt11-decoder";
3
+ import { sha256 } from "@noble/hashes/sha2.js";
4
+ import { base58, bech32, bech32m } from "@scure/base";
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
+
15
+ const FORBIDDEN_POP_SCHEMES = ["http", "https", "file", "javascript", "mailto"];
16
+
17
+ export function detectAddressNetwork(
18
+ address: string,
19
+ ): "mainnet" | "testnet" | "regtest" | "signet" | undefined {
20
+ try {
21
+ const lowerAddress = address.toLowerCase();
22
+
23
+ // Bech32/Bech32m addresses
24
+ if (lowerAddress.startsWith("bc1")) {
25
+ try {
26
+ // Try using bitcoinjs-lib first (works for non-taproot)
27
+ bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
28
+ return "mainnet";
29
+ } catch {
30
+ // Fallback to manual bech32/bech32m validation for taproot
31
+ try {
32
+ const decoded = lowerAddress.startsWith("bc1p")
33
+ ? bech32m.decode(address as `${string}1${string}`, 90)
34
+ : bech32.decode(address as `${string}1${string}`, 90);
35
+ if (decoded.prefix === "bc") {
36
+ return "mainnet";
37
+ }
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ } else if (lowerAddress.startsWith("tb1")) {
43
+ try {
44
+ bitcoin.address.toOutputScript(address, bitcoin.networks.testnet);
45
+ return "testnet";
46
+ } catch {
47
+ try {
48
+ const decoded = lowerAddress.startsWith("tb1p")
49
+ ? bech32m.decode(address as `${string}1${string}`, 90)
50
+ : bech32.decode(address as `${string}1${string}`, 90);
51
+ if (decoded.prefix === "tb") {
52
+ return "testnet";
53
+ }
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ } else if (lowerAddress.startsWith("bcrt1")) {
59
+ try {
60
+ bitcoin.address.toOutputScript(address, bitcoin.networks.regtest);
61
+ return "regtest";
62
+ } catch {
63
+ try {
64
+ const decoded = lowerAddress.startsWith("bcrt1p")
65
+ ? bech32m.decode(address as `${string}1${string}`, 90)
66
+ : bech32.decode(address as `${string}1${string}`, 90);
67
+ if (decoded.prefix === "bcrt") {
68
+ return "regtest";
69
+ }
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Base58 addresses (P2PKH, P2SH) - manual validation with checksum
77
+ try {
78
+ const decoded = base58.decode(address);
79
+ if (decoded.length < 25) {
80
+ return undefined;
81
+ }
82
+
83
+ // Verify checksum
84
+ const payload = decoded.slice(0, -4);
85
+ const checksum = decoded.slice(-4);
86
+ const hash = sha256(sha256(payload));
87
+
88
+ for (let i = 0; i < 4; i++) {
89
+ if (checksum[i] !== hash[i]) {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ const version = payload[0];
95
+
96
+ // Mainnet: P2PKH (0x00), P2SH (0x05)
97
+ if (version === 0x00 || version === 0x05) {
98
+ return "mainnet";
99
+ }
100
+ // Testnet: P2PKH (0x6f), P2SH (0xc4)
101
+ else if (version === 0x6f || version === 0xc4) {
102
+ return "testnet";
103
+ }
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ export function validateBitcoinAddress(address: string): {
114
+ valid: boolean;
115
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
116
+ error?: string;
117
+ } {
118
+ if (!address) {
119
+ return { valid: false, error: "Empty address" };
120
+ }
121
+
122
+ const network = detectAddressNetwork(address);
123
+ if (!network) {
124
+ return { valid: false, error: "Invalid bitcoin address" };
125
+ }
126
+
127
+ return { valid: true, network };
128
+ }
129
+
130
+ export function validateLightningInvoice(invoice: string): {
131
+ valid: boolean;
132
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
133
+ error?: string;
134
+ } {
135
+ try {
136
+ const decoded = decodeLightning(invoice);
137
+ let network: "mainnet" | "testnet" | "regtest" | "signet" | undefined;
138
+
139
+ // Find the coin_network section from decoded invoice
140
+ const coinNetworkSection = decoded.sections.find(
141
+ (section) => section.name === "coin_network",
142
+ );
143
+
144
+ if (
145
+ coinNetworkSection &&
146
+ "value" in coinNetworkSection &&
147
+ coinNetworkSection.value
148
+ ) {
149
+ const bech32Prefix = coinNetworkSection.value.bech32;
150
+ switch (bech32Prefix) {
151
+ case "bc":
152
+ network = "mainnet";
153
+ break;
154
+ case "tb":
155
+ network = "testnet";
156
+ break;
157
+ case "tbs":
158
+ network = "signet";
159
+ break;
160
+ case "bcrt":
161
+ network = "regtest";
162
+ break;
163
+ }
164
+ }
165
+
166
+ return { valid: true, network };
167
+ } catch (e) {
168
+ return {
169
+ valid: false,
170
+ error: `Invalid lightning invoice: ${e instanceof Error ? e.message : String(e)}`,
171
+ };
172
+ }
173
+ }
174
+
175
+ export function validateBolt12Offer(offer: string): {
176
+ valid: boolean;
177
+ error?: string;
178
+ } {
179
+ try {
180
+ if (!offer || typeof offer !== "string") {
181
+ return { valid: false, error: "Empty or invalid offer" };
182
+ }
183
+
184
+ const lowerOffer = offer.toLowerCase();
185
+ if (!lowerOffer.startsWith("ln")) {
186
+ return { valid: false, error: "Invalid BOLT12 offer format" };
187
+ }
188
+
189
+ decodeBolt12(offer);
190
+ return { valid: true };
191
+ } catch (e) {
192
+ return {
193
+ valid: false,
194
+ error: `Invalid BOLT12 offer: ${e instanceof Error ? e.message : String(e)}`,
195
+ };
196
+ }
197
+ }
198
+
199
+ export function validateSilentPaymentAddress(address: string): {
200
+ valid: boolean;
201
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
202
+ error?: string;
203
+ } {
204
+ try {
205
+ const lowerAddress = address.toLowerCase();
206
+
207
+ let network: "mainnet" | "testnet" | undefined;
208
+ if (lowerAddress.startsWith("sp1q")) {
209
+ network = "mainnet";
210
+ } else if (lowerAddress.startsWith("tsp1q")) {
211
+ network = "testnet";
212
+ } else {
213
+ return { valid: false, error: "Invalid silent payment address prefix" };
214
+ }
215
+
216
+ const decoded = bech32m.decode(address as `${string}1${string}`, 1023);
217
+
218
+ const expectedPrefix = network === "mainnet" ? "sp" : "tsp";
219
+ if (decoded.prefix !== expectedPrefix) {
220
+ return { valid: false, error: "Invalid silent payment address prefix" };
221
+ }
222
+
223
+ // Check version (first word should be 0 for v0, which encodes as 'q')
224
+ if (decoded.words.length === 0 || decoded.words[0] !== 0) {
225
+ return { valid: false, error: "Unsupported silent payment version" };
226
+ }
227
+
228
+ // Convert from 5-bit words to bytes
229
+ const dataWords = decoded.words.slice(1);
230
+ const data = bech32m.fromWordsUnsafe(dataWords);
231
+
232
+ if (!data) {
233
+ return { valid: false, error: "Invalid silent payment address data" };
234
+ }
235
+
236
+ // BIP-352: v0 addresses must be exactly 66 bytes (33-byte scan key + 33-byte spend key)
237
+ if (data.length !== 66) {
238
+ return { valid: false, error: "Invalid silent payment address length" };
239
+ }
240
+
241
+ // Validate both public keys are valid compressed keys (0x02 or 0x03 prefix)
242
+ const scanKey = data[0];
243
+ const spendKey = data[33];
244
+ if (
245
+ (scanKey !== 0x02 && scanKey !== 0x03) ||
246
+ (spendKey !== 0x02 && spendKey !== 0x03)
247
+ ) {
248
+ return {
249
+ valid: false,
250
+ error: "Invalid public key format in silent payment address",
251
+ };
252
+ }
253
+
254
+ return { valid: true, network };
255
+ } catch (e) {
256
+ return {
257
+ valid: false,
258
+ error: `Invalid silent payment address: ${e instanceof Error ? e.message : String(e)}`,
259
+ };
260
+ }
261
+ }
262
+
263
+ export function validateArkAddress(address: string): {
264
+ valid: boolean;
265
+ network?: "mainnet" | "testnet" | "regtest" | "signet";
266
+ error?: string;
267
+ } {
268
+ try {
269
+ const lowerAddress = address.toLowerCase();
270
+
271
+ if (lowerAddress.startsWith("ark1")) {
272
+ const decoded = bech32m.decode(address as `${string}1${string}`, 1023);
273
+ if (decoded.prefix === "ark") {
274
+ return { valid: true, network: "mainnet" };
275
+ }
276
+ } else if (lowerAddress.startsWith("tark1")) {
277
+ const decoded = bech32m.decode(address as `${string}1${string}`, 1023);
278
+ if (decoded.prefix === "tark") {
279
+ return { valid: true, network: "testnet" };
280
+ }
281
+ }
282
+
283
+ return { valid: false, error: "Invalid Ark address format" };
284
+ } catch (e) {
285
+ return {
286
+ valid: false,
287
+ error: `Invalid Ark address: ${e instanceof Error ? e.message : String(e)}`,
288
+ };
289
+ }
290
+ }
291
+
292
+ export function validatePopUri(popUri: string): {
293
+ valid: boolean;
294
+ error?: string;
295
+ } {
296
+ try {
297
+ const decoded = decodeURIComponent(popUri);
298
+ const schemeMatch = decoded.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):?/);
299
+
300
+ if (schemeMatch?.[1]) {
301
+ const scheme = schemeMatch[1].toLowerCase();
302
+ if (FORBIDDEN_POP_SCHEMES.includes(scheme)) {
303
+ return { valid: false, error: `Forbidden pop scheme: ${scheme}` };
304
+ }
305
+ }
306
+
307
+ return { valid: true };
308
+ } catch {
309
+ return { valid: false, error: "Invalid pop URI encoding" };
310
+ }
311
+ }