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 +139 -14
- package/index.test.ts +40 -0
- package/index.ts +34 -302
- package/package.json +1 -1
- package/validators.ts +311 -0
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
|
-
|
|
198
|
-
## Validation Rules
|
|
239
|
+
**Returns:** `BIP321ParseResult` object
|
|
199
240
|
|
|
200
|
-
|
|
241
|
+
### Validation Functions
|
|
201
242
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
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
|
+
}
|