bip-321 0.0.5 → 0.0.6
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.test.ts +40 -0
- package/index.ts +24 -302
- package/package.json +1 -1
- package/validators.ts +311 -0
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,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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";
|
|
14
9
|
|
|
15
10
|
export interface PaymentMethod {
|
|
16
11
|
type: "onchain" | "lightning" | "offer" | "silent-payment" | "ark";
|
|
@@ -35,289 +30,6 @@ export interface BIP321ParseResult {
|
|
|
35
30
|
errors: string[];
|
|
36
31
|
}
|
|
37
32
|
|
|
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
33
|
export function parseBIP321(
|
|
322
34
|
uri: string,
|
|
323
35
|
expectedNetwork?: "mainnet" | "testnet" | "regtest" | "signet",
|
|
@@ -551,12 +263,22 @@ export function parseBIP321(
|
|
|
551
263
|
if (expectedNetwork) {
|
|
552
264
|
for (const method of result.paymentMethods) {
|
|
553
265
|
if (method.network && method.network !== expectedNetwork) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
266
|
+
// For Ark and Silent Payments, testnet covers testnet/signet/regtest
|
|
267
|
+
const isTestnetCompatible =
|
|
268
|
+
(method.type === "ark" || method.type === "silent-payment") &&
|
|
269
|
+
method.network === "testnet" &&
|
|
270
|
+
(expectedNetwork === "testnet" ||
|
|
271
|
+
expectedNetwork === "signet" ||
|
|
272
|
+
expectedNetwork === "regtest");
|
|
273
|
+
|
|
274
|
+
if (!isTestnetCompatible) {
|
|
275
|
+
result.errors.push(
|
|
276
|
+
`Payment method network mismatch: expected ${expectedNetwork}, got ${method.network}`,
|
|
277
|
+
);
|
|
278
|
+
result.valid = false;
|
|
279
|
+
method.valid = false;
|
|
280
|
+
method.error = `Network mismatch: expected ${expectedNetwork}`;
|
|
281
|
+
}
|
|
560
282
|
}
|
|
561
283
|
}
|
|
562
284
|
}
|
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
|
+
}
|