@armory-sh/base 0.2.28 → 0.2.29

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.
@@ -0,0 +1,654 @@
1
+ /**
2
+ * Comprehensive validation for Armory configurations
3
+ * Ensures networks, tokens, and facilitators are compatible
4
+ */
5
+
6
+ import type { CustomToken, NetworkConfig } from "./types/networks";
7
+ import type { CAIPAssetId } from "./types/v2";
8
+
9
+ const isEvmAddress = (value: string): boolean =>
10
+ /^0x[a-fA-F0-9]{40}$/.test(value);
11
+
12
+ import type {
13
+ FacilitatorConfig,
14
+ NetworkId,
15
+ PaymentErrorCode,
16
+ ResolvedFacilitator,
17
+ ResolvedNetwork,
18
+ ResolvedPaymentConfig,
19
+ ResolvedToken,
20
+ TokenId,
21
+ ValidationError,
22
+ } from "./types/api";
23
+
24
+ import {
25
+ getAllCustomTokens,
26
+ getCustomToken,
27
+ getNetworkByChainId,
28
+ getNetworkConfig,
29
+ NETWORKS,
30
+ } from "./types/networks";
31
+
32
+ // ═══════════════════════════════════════════════════════════════
33
+ // Error Creation
34
+ // ═══════════════════════════════════════════════════════════════
35
+
36
+ export const createError = (
37
+ code: PaymentErrorCode,
38
+ message: string,
39
+ details?: Partial<ValidationError>,
40
+ ): ValidationError => ({
41
+ code,
42
+ message,
43
+ ...details,
44
+ });
45
+
46
+ // ═══════════════════════════════════════════════════════════════
47
+ // Network Resolution & Validation
48
+ // ═══════════════════════════════════════════════════════════════
49
+
50
+ export const normalizeNetworkName = (name: string): string =>
51
+ name
52
+ .toLowerCase()
53
+ .replace(/\s+/g, "-")
54
+ .replace("-mainnet", "")
55
+ .replace(/-mainnet/, "")
56
+ .replace("mainnet-", "")
57
+ .replace(/-sepolia$/, "-sepolia")
58
+ .replace(/^-|-$/g, "");
59
+
60
+ export const resolveNetwork = (
61
+ input: unknown,
62
+ ): ResolvedNetwork | ValidationError => {
63
+ if (typeof input === "object" && input !== null && "chainId" in input) {
64
+ const config = input as NetworkConfig;
65
+ if (!config.chainId || !config.usdcAddress || !config.caip2Id) {
66
+ return createError("INVALID_CAIP_FORMAT", "Invalid network config", {
67
+ value: config,
68
+ });
69
+ }
70
+ return {
71
+ input: input as unknown as NetworkId,
72
+ config,
73
+ caip2: config.caip2Id as `eip155:${number}`,
74
+ };
75
+ }
76
+
77
+ if (typeof input === "number") {
78
+ const config = getNetworkByChainId(input);
79
+ if (!config) {
80
+ const validChainIds = Object.values(NETWORKS).map((n) => n.chainId);
81
+ return createError("UNKNOWN_NETWORK", `Unknown chain ID: ${input}`, {
82
+ value: input,
83
+ validOptions: validChainIds.map(String),
84
+ });
85
+ }
86
+ return {
87
+ input,
88
+ config,
89
+ caip2: config.caip2Id as `eip155:${number}`,
90
+ };
91
+ }
92
+
93
+ if (typeof input === "string") {
94
+ if (input.startsWith("eip155:")) {
95
+ const parts = input.split(":");
96
+ if (parts.length !== 2 || Number.isNaN(Number(parts[1]))) {
97
+ return createError(
98
+ "INVALID_CAIP_FORMAT",
99
+ `Invalid CAIP-2 format: ${input}`,
100
+ { value: input },
101
+ );
102
+ }
103
+ const chainId = Number(parts[1]);
104
+ const config = getNetworkByChainId(chainId);
105
+ if (!config) {
106
+ return createError(
107
+ "UNKNOWN_NETWORK",
108
+ `Unknown CAIP-2 network: ${input}`,
109
+ { value: input },
110
+ );
111
+ }
112
+ return {
113
+ input,
114
+ config,
115
+ caip2: input as `eip155:${number}`,
116
+ };
117
+ }
118
+
119
+ const normalizedName = normalizeNetworkName(input);
120
+ const config = getNetworkConfig(normalizedName);
121
+ if (!config) {
122
+ const validNames = Object.keys(NETWORKS);
123
+ return createError("UNKNOWN_NETWORK", `Unknown network: "${input}"`, {
124
+ value: input,
125
+ validOptions: validNames,
126
+ });
127
+ }
128
+ return {
129
+ input,
130
+ config,
131
+ caip2: config.caip2Id as `eip155:${number}`,
132
+ };
133
+ }
134
+
135
+ return createError(
136
+ "UNKNOWN_NETWORK",
137
+ `Invalid network identifier type: ${typeof input}`,
138
+ { value: input },
139
+ );
140
+ };
141
+
142
+ export const getAvailableNetworks = (): string[] => Object.keys(NETWORKS);
143
+
144
+ // ═══════════════════════════════════════════════════════════════
145
+ // Token Resolution & Validation
146
+ // ═══════════════════════════════════════════════════════════════
147
+
148
+ const normalizeTokenSymbol = (symbol: string): string => symbol.toUpperCase();
149
+
150
+ export const resolveToken = (
151
+ input: unknown,
152
+ network?: ResolvedNetwork,
153
+ ): ResolvedToken | ValidationError => {
154
+ if (
155
+ typeof input === "object" &&
156
+ input !== null &&
157
+ "contractAddress" in input
158
+ ) {
159
+ const config = input as CustomToken;
160
+ if (!config.chainId || !config.contractAddress || !config.symbol) {
161
+ return createError("VALIDATION_FAILED", "Invalid token config", {
162
+ value: config,
163
+ });
164
+ }
165
+
166
+ if (network && config.chainId !== network.config.chainId) {
167
+ return createError(
168
+ "TOKEN_NOT_ON_NETWORK",
169
+ `Token ${config.symbol} is on chain ${config.chainId}, but expected chain ${network.config.chainId}`,
170
+ { value: config },
171
+ );
172
+ }
173
+
174
+ const tokenNetwork = resolveNetwork(config.chainId);
175
+ if ("code" in tokenNetwork) {
176
+ return tokenNetwork;
177
+ }
178
+
179
+ const caipAsset =
180
+ `eip155:${config.chainId}/erc20:${config.contractAddress}` as CAIPAssetId;
181
+
182
+ return {
183
+ input: input as unknown as TokenId,
184
+ config,
185
+ caipAsset,
186
+ network: tokenNetwork,
187
+ };
188
+ }
189
+
190
+ if (typeof input === "string") {
191
+ if (isEvmAddress(input)) {
192
+ const contractAddress = input as `0x${string}`;
193
+
194
+ if (network) {
195
+ const customToken = getCustomToken(
196
+ network.config.chainId,
197
+ contractAddress,
198
+ );
199
+ const config = customToken || {
200
+ symbol: "CUSTOM",
201
+ name: "Custom Token",
202
+ version: "1",
203
+ chainId: network.config.chainId,
204
+ contractAddress,
205
+ decimals: 18,
206
+ };
207
+
208
+ const caipAsset: CAIPAssetId = `eip155:${network.config.chainId}/erc20:${contractAddress}`;
209
+
210
+ return {
211
+ input,
212
+ config,
213
+ caipAsset,
214
+ network,
215
+ };
216
+ }
217
+
218
+ const customTokens = getAllCustomTokens();
219
+ const matchingToken = customTokens.find(
220
+ (t) =>
221
+ t.contractAddress.toLowerCase() === contractAddress.toLowerCase(),
222
+ );
223
+
224
+ if (matchingToken) {
225
+ const tokenNetwork = resolveNetwork(matchingToken.chainId);
226
+ if ("code" in tokenNetwork) {
227
+ return tokenNetwork;
228
+ }
229
+
230
+ const caipAsset: CAIPAssetId = `eip155:${matchingToken.chainId}/erc20:${contractAddress}`;
231
+
232
+ return {
233
+ input,
234
+ config: matchingToken,
235
+ caipAsset,
236
+ network: tokenNetwork,
237
+ };
238
+ }
239
+
240
+ return createError(
241
+ "UNKNOWN_TOKEN",
242
+ `Token address "${contractAddress}" not found in registry. Please specify a network.`,
243
+ { value: input, validOptions: ["Specify network parameter"] },
244
+ );
245
+ }
246
+
247
+ if (input.includes("/erc20:")) {
248
+ const parts = input.split("/");
249
+ if (parts.length !== 2) {
250
+ return createError(
251
+ "INVALID_CAIP_FORMAT",
252
+ `Invalid CAIP Asset ID format: ${input}`,
253
+ { value: input },
254
+ );
255
+ }
256
+ const chainId = Number(parts[0].split(":")[1]);
257
+ const contractAddress = parts[1].split(":")[1] as `0x${string}`;
258
+
259
+ const tokenNetwork = resolveNetwork(chainId);
260
+ if ("code" in tokenNetwork) {
261
+ return tokenNetwork;
262
+ }
263
+
264
+ const customToken = getCustomToken(chainId, contractAddress);
265
+ const config = customToken || {
266
+ symbol: "CUSTOM",
267
+ name: "Custom Token",
268
+ version: "1",
269
+ chainId,
270
+ contractAddress,
271
+ decimals: 18,
272
+ };
273
+
274
+ return {
275
+ input,
276
+ config,
277
+ caipAsset: input as `eip155:${number}/erc20:${string}`,
278
+ network: tokenNetwork,
279
+ };
280
+ }
281
+
282
+ const normalizedSymbol = normalizeTokenSymbol(input);
283
+
284
+ if (network) {
285
+ const customTokens = getAllCustomTokens();
286
+ const matchingToken = customTokens.find(
287
+ (t) =>
288
+ t.symbol?.toUpperCase() === normalizedSymbol &&
289
+ t.chainId === network.config.chainId,
290
+ );
291
+
292
+ if (matchingToken) {
293
+ return {
294
+ input,
295
+ config: matchingToken,
296
+ caipAsset:
297
+ `eip155:${matchingToken.chainId}/erc20:${matchingToken.contractAddress}` as const,
298
+ network,
299
+ };
300
+ }
301
+
302
+ return {
303
+ input,
304
+ config: {
305
+ symbol: normalizedSymbol,
306
+ name: `${network.config.name} ${normalizedSymbol}`,
307
+ version: "2",
308
+ chainId: network.config.chainId,
309
+ contractAddress: network.config.usdcAddress,
310
+ decimals: 6,
311
+ },
312
+ caipAsset: network.config
313
+ .caipAssetId as `eip155:${number}/erc20:${string}`,
314
+ network,
315
+ };
316
+ }
317
+
318
+ const customTokens = getAllCustomTokens();
319
+ const matchingToken = customTokens.find(
320
+ (t) => t.symbol?.toUpperCase() === normalizedSymbol,
321
+ );
322
+
323
+ if (matchingToken) {
324
+ const tokenNetwork = resolveNetwork(matchingToken.chainId);
325
+ if ("code" in tokenNetwork) {
326
+ return tokenNetwork;
327
+ }
328
+ return {
329
+ input,
330
+ config: matchingToken,
331
+ caipAsset:
332
+ `eip155:${matchingToken.chainId}/erc20:${matchingToken.contractAddress}` as const,
333
+ network: tokenNetwork,
334
+ };
335
+ }
336
+
337
+ const baseNetwork = resolveNetwork("base");
338
+ if ("code" in baseNetwork) {
339
+ return baseNetwork;
340
+ }
341
+
342
+ return {
343
+ input,
344
+ config: {
345
+ symbol: normalizedSymbol,
346
+ name: "USD Coin",
347
+ version: "2",
348
+ chainId: 8453,
349
+ contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
350
+ decimals: 6,
351
+ },
352
+ caipAsset: baseNetwork.config
353
+ .caipAssetId as `eip155:${number}/erc20:${string}`,
354
+ network: baseNetwork,
355
+ };
356
+ }
357
+
358
+ return createError(
359
+ "UNKNOWN_TOKEN",
360
+ `Invalid token identifier type: ${typeof input}`,
361
+ { value: input },
362
+ );
363
+ };
364
+
365
+ export const getAvailableTokens = (): string[] => {
366
+ const customTokens = getAllCustomTokens();
367
+ const symbols = new Set(customTokens.map((t) => t.symbol.toUpperCase()));
368
+ return Array.from(symbols);
369
+ };
370
+
371
+ // ═══════════════════════════════════════════════════════════════
372
+ // Facilitator Resolution & Validation
373
+ // ═══════════════════════════════════════════════════════════════
374
+
375
+ export const resolveFacilitator = (
376
+ input: FacilitatorConfig,
377
+ supportedNetworks?: ResolvedNetwork[],
378
+ supportedTokens?: ResolvedToken[],
379
+ ): ResolvedFacilitator | ValidationError => {
380
+ try {
381
+ new URL(input.url);
382
+ } catch {
383
+ return createError(
384
+ "VALIDATION_FAILED",
385
+ `Invalid facilitator URL: ${input.url}`,
386
+ { value: input.url },
387
+ );
388
+ }
389
+
390
+ const networks: ResolvedNetwork[] = [];
391
+ if (input.networks) {
392
+ for (const network of input.networks) {
393
+ const resolved = resolveNetwork(network);
394
+ if ("code" in resolved) {
395
+ return resolved;
396
+ }
397
+ networks.push(resolved);
398
+ }
399
+ } else if (supportedNetworks) {
400
+ networks.push(...supportedNetworks);
401
+ }
402
+
403
+ const tokens: ResolvedToken[] = [];
404
+ if (input.tokens) {
405
+ for (const token of input.tokens) {
406
+ for (const network of networks.length > 0
407
+ ? networks
408
+ : supportedNetworks || []) {
409
+ const resolved = resolveToken(token, network);
410
+ if ("code" in resolved) {
411
+ continue;
412
+ }
413
+ tokens.push(resolved);
414
+ break;
415
+ }
416
+ }
417
+ } else if (supportedTokens) {
418
+ tokens.push(...supportedTokens);
419
+ }
420
+
421
+ return {
422
+ input,
423
+ url: input.url,
424
+ networks,
425
+ tokens,
426
+ };
427
+ };
428
+
429
+ export const checkFacilitatorSupport = (
430
+ facilitator: ResolvedFacilitator,
431
+ network: ResolvedNetwork,
432
+ token: ResolvedToken,
433
+ ): ValidationError | { supported: true } => {
434
+ if (facilitator.networks.length > 0) {
435
+ const networkSupported = facilitator.networks.some(
436
+ (n) => n.config.chainId === network.config.chainId,
437
+ );
438
+
439
+ if (!networkSupported) {
440
+ const supportedNames = facilitator.networks.map((n) => n.config.name);
441
+ return createError(
442
+ "FACILITATOR_NO_NETWORK_SUPPORT",
443
+ `Facilitator "${facilitator.url}" does not support network "${network.config.name}" (chain ${network.config.chainId}). Supported: ${supportedNames.join(", ")}`,
444
+ {
445
+ value: { facilitator: facilitator.url, network: network.config.name },
446
+ validOptions: supportedNames,
447
+ },
448
+ );
449
+ }
450
+ }
451
+
452
+ if (facilitator.tokens.length > 0) {
453
+ const tokenSupported = facilitator.tokens.some(
454
+ (t) =>
455
+ t.config.contractAddress.toLowerCase() ===
456
+ token.config.contractAddress.toLowerCase() &&
457
+ t.network.config.chainId === token.network.config.chainId,
458
+ );
459
+
460
+ if (!tokenSupported) {
461
+ const supportedTokens = facilitator.tokens.map(
462
+ (t) => `${t.config.symbol} (${t.network.config.name})`,
463
+ );
464
+ return createError(
465
+ "FACILITATOR_NO_TOKEN_SUPPORT",
466
+ `Facilitator "${facilitator.url}" does not support token "${token.config.symbol}" on "${token.network.config.name}". Supported: ${supportedTokens.join(", ")}`,
467
+ {
468
+ value: {
469
+ facilitator: facilitator.url,
470
+ token: token.config.symbol,
471
+ network: token.network.config.name,
472
+ },
473
+ validOptions: supportedTokens,
474
+ },
475
+ );
476
+ }
477
+ }
478
+
479
+ return { supported: true };
480
+ };
481
+
482
+ // ═══════════════════════════════════════════════════════════════
483
+ // Full Configuration Validation
484
+ // ═══════════════════════════════════════════════════════════════
485
+
486
+ export const validatePaymentConfig = (
487
+ network: NetworkId,
488
+ token: TokenId,
489
+ facilitators?: FacilitatorConfig | FacilitatorConfig[],
490
+ payTo: string = "0x0000000000000000000000000000000000000000",
491
+ amount: string = "1.0",
492
+ ): ResolvedPaymentConfig | ValidationError => {
493
+ const resolvedNetwork = resolveNetwork(network);
494
+ if ("code" in resolvedNetwork) {
495
+ return resolvedNetwork;
496
+ }
497
+
498
+ const resolvedToken = resolveToken(token, resolvedNetwork);
499
+ if ("code" in resolvedToken) {
500
+ return resolvedToken;
501
+ }
502
+
503
+ const resolvedFacilitators: ResolvedFacilitator[] = [];
504
+ if (facilitators) {
505
+ const facilitatorArray = Array.isArray(facilitators)
506
+ ? facilitators
507
+ : [facilitators];
508
+ for (const facilitator of facilitatorArray) {
509
+ const resolved = resolveFacilitator(
510
+ facilitator,
511
+ [resolvedNetwork],
512
+ [resolvedToken],
513
+ );
514
+ if ("code" in resolved) {
515
+ return resolved;
516
+ }
517
+
518
+ const supportCheck = checkFacilitatorSupport(
519
+ resolved,
520
+ resolvedNetwork,
521
+ resolvedToken,
522
+ );
523
+ if ("code" in supportCheck) {
524
+ return supportCheck;
525
+ }
526
+
527
+ resolvedFacilitators.push(resolved);
528
+ }
529
+ }
530
+
531
+ return {
532
+ network: resolvedNetwork,
533
+ token: resolvedToken,
534
+ facilitators: resolvedFacilitators,
535
+ version: 2,
536
+ payTo: payTo as `0x${string}`,
537
+ amount,
538
+ };
539
+ };
540
+
541
+ export const validateAcceptConfig = (
542
+ options: {
543
+ networks?: NetworkId[];
544
+ tokens?: TokenId[];
545
+ facilitators?: FacilitatorConfig | FacilitatorConfig[];
546
+ version?: 2;
547
+ },
548
+ payTo: string,
549
+ amount: string,
550
+ ):
551
+ | { success: true; config: ResolvedPaymentConfig[] }
552
+ | { success: false; error: ValidationError } => {
553
+ const {
554
+ networks: networkInputs,
555
+ tokens: tokenInputs,
556
+ facilitators,
557
+ version = 2,
558
+ } = options;
559
+
560
+ const networkIds = networkInputs?.length
561
+ ? networkInputs
562
+ : Object.keys(NETWORKS);
563
+ const tokenIds = tokenInputs?.length ? tokenInputs : ["usdc"];
564
+
565
+ const networks: ResolvedNetwork[] = [];
566
+ for (const networkId of networkIds) {
567
+ const resolved = resolveNetwork(networkId);
568
+ if ("code" in resolved) {
569
+ return { success: false, error: resolved };
570
+ }
571
+ networks.push(resolved);
572
+ }
573
+
574
+ const tokens: ResolvedToken[] = [];
575
+ for (const tokenId of tokenIds) {
576
+ let matches = 0;
577
+ for (const network of networks) {
578
+ const resolved = resolveToken(tokenId, network);
579
+ if ("code" in resolved) {
580
+ continue;
581
+ }
582
+ tokens.push(resolved);
583
+ matches += 1;
584
+ }
585
+ if (matches === 0) {
586
+ return {
587
+ success: false,
588
+ error: createError(
589
+ "TOKEN_NOT_ON_NETWORK",
590
+ `Token "${String(tokenId)}" not found on any of the specified networks`,
591
+ { value: tokenId, validOptions: networks.map((n) => n.config.name) },
592
+ ),
593
+ };
594
+ }
595
+ }
596
+
597
+ const facilitatorArray = facilitators
598
+ ? Array.isArray(facilitators)
599
+ ? facilitators
600
+ : [facilitators]
601
+ : [];
602
+ const resolvedFacilitators: ResolvedFacilitator[] = [];
603
+ for (const facilitator of facilitatorArray) {
604
+ const resolved = resolveFacilitator(facilitator, networks, tokens);
605
+ if ("code" in resolved) {
606
+ return { success: false, error: resolved };
607
+ }
608
+ resolvedFacilitators.push(resolved);
609
+ }
610
+
611
+ const configs: ResolvedPaymentConfig[] = [];
612
+ for (const network of networks) {
613
+ for (const token of tokens) {
614
+ if (token.network.config.chainId === network.config.chainId) {
615
+ configs.push({
616
+ network,
617
+ token,
618
+ facilitators: resolvedFacilitators,
619
+ version,
620
+ payTo: payTo as `0x${string}`,
621
+ amount,
622
+ });
623
+ }
624
+ }
625
+ }
626
+
627
+ return { success: true, config: configs };
628
+ };
629
+
630
+ // ═══════════════════════════════════════════════════════════════
631
+ // Type Guards
632
+ // ═══════════════════════════════════════════════════════════════
633
+
634
+ export const isValidationError = (value: unknown): value is ValidationError => {
635
+ return typeof value === "object" && value !== null && "code" in value;
636
+ };
637
+
638
+ export const isResolvedNetwork = (value: unknown): value is ResolvedNetwork => {
639
+ return (
640
+ typeof value === "object" &&
641
+ value !== null &&
642
+ "config" in value &&
643
+ "caip2" in value
644
+ );
645
+ };
646
+
647
+ export const isResolvedToken = (value: unknown): value is ResolvedToken => {
648
+ return (
649
+ typeof value === "object" &&
650
+ value !== null &&
651
+ "config" in value &&
652
+ "caipAsset" in value
653
+ );
654
+ };