bip-321 0.0.4 → 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/README.md +33 -5
- package/bun.lock +467 -11
- package/eslint.config.js +94 -0
- package/example.ts +3 -3
- package/index.test.ts +144 -8
- package/index.ts +68 -196
- package/package.json +8 -4
- package/validators.ts +311 -0
- package/oxlintrc.json +0 -23
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
|
+
}
|
package/oxlintrc.json
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"categories": {
|
|
3
|
-
"correctness": "warn",
|
|
4
|
-
"suspicious": "warn",
|
|
5
|
-
"pedantic": "warn",
|
|
6
|
-
"perf": "warn",
|
|
7
|
-
"style": "warn",
|
|
8
|
-
"restriction": "warn"
|
|
9
|
-
},
|
|
10
|
-
"rules": {
|
|
11
|
-
"typescript/no-explicit-any": "error",
|
|
12
|
-
"typescript/no-non-null-assertion": "error",
|
|
13
|
-
"no-console": "warn",
|
|
14
|
-
"no-debugger": "error",
|
|
15
|
-
"eqeqeq": "error"
|
|
16
|
-
},
|
|
17
|
-
"ignorePatterns": [
|
|
18
|
-
"**/node_modules/**",
|
|
19
|
-
"**/dist/**",
|
|
20
|
-
"**/build/**",
|
|
21
|
-
"**/*.min.js"
|
|
22
|
-
]
|
|
23
|
-
}
|