@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,240 @@
1
+ export type RoutePattern = string;
2
+ export type RouteMatcher = (path: string) => boolean;
3
+
4
+ export interface RouteConfig<T = unknown> {
5
+ pattern: RoutePattern;
6
+ config: T;
7
+ }
8
+
9
+ export interface ParsedPattern {
10
+ segments: string[];
11
+ isWildcard: boolean;
12
+ isParametrized: boolean;
13
+ paramNames: string[];
14
+ priority: number;
15
+ }
16
+
17
+ const PRIORITY_EXACT = 3;
18
+ const PRIORITY_PARAMETRIZED = 2;
19
+ const PRIORITY_WILDCARD = 1;
20
+
21
+ export function parseRoutePattern(pattern: string): ParsedPattern {
22
+ const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
23
+ const segments = normalizedPattern.split("/").filter(Boolean);
24
+
25
+ let isWildcard = false;
26
+ let isParametrized = false;
27
+ const paramNames: string[] = [];
28
+ const seenParamNames = new Set<string>();
29
+ const recordParamName = (name: string) => {
30
+ if (!name) {
31
+ return;
32
+ }
33
+ if (seenParamNames.has(name)) {
34
+ return;
35
+ }
36
+ seenParamNames.add(name);
37
+ paramNames.push(name);
38
+ };
39
+
40
+ for (const segment of segments) {
41
+ if (segment === "*") {
42
+ isWildcard = true;
43
+ continue;
44
+ }
45
+
46
+ const hasWildcardToken = segment.includes("*");
47
+
48
+ if (segment.startsWith(":")) {
49
+ isParametrized = true;
50
+ const paramName = segment.replace(/\*+$/, "").slice(1);
51
+ recordParamName(paramName);
52
+ if (hasWildcardToken) {
53
+ isWildcard = true;
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (hasWildcardToken) {
59
+ const parts = segment.split("*");
60
+ for (const part of parts) {
61
+ if (part.startsWith(":")) {
62
+ isParametrized = true;
63
+ recordParamName(part.slice(1));
64
+ }
65
+ }
66
+ isWildcard = true;
67
+ }
68
+ }
69
+
70
+ let priority = PRIORITY_WILDCARD;
71
+ if (!isWildcard && !isParametrized) {
72
+ priority = PRIORITY_EXACT;
73
+ } else if (isParametrized && !isWildcard) {
74
+ priority = PRIORITY_PARAMETRIZED;
75
+ } else if (isParametrized && isWildcard) {
76
+ priority = PRIORITY_PARAMETRIZED;
77
+ }
78
+
79
+ return { segments, isWildcard, isParametrized, paramNames, priority };
80
+ }
81
+
82
+ function matchSegment(patternSegment: string, pathSegment: string): boolean {
83
+ if (patternSegment === "*") {
84
+ return true;
85
+ }
86
+
87
+ if (patternSegment.startsWith(":")) {
88
+ return true;
89
+ }
90
+
91
+ if (patternSegment.includes("*")) {
92
+ const regex = new RegExp(
93
+ `^${patternSegment.replace(/\*/g, ".*").replace(/:/g, "")}$`,
94
+ );
95
+ return regex.test(pathSegment);
96
+ }
97
+
98
+ return patternSegment === pathSegment;
99
+ }
100
+
101
+ function matchWildcardPattern(
102
+ patternSegments: string[],
103
+ pathSegments: string[],
104
+ ): boolean {
105
+ const requiredSegments = patternSegments.filter((s) => s !== "*");
106
+
107
+ if (requiredSegments.length > pathSegments.length) {
108
+ return false;
109
+ }
110
+
111
+ for (let i = 0; i < requiredSegments.length; i++) {
112
+ const patternIndex = patternSegments.indexOf(requiredSegments[i]);
113
+ if (pathSegments[patternIndex] !== requiredSegments[i].replace(/^:/, "")) {
114
+ if (!requiredSegments[i].startsWith(":") && requiredSegments[i] !== "*") {
115
+ return false;
116
+ }
117
+ }
118
+ }
119
+
120
+ return true;
121
+ }
122
+
123
+ export function matchRoute(pattern: string, path: string): boolean {
124
+ const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
125
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
126
+
127
+ if (normalizedPattern === normalizedPath) {
128
+ return true;
129
+ }
130
+
131
+ const parsed = parseRoutePattern(normalizedPattern);
132
+ const patternSegments = parsed.segments;
133
+ const pathSegments = normalizedPath.split("/").filter(Boolean);
134
+
135
+ if (!parsed.isWildcard && patternSegments.length !== pathSegments.length) {
136
+ return false;
137
+ }
138
+
139
+ if (parsed.isWildcard && patternSegments.length > pathSegments.length + 1) {
140
+ return false;
141
+ }
142
+
143
+ if (parsed.isWildcard) {
144
+ return matchWildcardPattern(patternSegments, pathSegments);
145
+ }
146
+
147
+ for (let i = 0; i < patternSegments.length; i++) {
148
+ if (!matchSegment(patternSegments[i], pathSegments[i])) {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ return true;
154
+ }
155
+
156
+ export function findMatchingRoute<T>(
157
+ routes: RouteConfig<T>[],
158
+ path: string,
159
+ ): RouteConfig<T> | null {
160
+ const matchingRoutes: Array<{
161
+ route: RouteConfig<T>;
162
+ parsed: ParsedPattern;
163
+ }> = [];
164
+
165
+ for (const route of routes) {
166
+ if (matchRoute(route.pattern, path)) {
167
+ const parsed = parseRoutePattern(route.pattern);
168
+ matchingRoutes.push({ route, parsed });
169
+ }
170
+ }
171
+
172
+ if (matchingRoutes.length === 0) {
173
+ return null;
174
+ }
175
+
176
+ matchingRoutes.sort((a, b) => {
177
+ if (b.parsed.priority !== a.parsed.priority) {
178
+ return b.parsed.priority - a.parsed.priority;
179
+ }
180
+ if (b.parsed.segments.length !== a.parsed.segments.length) {
181
+ return b.parsed.segments.length - a.parsed.segments.length;
182
+ }
183
+ return b.route.pattern.length - a.route.pattern.length;
184
+ });
185
+
186
+ return matchingRoutes[0].route;
187
+ }
188
+
189
+ export interface RouteInputConfig {
190
+ route?: string;
191
+ routes?: string[];
192
+ }
193
+
194
+ export interface RouteValidationError {
195
+ code: string;
196
+ message: string;
197
+ path?: string;
198
+ value?: unknown;
199
+ validOptions?: string[];
200
+ }
201
+
202
+ function containsWildcard(pattern: string): boolean {
203
+ return pattern.includes("*");
204
+ }
205
+
206
+ export function validateRouteConfig(
207
+ config: RouteInputConfig,
208
+ ): RouteValidationError | null {
209
+ const { route, routes } = config;
210
+
211
+ if (!route && !routes) {
212
+ return null;
213
+ }
214
+
215
+ if (route && routes) {
216
+ return {
217
+ code: "INVALID_ROUTE_CONFIG",
218
+ message:
219
+ "Cannot specify both 'route' and 'routes'. Use 'route' for a single exact path or 'routes' for multiple paths.",
220
+ path: "route",
221
+ value: { route, routes },
222
+ };
223
+ }
224
+
225
+ if (route && containsWildcard(route)) {
226
+ return {
227
+ code: "INVALID_ROUTE_PATTERN",
228
+ message:
229
+ "Wildcard routes must use the routes array, not 'route'. Use 'routes: [\"/api/*\"]' instead of 'route: \"/api/*\"'.",
230
+ path: "route",
231
+ value: route,
232
+ validOptions: [
233
+ 'routes: ["/api/*"]',
234
+ 'routes: ["/api/users", "/api/posts"]',
235
+ ],
236
+ };
237
+ }
238
+
239
+ return null;
240
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Test Utilities Index
3
+ *
4
+ * Re-exports commonly used test utilities
5
+ */
6
+
7
+ export * from "./mock-facilitator";
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Mock Facilitator for Testing
3
+ */
4
+
5
+ /// <reference types="bun-types" />
6
+
7
+ import type { PaymentPayload } from "@armory-sh/base";
8
+ import { serve } from "bun";
9
+
10
+ export interface FacilitatorVerifyRequest {
11
+ payload: PaymentPayload;
12
+ requirements: Record<string, unknown>;
13
+ options?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface FacilitatorVerifyResponse {
17
+ success: boolean;
18
+ payerAddress?: string;
19
+ balance?: string;
20
+ requiredAmount?: string;
21
+ error?: string;
22
+ }
23
+
24
+ export interface FacilitatorSettleRequest {
25
+ payload: PaymentPayload;
26
+ requirements: Record<string, unknown>;
27
+ }
28
+
29
+ export interface FacilitatorSettleResponse {
30
+ success: boolean;
31
+ txHash?: string;
32
+ error?: string;
33
+ }
34
+
35
+ export type AnyFacilitatorRequest =
36
+ | FacilitatorVerifyRequest
37
+ | FacilitatorSettleRequest;
38
+ export type AnyFacilitatorResponse =
39
+ | FacilitatorVerifyResponse
40
+ | FacilitatorSettleResponse;
41
+
42
+ export interface MockFacilitatorOptions {
43
+ port?: number;
44
+ alwaysVerify?: boolean;
45
+ alwaysSettle?: boolean;
46
+ verifyDelay?: number;
47
+ settleDelay?: number;
48
+ }
49
+
50
+ let nextPort = 9100;
51
+
52
+ export const createMockFacilitator = async (
53
+ options: MockFacilitatorOptions = {},
54
+ ): Promise<{ url: string; close: () => Promise<void> }> => {
55
+ const {
56
+ port = nextPort++,
57
+ alwaysVerify = true,
58
+ alwaysSettle = true,
59
+ verifyDelay = 0,
60
+ settleDelay = 0,
61
+ } = options;
62
+
63
+ const server = serve({
64
+ port,
65
+ hostname: "127.0.0.1",
66
+ async fetch(req: Request) {
67
+ const url = new URL(req.url);
68
+ const path = url.pathname;
69
+
70
+ const corsHeaders = {
71
+ "Access-Control-Allow-Origin": "*",
72
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
73
+ "Access-Control-Allow-Headers": "Content-Type",
74
+ };
75
+
76
+ if (req.method === "OPTIONS") {
77
+ return new Response(null, { status: 204, headers: corsHeaders });
78
+ }
79
+
80
+ if (req.method !== "POST") {
81
+ return Response.json(
82
+ { error: "Method not allowed" },
83
+ { status: 405, headers: corsHeaders },
84
+ );
85
+ }
86
+
87
+ if (path === "/verify") {
88
+ const body = (await req.json()) as FacilitatorVerifyRequest;
89
+
90
+ if (verifyDelay > 0) {
91
+ await new Promise((resolve) => setTimeout(resolve, verifyDelay));
92
+ }
93
+
94
+ const result: FacilitatorVerifyResponse = alwaysVerify
95
+ ? {
96
+ success: true,
97
+ payerAddress: extractPayerAddress(body.payload),
98
+ balance: "10000000",
99
+ requiredAmount: "1000000",
100
+ }
101
+ : {
102
+ success: false,
103
+ error: "Verification failed (mock error)",
104
+ };
105
+
106
+ return Response.json(result, { headers: corsHeaders });
107
+ }
108
+
109
+ if (path === "/settle") {
110
+ const body = (await req.json()) as FacilitatorSettleRequest;
111
+
112
+ if (settleDelay > 0) {
113
+ await new Promise((resolve) => setTimeout(resolve, settleDelay));
114
+ }
115
+
116
+ const result: FacilitatorSettleResponse = alwaysSettle
117
+ ? {
118
+ success: true,
119
+ txHash: `0x${"a".repeat(64)}`,
120
+ }
121
+ : {
122
+ success: false,
123
+ error: "Settlement failed (mock error)",
124
+ };
125
+
126
+ return Response.json(result, { headers: corsHeaders });
127
+ }
128
+
129
+ return Response.json(
130
+ { error: "Not found" },
131
+ { status: 404, headers: corsHeaders },
132
+ );
133
+ },
134
+ });
135
+
136
+ await new Promise((resolve) => setTimeout(resolve, 50));
137
+
138
+ return {
139
+ url: `http://127.0.0.1:${port}`,
140
+ close: async () => {
141
+ server.stop();
142
+ await new Promise((resolve) => setTimeout(resolve, 50));
143
+ },
144
+ };
145
+ };
146
+
147
+ function extractPayerAddress(payload: PaymentPayload | unknown): string {
148
+ if (typeof payload === "string") {
149
+ try {
150
+ payload = JSON.parse(payload);
151
+ } catch {
152
+ return "0x0000000000000000000000000000000000000000000000";
153
+ }
154
+ }
155
+
156
+ if (
157
+ typeof payload === "object" &&
158
+ payload !== null &&
159
+ "x402Version" in payload
160
+ ) {
161
+ const p = payload as Record<string, unknown>;
162
+ if ("payload" in p && typeof p.payload === "object" && p.payload !== null) {
163
+ const schemePayload = p.payload as Record<string, unknown>;
164
+ if (
165
+ "authorization" in schemePayload &&
166
+ typeof schemePayload.authorization === "object"
167
+ ) {
168
+ const auth = schemePayload.authorization as Record<string, unknown>;
169
+ if ("from" in auth && typeof auth.from === "string") {
170
+ return auth.from;
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ if (typeof payload === "object" && payload !== null && "from" in payload) {
177
+ const p = payload as Record<string, unknown>;
178
+ if (typeof p.from === "string") {
179
+ return p.from;
180
+ }
181
+ }
182
+
183
+ return "0x0000000000000000000000000000000000000000000000";
184
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * X402 Protocol Utilities - Coinbase Compatible
3
+ *
4
+ * Helper functions for nonce generation, amount conversion, and other utilities.
5
+ */
6
+
7
+ import { randomBytes } from "node:crypto";
8
+ import type { Address, Hex } from "../types/x402";
9
+
10
+ /**
11
+ * Generate a random 32-byte nonce as hex string
12
+ * Matches Coinbase SDK format: "0x" + 64 hex characters
13
+ */
14
+ export function createNonce(): Hex {
15
+ const bytes = randomBytes(32);
16
+ return `0x${bytes.toString("hex")}` as Hex;
17
+ }
18
+
19
+ /**
20
+ * Convert human-readable amount to atomic units
21
+ * e.g., "1.5" USDC -> "1500000" (6 decimals)
22
+ */
23
+ export function toAtomicUnits(amount: string, decimals: number = 6): string {
24
+ const parts = amount.split(".");
25
+ const whole = parts[0];
26
+ const fractional = parts[1] || "";
27
+ const paddedFractional = fractional.padEnd(decimals, "0").slice(0, decimals);
28
+ return `${whole}${paddedFractional}`;
29
+ }
30
+
31
+ /**
32
+ * Convert atomic units to human-readable amount
33
+ * e.g., "1500000" -> "1.5" USDC (6 decimals)
34
+ */
35
+ export function fromAtomicUnits(amount: string, decimals: number = 6): string {
36
+ const value = BigInt(amount);
37
+ const divisor = BigInt(10 ** decimals);
38
+ const whole = (value / divisor).toString();
39
+ const fractional = (value % divisor)
40
+ .toString()
41
+ .padStart(decimals, "0")
42
+ .replace(/0+$/, "");
43
+
44
+ if (fractional.length === 0) {
45
+ return whole;
46
+ }
47
+ return `${whole}.${fractional}`;
48
+ }
49
+
50
+ /**
51
+ * Parse signature from hex string to components
52
+ * Returns { r, s, v } where v is 27 or 28
53
+ */
54
+ export function parseSignature(signature: Hex): { r: Hex; s: Hex; v: number } {
55
+ const hex = signature.startsWith("0x") ? signature.slice(2) : signature;
56
+ const r = `0x${hex.slice(0, 64)}` as Hex;
57
+ const s = `0x${hex.slice(64, 128)}` as Hex;
58
+ const v = parseInt(hex.slice(128, 130) || "1c", 16);
59
+ return { r, s, v };
60
+ }
61
+
62
+ /**
63
+ * Combine signature components into hex string
64
+ */
65
+ export function combineSignature(r: Hex, s: Hex, v: number): Hex {
66
+ const rHex = r.startsWith("0x") ? r.slice(2) : r;
67
+ const sHex = s.startsWith("0x") ? s.slice(2) : s;
68
+ const vHex = v.toString(16).padStart(2, "0");
69
+ return `0x${rHex}${sHex}${vHex}` as Hex;
70
+ }
71
+
72
+ /**
73
+ * Convert CAIP-2 chain ID to network name
74
+ * e.g., "eip155:8453" -> "base"
75
+ */
76
+ export function caip2ToNetwork(caip2Id: string): string {
77
+ const match = caip2Id.match(/^eip155:(\d+)$/);
78
+ if (!match) {
79
+ return caip2Id;
80
+ }
81
+
82
+ const chainId = parseInt(match[1], 10);
83
+ const chainIdToNetwork: Record<number, string> = {
84
+ 1: "ethereum",
85
+ 8453: "base",
86
+ 84532: "base-sepolia",
87
+ 137: "polygon",
88
+ 42161: "arbitrum",
89
+ 421614: "arbitrum-sepolia",
90
+ 10: "optimism",
91
+ 11155420: "optimism-sepolia",
92
+ 11155111: "ethereum-sepolia",
93
+ };
94
+
95
+ return chainIdToNetwork[chainId] || caip2Id;
96
+ }
97
+
98
+ /**
99
+ * Convert network name to CAIP-2 chain ID
100
+ * e.g., "base" -> "eip155:8453"
101
+ */
102
+ export function networkToCaip2(network: string): string {
103
+ const networkToChainId: Record<string, number> = {
104
+ ethereum: 1,
105
+ base: 8453,
106
+ "base-sepolia": 84532,
107
+ polygon: 137,
108
+ arbitrum: 42161,
109
+ "arbitrum-sepolia": 421614,
110
+ optimism: 10,
111
+ "optimism-sepolia": 11155420,
112
+ "ethereum-sepolia": 11155111,
113
+ };
114
+
115
+ const chainId = networkToChainId[network];
116
+ if (chainId) {
117
+ return `eip155:${chainId}`;
118
+ }
119
+
120
+ // If already in CAIP-2 format, return as-is
121
+ if (network.startsWith("eip155:")) {
122
+ return network;
123
+ }
124
+
125
+ return `eip155:0`;
126
+ }
127
+
128
+ /**
129
+ * Get current Unix timestamp in seconds
130
+ */
131
+ export function getCurrentTimestamp(): number {
132
+ return Math.floor(Date.now() / 1000);
133
+ }
134
+
135
+ /**
136
+ * Validate Ethereum address
137
+ */
138
+ export function isValidAddress(address: string): address is Address {
139
+ return /^0x[a-fA-F0-9]{40}$/.test(address);
140
+ }
141
+
142
+ /**
143
+ * Normalize address to checksum format (basic lowercase)
144
+ */
145
+ export function normalizeAddress(address: string): Address {
146
+ return address.toLowerCase() as Address;
147
+ }