@armory-sh/middleware-express-v4 0.1.1

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/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@armory-sh/middleware-express-v4",
3
+ "version": "0.1.1",
4
+ "author": "Sawyer Cutler <sawyer@dirtroad.dev>",
5
+ "module": "index.ts",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.ts",
13
+ "default": "./src/index.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/thegreataxios/armory.git",
25
+ "directory": "packages/middleware-express-v4"
26
+ },
27
+ "peerDependencies": {
28
+ "express": "^4"
29
+ },
30
+ "dependencies": {
31
+ "@armory-sh/base": "0.2.20"
32
+ },
33
+ "scripts": {
34
+ "test": "bun test"
35
+ }
36
+ }
package/src/core.ts ADDED
@@ -0,0 +1,292 @@
1
+ import type {
2
+ PaymentRequirements,
3
+ PaymentRequirementsV1,
4
+ PaymentRequirementsV2,
5
+ PaymentPayloadV1,
6
+ SettlementResponseV1,
7
+ SettlementResponseV2,
8
+ PayToV2,
9
+ X402SettlementResponse,
10
+ X402PaymentRequiredV1,
11
+ X402PaymentRequirementsV1,
12
+ } from "@armory-sh/base";
13
+ import {
14
+ getNetworkConfig,
15
+ getNetworkByChainId,
16
+ encodeSettlementResponse,
17
+ encodeX402PaymentRequiredV1,
18
+ normalizeNetworkName,
19
+ } from "@armory-sh/base";
20
+ import type {
21
+ MiddlewareConfig,
22
+ FacilitatorConfig,
23
+ FacilitatorVerifyResult,
24
+ FacilitatorSettleResult,
25
+ HttpRequest,
26
+ } from "./types";
27
+
28
+ /**
29
+ * Convert eip155 format to slug, or slug to slug (no-op)
30
+ * Handles both "eip155:84532" and "base-sepolia" inputs
31
+ */
32
+ const toSlug = (network: string | number): string => {
33
+ if (typeof network === "number") {
34
+ const net = getNetworkByChainId(network);
35
+ if (!net) throw new Error(`No network found for chainId: ${network}`);
36
+ return normalizeNetworkName(net.name);
37
+ }
38
+
39
+ // Handle eip155 format input
40
+ if (network.startsWith("eip155:")) {
41
+ const chainIdStr = network.split(":")[1];
42
+ if (!chainIdStr) throw new Error(`Invalid eip155 format: ${network}`);
43
+ const chainId = parseInt(chainIdStr, 10);
44
+ const net = getNetworkByChainId(chainId);
45
+ if (!net) throw new Error(`No network found for chainId: ${chainId}`);
46
+ return normalizeNetworkName(net.name);
47
+ }
48
+
49
+ return normalizeNetworkName(network);
50
+ };
51
+
52
+ /**
53
+ * Convert slug to eip155 format
54
+ */
55
+ const toEip155 = (network: string | number): string => {
56
+ if (typeof network === "number") {
57
+ const net = getNetworkByChainId(network);
58
+ if (!net) throw new Error(`No network found for chainId: ${network}`);
59
+ return net.caip2Id;
60
+ }
61
+
62
+ // Already in eip155 format
63
+ if (network.startsWith("eip155:")) {
64
+ const net = getNetworkConfig(network);
65
+ if (!net) throw new Error(`No network found for: ${network}`);
66
+ return net.caip2Id;
67
+ }
68
+
69
+ // Slug input - convert to eip155
70
+ const slug = normalizeNetworkName(network);
71
+ const net = getNetworkConfig(slug);
72
+ if (!net) throw new Error(`No network found for: ${slug}`);
73
+ return net.caip2Id;
74
+ };
75
+
76
+ const getNetworkName = (network: string | number): string => toSlug(network);
77
+
78
+ const getChainId = (network: string | number): string => toEip155(network);
79
+
80
+ const createV1Requirements = (
81
+ config: MiddlewareConfig,
82
+ expiry: number
83
+ ): PaymentRequirementsV1 => {
84
+ const networkName = getNetworkName(config.network);
85
+ const network = getNetworkConfig(networkName);
86
+ if (!network) throw new Error(`Unsupported network: ${networkName}`);
87
+
88
+ return {
89
+ amount: config.amount,
90
+ network: networkName,
91
+ contractAddress: network.usdcAddress,
92
+ payTo: config.payTo as string,
93
+ expiry,
94
+ };
95
+ };
96
+
97
+ const createV2Requirements = (
98
+ config: MiddlewareConfig,
99
+ _expiry: number
100
+ ): PaymentRequirementsV2 => {
101
+ const networkName = getNetworkName(config.network);
102
+ const network = getNetworkConfig(networkName);
103
+ if (!network) throw new Error(`Unsupported network: ${networkName}`);
104
+
105
+ // Extract address from payTo - should be a valid Ethereum address
106
+ const payToAddress = typeof config.payTo === "string" && config.payTo.startsWith("0x")
107
+ ? config.payTo as `0x${string}`
108
+ : network.usdcAddress as `0x${string}`;
109
+
110
+ return {
111
+ scheme: "exact",
112
+ network: getChainId(config.network) as `eip155:${string}`,
113
+ amount: config.amount,
114
+ asset: network.usdcAddress as `0x${string}`,
115
+ payTo: payToAddress,
116
+ maxTimeoutSeconds: 300,
117
+ };
118
+ };
119
+
120
+ export const createPaymentRequirements = (
121
+ config: MiddlewareConfig,
122
+ version: 1 | 2 = 1
123
+ ): PaymentRequirements => {
124
+ const networkName = getNetworkName(config.network);
125
+ const network = getNetworkConfig(networkName);
126
+ if (!network) throw new Error(`Unsupported network: ${networkName}`);
127
+ const expiry = Math.floor(Date.now() / 1000) + 3600;
128
+
129
+ return version === 1 ? createV1Requirements(config, expiry) : createV2Requirements(config, expiry);
130
+ };
131
+
132
+ const findHeaderValue = (
133
+ headers: Record<string, string | string[] | undefined>,
134
+ name: string
135
+ ): string | undefined => {
136
+ const value = headers[name];
137
+ if (typeof value === "string") return value;
138
+ if (Array.isArray(value) && value.length > 0) return value[0];
139
+
140
+ const lowerName = name.toLowerCase();
141
+ for (const [key, val] of Object.entries(headers)) {
142
+ if (key.toLowerCase() === lowerName) {
143
+ if (typeof val === "string") return val;
144
+ if (Array.isArray(val) && val.length > 0) return val[0];
145
+ }
146
+ }
147
+ return undefined;
148
+ };
149
+
150
+ const parseHeader = (header: string): Record<string, unknown> | null => {
151
+ try {
152
+ if (header.startsWith("{")) return JSON.parse(header);
153
+ return JSON.parse(atob(header));
154
+ } catch {
155
+ return null;
156
+ }
157
+ };
158
+
159
+ const extractPaymentPayload = (request: HttpRequest): Record<string, unknown> | null => {
160
+ const v1Header = findHeaderValue(request.headers, "X-PAYMENT");
161
+ if (v1Header) return parseHeader(v1Header);
162
+
163
+ const v2Header = findHeaderValue(request.headers, "PAYMENT-SIGNATURE");
164
+ if (v2Header) return parseHeader(v2Header);
165
+
166
+ return null;
167
+ };
168
+
169
+ const postFacilitator = async (
170
+ url: string,
171
+ headers: Record<string, string>,
172
+ body: unknown
173
+ ): Promise<unknown> => {
174
+ const response = await fetch(url, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json", ...headers },
177
+ body: JSON.stringify(body),
178
+ });
179
+
180
+ if (!response.ok) {
181
+ const error = await response.text();
182
+ throw new Error(error || `Request failed: ${response.status}`);
183
+ }
184
+
185
+ return response.json();
186
+ };
187
+
188
+ export const verifyWithFacilitator = async (
189
+ request: HttpRequest,
190
+ facilitator: FacilitatorConfig
191
+ ): Promise<FacilitatorVerifyResult> => {
192
+ const payload = extractPaymentPayload(request);
193
+ if (!payload) {
194
+ return { success: false, error: "No payment payload found in request headers" };
195
+ }
196
+
197
+ try {
198
+ const url = new URL("/verify", facilitator.url);
199
+ const headers = facilitator.createHeaders?.() ?? {};
200
+ const data = await postFacilitator(url.toString(), headers, { payload, headers: request.headers });
201
+
202
+ return {
203
+ success: true,
204
+ payerAddress: (data as { payerAddress?: string }).payerAddress,
205
+ balance: (data as { balance?: string }).balance,
206
+ requiredAmount: (data as { requiredAmount?: string }).requiredAmount,
207
+ };
208
+ } catch (error) {
209
+ return {
210
+ success: false,
211
+ error: error instanceof Error ? error.message : "Unknown verification error",
212
+ };
213
+ }
214
+ };
215
+
216
+ export const settleWithFacilitator = async (
217
+ request: HttpRequest,
218
+ facilitator: FacilitatorConfig
219
+ ): Promise<FacilitatorSettleResult> => {
220
+ const payload = extractPaymentPayload(request);
221
+ if (!payload) {
222
+ return { success: false, error: "No payment payload found in request headers" };
223
+ }
224
+
225
+ try {
226
+ const url = new URL("/settle", facilitator.url);
227
+ const headers = facilitator.createHeaders?.() ?? {};
228
+ const data = await postFacilitator(url.toString(), headers, { payload, headers: request.headers });
229
+
230
+ return {
231
+ success: true,
232
+ txHash: (data as { txHash?: string }).txHash,
233
+ };
234
+ } catch (error) {
235
+ return {
236
+ success: false,
237
+ error: error instanceof Error ? error.message : "Unknown settlement error",
238
+ };
239
+ }
240
+ };
241
+
242
+ const encode = (data: unknown): string => btoa(JSON.stringify(data));
243
+
244
+ export const createPaymentRequiredHeaders = (
245
+ requirements: PaymentRequirements,
246
+ version: 1 | 2
247
+ ): Record<string, string> => {
248
+ if (version === 1) {
249
+ // For V1, wrap in X402PaymentRequiredV1 format and encode
250
+ const v1Requirements: X402PaymentRequiredV1 = {
251
+ x402Version: 1,
252
+ error: "Payment required",
253
+ accepts: [requirements as unknown as X402PaymentRequirementsV1],
254
+ };
255
+ return { "X-PAYMENT-REQUIRED": encodeX402PaymentRequiredV1(v1Requirements) };
256
+ }
257
+ // For V2/x402 - base64 encode the JSON
258
+ return { "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(requirements)).toString("base64") };
259
+ };
260
+
261
+ /**
262
+ * Accepts both X402SettlementResponse and legacy SettlementResponseV1/V2
263
+ * For V1, manually constructs the header. For V2/x402, uses the x402 encoder.
264
+ */
265
+ export const createSettlementHeaders = (
266
+ response: X402SettlementResponse | SettlementResponseV1 | SettlementResponseV2,
267
+ version: 1 | 2
268
+ ): Record<string, string> => {
269
+ // Extract success and txHash from any response format
270
+ const isSuccess = "success" in response ? response.success : false;
271
+ const txHash = "transaction" in response
272
+ ? response.transaction
273
+ : "txHash" in response
274
+ ? response.txHash
275
+ : "";
276
+
277
+ if (version === 1) {
278
+ // V1 settlement response
279
+ const settlementJson = JSON.stringify({
280
+ success: isSuccess,
281
+ transaction: txHash,
282
+ });
283
+ return { "X-PAYMENT-RESPONSE": Buffer.from(settlementJson).toString("base64") };
284
+ }
285
+
286
+ // V2/x402 settlement response
287
+ const settlementJson = JSON.stringify({
288
+ success: isSuccess,
289
+ transaction: txHash,
290
+ });
291
+ return { "PAYMENT-RESPONSE": Buffer.from(settlementJson).toString("base64") };
292
+ };
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { Request, Response, NextFunction } from "express";
2
+ import type {
3
+ X402PaymentPayload,
4
+ X402PaymentRequirements,
5
+ } from "@armory-sh/base";
6
+ import type {
7
+ PaymentPayload,
8
+ PaymentRequirements,
9
+ } from "./payment-utils";
10
+ import {
11
+ PAYMENT_HEADERS,
12
+ encodeRequirements,
13
+ decodePayload,
14
+ verifyPaymentWithRetry,
15
+ extractPayerAddress,
16
+ } from "./payment-utils";
17
+
18
+ export interface PaymentMiddlewareConfig {
19
+ requirements: X402PaymentRequirements;
20
+ facilitatorUrl?: string;
21
+ }
22
+
23
+ export interface AugmentedRequest extends Request {
24
+ payment?: {
25
+ payload: X402PaymentPayload;
26
+ payerAddress: string;
27
+ verified: boolean;
28
+ };
29
+ }
30
+
31
+ const sendError = (
32
+ res: Response,
33
+ status: number,
34
+ headers: Record<string, string>,
35
+ body: unknown
36
+ ): void => {
37
+ res.status(status);
38
+ Object.entries(headers).forEach(([k, v]) => res.setHeader(k, v));
39
+ res.json(body);
40
+ };
41
+
42
+ export const paymentMiddleware = (config: PaymentMiddlewareConfig) => {
43
+ const { requirements, facilitatorUrl } = config;
44
+
45
+ return async (req: AugmentedRequest, res: Response, next: NextFunction): Promise<void> => {
46
+ try {
47
+ const paymentHeader = req.headers[PAYMENT_HEADERS.PAYMENT.toLowerCase()] as string | undefined;
48
+
49
+ if (!paymentHeader) {
50
+ sendError(res, 402, { [PAYMENT_HEADERS.REQUIRED]: encodeRequirements(requirements), "Content-Type": "application/json" }, { error: "Payment required", accepts: [requirements] });
51
+ return;
52
+ }
53
+
54
+ let payload: X402PaymentPayload;
55
+ try {
56
+ ({ payload } = decodePayload(paymentHeader) as { payload: X402PaymentPayload });
57
+ } catch (error) {
58
+ sendError(res, 400, {}, { error: "Invalid payment payload", message: error instanceof Error ? error.message : "Unknown error" });
59
+ return;
60
+ }
61
+
62
+ const result = await verifyPaymentWithRetry(payload, requirements, facilitatorUrl);
63
+ if (!result.success) {
64
+ sendError(res, 402, { [PAYMENT_HEADERS.RESPONSE]: JSON.stringify({ status: "verified", payerAddress: result.payerAddress, version: 2 }) }, { error: result.error });
65
+ return;
66
+ }
67
+
68
+ const payerAddress = result.payerAddress!;
69
+
70
+ req.payment = { payload, payerAddress, verified: true };
71
+ res.setHeader(PAYMENT_HEADERS.RESPONSE, JSON.stringify({ status: "verified", payerAddress, version: 2 }));
72
+ next();
73
+ } catch (error) {
74
+ sendError(res, 500, {}, { error: "Payment middleware error", message: error instanceof Error ? error.message : "Unknown error" });
75
+ }
76
+ };
77
+ };
78
+
79
+ export { routeAwarePaymentMiddleware, type RouteAwarePaymentMiddlewareConfig, type PaymentMiddlewareConfigEntry } from "./routes";
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Simple one-line middleware API for Armory merchants
3
+ * Focus on DX/UX - "everything just magically works"
4
+ */
5
+
6
+ import type {
7
+ Address,
8
+ PaymentRequirements,
9
+ PaymentRequirementsV1,
10
+ PaymentRequirementsV2,
11
+ } from "@armory-sh/base";
12
+ import type {
13
+ NetworkId,
14
+ TokenId,
15
+ FacilitatorConfig,
16
+ AcceptPaymentOptions,
17
+ ResolvedPaymentConfig,
18
+ ValidationError,
19
+ PricingConfig,
20
+ } from "@armory-sh/base";
21
+ import {
22
+ resolveNetwork,
23
+ resolveToken,
24
+ validateAcceptConfig,
25
+ isValidationError,
26
+ getNetworkConfig,
27
+ getNetworkByChainId,
28
+ normalizeNetworkName,
29
+ } from "@armory-sh/base";
30
+ import {
31
+ createPaymentRequirements,
32
+ createPaymentRequiredHeaders,
33
+ } from "./core";
34
+ import type { MiddlewareConfig, FacilitatorConfig as CoreFacilitatorConfig } from "./types";
35
+
36
+ // ═══════════════════════════════════════════════════════════════
37
+ // Simple Middleware Configuration
38
+ // ═══════════════════════════════════════════════════════════════
39
+
40
+ /**
41
+ * Simple configuration for acceptPaymentsViaArmory middleware
42
+ */
43
+ export interface SimpleMiddlewareConfig {
44
+ /** Address to receive payments */
45
+ payTo: Address;
46
+ /** Default amount to charge (default: "1.0") */
47
+ amount?: string;
48
+ /** Payment acceptance options */
49
+ accept?: AcceptPaymentOptions;
50
+ /** Fallback facilitator URL (if not using accept.facilitators) */
51
+ facilitatorUrl?: string;
52
+ /** Per-network/token/facilitator pricing overrides */
53
+ pricing?: PricingConfig[];
54
+ }
55
+
56
+ /**
57
+ * Resolved middleware configuration with all options validated
58
+ */
59
+ export interface ResolvedMiddlewareConfig {
60
+ /** All valid payment configurations (network/token combinations) */
61
+ configs: ResolvedPaymentConfigWithPricing[];
62
+ /** Protocol version */
63
+ version: 1 | 2 | "auto";
64
+ /** Facilitator configs */
65
+ facilitators: CoreFacilitatorConfig[];
66
+ }
67
+
68
+ /** Extended config with pricing info */
69
+ export interface ResolvedPaymentConfigWithPricing extends ResolvedPaymentConfig {
70
+ /** Facilitator URL (for per-facilitator pricing) */
71
+ facilitatorUrl?: string;
72
+ /** Pricing config entry (if any) */
73
+ pricing?: PricingConfig;
74
+ }
75
+
76
+ // ═══════════════════════════════════════════════════════════════
77
+ // Configuration Resolution
78
+ // ═══════════════════════════════════════════════════════════════
79
+
80
+ /**
81
+ * Find matching pricing config for a network/token/facilitator combination
82
+ */
83
+ const findPricingConfig = (
84
+ pricing: PricingConfig[] | undefined,
85
+ network: string,
86
+ token: string,
87
+ facilitatorUrl: string
88
+ ): PricingConfig | undefined => {
89
+ if (!pricing) return undefined;
90
+
91
+ // First try exact match with facilitator
92
+ const withFacilitator = pricing.find(
93
+ p =>
94
+ p.network === network &&
95
+ p.token === token &&
96
+ p.facilitator === facilitatorUrl
97
+ );
98
+ if (withFacilitator) return withFacilitator;
99
+
100
+ // Then try network/token match (any facilitator)
101
+ const withNetworkToken = pricing.find(
102
+ p =>
103
+ p.network === network &&
104
+ p.token === token &&
105
+ !p.facilitator
106
+ );
107
+ if (withNetworkToken) return withNetworkToken;
108
+
109
+ // Then try network-only match
110
+ const networkOnly = pricing.find(
111
+ p => p.network === network && !p.token && !p.facilitator
112
+ );
113
+ if (networkOnly) return networkOnly;
114
+
115
+ return undefined;
116
+ };
117
+
118
+ /**
119
+ * Resolve simple middleware config to full config
120
+ */
121
+ export const resolveMiddlewareConfig = (
122
+ config: SimpleMiddlewareConfig
123
+ ): ResolvedMiddlewareConfig | ValidationError => {
124
+ const { payTo, amount = "1.0", accept = {}, facilitatorUrl, pricing } = config;
125
+
126
+ // If using legacy facilitatorUrl, convert to AcceptPaymentOptions
127
+ const acceptOptions: AcceptPaymentOptions = facilitatorUrl
128
+ ? {
129
+ ...accept,
130
+ facilitators: accept.facilitators
131
+ ? [...(Array.isArray(accept.facilitators) ? accept.facilitators : [accept.facilitators]), { url: facilitatorUrl }]
132
+ : { url: facilitatorUrl },
133
+ }
134
+ : accept;
135
+
136
+ const result = validateAcceptConfig(acceptOptions, payTo, amount);
137
+ if (!result.success) {
138
+ return result.error;
139
+ }
140
+
141
+ const facilitatorConfigs: CoreFacilitatorConfig[] = result.config[0]?.facilitators.map((f) => ({
142
+ url: f.url,
143
+ createHeaders: f.input.headers,
144
+ })) ?? [];
145
+
146
+ const enrichedConfigs: ResolvedPaymentConfigWithPricing[] = result.config.map((c) => {
147
+ const networkName = normalizeNetworkName(c.network.config.name);
148
+ const tokenSymbol = c.token.config.symbol;
149
+
150
+ const facilitatorPricing: { url: string; pricing?: PricingConfig }[] = c.facilitators.map((f) => {
151
+ const pricingConfig = findPricingConfig(pricing, networkName, tokenSymbol, f.url);
152
+ return { url: f.url, pricing: pricingConfig };
153
+ });
154
+
155
+ const defaultPricing = findPricingConfig(pricing, networkName, tokenSymbol, "");
156
+
157
+ return {
158
+ ...c,
159
+ amount: defaultPricing?.amount ?? c.amount,
160
+ facilitatorUrl: facilitatorPricing[0]?.url,
161
+ pricing: defaultPricing,
162
+ };
163
+ });
164
+
165
+ return {
166
+ configs: enrichedConfigs,
167
+ version: acceptOptions.version ?? "auto",
168
+ facilitators: facilitatorConfigs,
169
+ };
170
+ };
171
+
172
+ /**
173
+ * Get payment requirements for a specific network/token combination
174
+ */
175
+ export const getRequirements = (
176
+ config: ResolvedMiddlewareConfig,
177
+ network: NetworkId,
178
+ token: TokenId
179
+ ): PaymentRequirements | ValidationError => {
180
+ // Find matching config
181
+ const resolvedNetwork = resolveNetwork(network);
182
+ if (isValidationError(resolvedNetwork)) {
183
+ return resolvedNetwork;
184
+ }
185
+
186
+ const resolvedToken = resolveToken(token, resolvedNetwork);
187
+ if (isValidationError(resolvedToken)) {
188
+ return resolvedToken;
189
+ }
190
+
191
+ const matchingConfig = config.configs.find(
192
+ (c) =>
193
+ c.network.config.chainId === resolvedNetwork.config.chainId &&
194
+ c.token.config.contractAddress.toLowerCase() === resolvedToken.config.contractAddress.toLowerCase()
195
+ );
196
+
197
+ if (!matchingConfig) {
198
+ return {
199
+ code: "TOKEN_NOT_ON_NETWORK",
200
+ message: `No configuration found for network "${network}" with token "${token}"`,
201
+ } as ValidationError;
202
+ }
203
+
204
+ // Determine version
205
+ const version = config.version === "auto" ? 2 : config.version;
206
+
207
+ return createPaymentRequirements(
208
+ {
209
+ payTo: matchingConfig.payTo,
210
+ network: normalizeNetworkName(matchingConfig.network.config.name),
211
+ amount: matchingConfig.amount,
212
+ facilitator: config.facilitators[0],
213
+ },
214
+ version
215
+ );
216
+ };
217
+
218
+ // ═══════════════════════════════════════════════════════════════
219
+ // Helper to get default config from first result
220
+ // ═══════════════════════════════════════════════════════════════
221
+
222
+ /**
223
+ * Get primary/default middleware config for legacy middlewares
224
+ */
225
+ export const getPrimaryConfig = (resolved: ResolvedMiddlewareConfig): MiddlewareConfig => {
226
+ const primary = resolved.configs[0];
227
+ if (!primary) {
228
+ throw new Error("No valid payment configurations found");
229
+ }
230
+
231
+ return {
232
+ payTo: primary.payTo,
233
+ network: normalizeNetworkName(primary.network.config.name),
234
+ amount: primary.amount,
235
+ facilitator: resolved.facilitators[0],
236
+ settlementMode: "verify",
237
+ };
238
+ };
239
+
240
+ /**
241
+ * Get all supported networks from config
242
+ */
243
+ export const getSupportedNetworks = (config: ResolvedMiddlewareConfig): string[] => {
244
+ const networks = new Set(config.configs.map((c) => normalizeNetworkName(c.network.config.name)));
245
+ return Array.from(networks);
246
+ };
247
+
248
+ /**
249
+ * Get all supported tokens from config
250
+ */
251
+ export const getSupportedTokens = (config: ResolvedMiddlewareConfig): string[] => {
252
+ const tokens = new Set(config.configs.map((c) => c.token.config.symbol));
253
+ return Array.from(tokens);
254
+ };
255
+
256
+ /**
257
+ * Check if a network/token combination is supported
258
+ */
259
+ export const isSupported = (
260
+ config: ResolvedMiddlewareConfig,
261
+ network: NetworkId,
262
+ token: TokenId
263
+ ): boolean => {
264
+ const resolvedNetwork = resolveNetwork(network);
265
+ if (isValidationError(resolvedNetwork)) {
266
+ return false;
267
+ }
268
+
269
+ const resolvedToken = resolveToken(token, resolvedNetwork);
270
+ if (isValidationError(resolvedToken)) {
271
+ return false;
272
+ }
273
+
274
+ return config.configs.some(
275
+ (c) =>
276
+ c.network.config.chainId === resolvedNetwork.config.chainId &&
277
+ c.token.config.contractAddress.toLowerCase() === resolvedToken.config.contractAddress.toLowerCase()
278
+ );
279
+ };
@@ -0,0 +1,183 @@
1
+ import type {
2
+ X402PaymentPayload,
3
+ X402PaymentRequirements,
4
+ PaymentRequirements,
5
+ } from "@armory-sh/base";
6
+ import { extractPaymentFromHeaders, PAYMENT_SIGNATURE_HEADER } from "@armory-sh/base";
7
+
8
+ // Legacy V2 payload type for backward compatibility
9
+ export interface LegacyPaymentPayloadV2 {
10
+ to: string;
11
+ from: string;
12
+ amount: string;
13
+ chainId: string;
14
+ assetId: string;
15
+ nonce: string;
16
+ expiry: number;
17
+ signature: string; // 0x-prefixed hex signature
18
+ }
19
+
20
+ // Union type for V2 payload formats
21
+ export type AnyPaymentPayload = X402PaymentPayload | LegacyPaymentPayloadV2;
22
+
23
+ // Re-export types for use in index.ts
24
+ export type PaymentPayload = AnyPaymentPayload;
25
+ export type { X402PaymentRequirements as PaymentRequirements } from "@armory-sh/base";
26
+
27
+ export interface PaymentVerificationResult {
28
+ success: boolean;
29
+ payload?: AnyPaymentPayload;
30
+ payerAddress?: string;
31
+ error?: string;
32
+ }
33
+
34
+ // V2 hardcoded headers
35
+ export const PAYMENT_HEADERS = {
36
+ PAYMENT: "PAYMENT-SIGNATURE",
37
+ REQUIRED: "PAYMENT-REQUIRED",
38
+ RESPONSE: "PAYMENT-RESPONSE",
39
+ } as const;
40
+
41
+ export const encodeRequirements = (requirements: X402PaymentRequirements): string =>
42
+ JSON.stringify(requirements);
43
+
44
+ /**
45
+ * Detect if a payload is legacy V2 format
46
+ */
47
+ function isLegacyV2(payload: unknown): payload is LegacyPaymentPayloadV2 {
48
+ return (
49
+ typeof payload === "object" &&
50
+ payload !== null &&
51
+ "chainId" in payload &&
52
+ "assetId" in payload &&
53
+ "signature" in payload &&
54
+ typeof (payload as LegacyPaymentPayloadV2).signature === "string"
55
+ );
56
+ }
57
+
58
+ export const decodePayload = (
59
+ headerValue: string
60
+ ): { payload: AnyPaymentPayload } => {
61
+ // Try to parse as JSON first
62
+ let parsed: unknown;
63
+ let isJsonString = false;
64
+ try {
65
+ if (headerValue.startsWith("{")) {
66
+ parsed = JSON.parse(headerValue);
67
+ isJsonString = true;
68
+ } else {
69
+ parsed = JSON.parse(atob(headerValue));
70
+ }
71
+ } catch {
72
+ throw new Error("Invalid payment payload");
73
+ }
74
+
75
+ const base64Value = isJsonString
76
+ ? Buffer.from(headerValue).toString("base64")
77
+ : headerValue;
78
+ const headers = new Headers();
79
+ headers.set(PAYMENT_SIGNATURE_HEADER, base64Value);
80
+ const x402Payload = extractPaymentFromHeaders(headers);
81
+ if (x402Payload) {
82
+ return { payload: x402Payload };
83
+ }
84
+
85
+ if (isLegacyV2(parsed)) {
86
+ return { payload: parsed };
87
+ }
88
+
89
+ throw new Error("Unrecognized payment payload format");
90
+ };
91
+
92
+ export const createVerificationError = (
93
+ message: string,
94
+ details?: unknown
95
+ ): string => JSON.stringify({ error: message, details });
96
+
97
+ export const verifyWithFacilitator = async (
98
+ facilitatorUrl: string,
99
+ payload: AnyPaymentPayload,
100
+ requirements: X402PaymentRequirements
101
+ ): Promise<PaymentVerificationResult> => {
102
+ try {
103
+ const response = await fetch(`${facilitatorUrl}/verify`, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify({ payload, requirements }),
107
+ });
108
+
109
+ if (!response.ok) {
110
+ const error = await response.json();
111
+ return { success: false, error: JSON.stringify(error) };
112
+ }
113
+
114
+ const result = await response.json() as { success: boolean; error?: string; payerAddress?: string };
115
+ if (!result.success) {
116
+ return { success: false, error: result.error ?? "Verification failed" };
117
+ }
118
+
119
+ return { success: true, payerAddress: result.payerAddress ?? "" };
120
+ } catch (error) {
121
+ return {
122
+ success: false,
123
+ error: error instanceof Error ? error.message : "Unknown facilitator error",
124
+ };
125
+ }
126
+ };
127
+
128
+ export const verifyLocally = async (
129
+ payload: AnyPaymentPayload,
130
+ requirements: X402PaymentRequirements
131
+ ): Promise<PaymentVerificationResult> => {
132
+ // For legacy format, we'd need to convert to x402 format first
133
+ if (isLegacyV2(payload)) {
134
+ return {
135
+ success: false,
136
+ error: "Local verification not supported for legacy payload format. Use a facilitator.",
137
+ };
138
+ }
139
+
140
+ // For x402 format, verify locally
141
+ return {
142
+ success: true,
143
+ payerAddress: (payload as X402PaymentPayload).payload.authorization.from,
144
+ };
145
+ };
146
+
147
+ export const verifyPaymentWithRetry = async (
148
+ payload: AnyPaymentPayload,
149
+ requirements: X402PaymentRequirements,
150
+ facilitatorUrl?: string
151
+ ): Promise<PaymentVerificationResult> =>
152
+ facilitatorUrl
153
+ ? verifyWithFacilitator(facilitatorUrl, payload, requirements)
154
+ : verifyLocally(payload, requirements);
155
+
156
+ /**
157
+ * Extract payer address from various payload formats
158
+ */
159
+ export const extractPayerAddress = (payload: AnyPaymentPayload): string => {
160
+ // x402 format
161
+ if ("payload" in payload) {
162
+ const x402Payload = payload as X402PaymentPayload;
163
+ if ("authorization" in x402Payload.payload) {
164
+ return x402Payload.payload.authorization.from;
165
+ }
166
+ }
167
+
168
+ // Legacy V2 format
169
+ if ("from" in payload && typeof payload.from === "string") {
170
+ return payload.from;
171
+ }
172
+
173
+ throw new Error("Unable to extract payer address from payload");
174
+ };
175
+
176
+ export const createResponseHeaders = (
177
+ payerAddress: string
178
+ ): Record<string, string> => ({
179
+ [PAYMENT_HEADERS.RESPONSE]: JSON.stringify({
180
+ status: "verified",
181
+ payerAddress,
182
+ }),
183
+ });
package/src/routes.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type { Request, Response, NextFunction } from "express";
2
+ import type {
3
+ X402PaymentPayload,
4
+ X402PaymentRequirements,
5
+ } from "@armory-sh/base";
6
+ import type {
7
+ PaymentPayload,
8
+ PaymentRequirements,
9
+ } from "./payment-utils";
10
+ import {
11
+ PAYMENT_HEADERS,
12
+ encodeRequirements,
13
+ decodePayload,
14
+ verifyPaymentWithRetry,
15
+ extractPayerAddress,
16
+ } from "./payment-utils";
17
+ import { matchRoute, validateRouteConfig, type RouteValidationError } from "@armory-sh/base";
18
+
19
+ export interface RouteAwarePaymentMiddlewareConfig {
20
+ route?: string;
21
+ routes?: string[];
22
+ perRoute?: Record<string, PaymentMiddlewareConfigEntry>;
23
+ }
24
+
25
+ export interface PaymentMiddlewareConfigEntry {
26
+ requirements: X402PaymentRequirements;
27
+ facilitatorUrl?: string;
28
+ }
29
+
30
+ export interface AugmentedRequest extends Request {
31
+ payment?: {
32
+ payload: X402PaymentPayload;
33
+ payerAddress: string;
34
+ verified: boolean;
35
+ route?: string;
36
+ };
37
+ }
38
+
39
+ interface ResolvedRouteConfig {
40
+ pattern: string;
41
+ config: PaymentMiddlewareConfigEntry;
42
+ }
43
+
44
+ const resolveRouteConfig = (
45
+ config: RouteAwarePaymentMiddlewareConfig
46
+ ): { routes: ResolvedRouteConfig[]; error?: RouteValidationError } => {
47
+ const validationError = validateRouteConfig(config);
48
+ if (validationError) {
49
+ return { routes: [], error: validationError };
50
+ }
51
+
52
+ const { route, routes, perRoute } = config;
53
+ const routePatterns = route ? [route] : routes || [];
54
+
55
+ const resolvedRoutes: ResolvedRouteConfig[] = [];
56
+
57
+ for (const pattern of routePatterns) {
58
+ const routeConfig = perRoute?.[pattern];
59
+ if (routeConfig) {
60
+ resolvedRoutes.push({
61
+ pattern,
62
+ config: routeConfig,
63
+ });
64
+ }
65
+ }
66
+
67
+ return { routes: resolvedRoutes };
68
+ };
69
+
70
+ const sendError = (
71
+ res: Response,
72
+ status: number,
73
+ headers: Record<string, string>,
74
+ body: unknown
75
+ ): void => {
76
+ res.status(status);
77
+ Object.entries(headers).forEach(([k, v]) => res.setHeader(k, v));
78
+ res.json(body);
79
+ };
80
+
81
+ export const routeAwarePaymentMiddleware = (
82
+ perRouteConfig: Record<string, PaymentMiddlewareConfigEntry>
83
+ ) => {
84
+ const config: RouteAwarePaymentMiddlewareConfig = {
85
+ routes: Object.keys(perRouteConfig),
86
+ perRoute: perRouteConfig,
87
+ };
88
+
89
+ const { routes: resolvedRoutes, error: configError } = resolveRouteConfig(config);
90
+
91
+ return async (req: AugmentedRequest, res: Response, next: NextFunction): Promise<void> => {
92
+ try {
93
+ if (configError) {
94
+ sendError(res, 500, {}, { error: "Payment middleware configuration error", details: configError.message });
95
+ return;
96
+ }
97
+
98
+ const path = req.path;
99
+ const matchedRoute = resolvedRoutes.find((r) => matchRoute(r.pattern, path));
100
+
101
+ if (!matchedRoute) {
102
+ next();
103
+ return;
104
+ }
105
+
106
+ const routeConfig = matchedRoute.config;
107
+ const { requirements, facilitatorUrl } = routeConfig;
108
+
109
+ const paymentHeader = req.headers[PAYMENT_HEADERS.PAYMENT.toLowerCase()] as string | undefined;
110
+
111
+ if (!paymentHeader) {
112
+ sendError(res, 402, { [PAYMENT_HEADERS.REQUIRED]: encodeRequirements(requirements), "Content-Type": "application/json" }, { error: "Payment required", accepts: [requirements] });
113
+ return;
114
+ }
115
+
116
+ let payload: X402PaymentPayload;
117
+ try {
118
+ ({ payload } = decodePayload(paymentHeader) as { payload: X402PaymentPayload });
119
+ } catch (error) {
120
+ sendError(res, 400, {}, { error: "Invalid payment payload", message: error instanceof Error ? error.message : "Unknown error" });
121
+ return;
122
+ }
123
+
124
+ const result = await verifyPaymentWithRetry(payload, requirements, facilitatorUrl);
125
+ if (!result.success) {
126
+ sendError(res, 402, { [PAYMENT_HEADERS.RESPONSE]: JSON.stringify({ status: "verified", payerAddress: result.payerAddress, version: 2 }) }, { error: result.error });
127
+ return;
128
+ }
129
+
130
+ const payerAddress = result.payerAddress!;
131
+
132
+ req.payment = { payload, payerAddress, verified: true, route: matchedRoute.pattern };
133
+ res.setHeader(PAYMENT_HEADERS.RESPONSE, JSON.stringify({ status: "verified", payerAddress, version: 2 }));
134
+ next();
135
+ } catch (error) {
136
+ sendError(res, 500, {}, { error: "Payment middleware error", message: error instanceof Error ? error.message : "Unknown error" });
137
+ }
138
+ };
139
+ };
package/src/types.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Address, CAIP2ChainId, CAIPAssetId } from "@armory-sh/base";
2
+
3
+ export interface FacilitatorConfig {
4
+ url: string;
5
+ createHeaders?: () => Record<string, string>;
6
+ }
7
+
8
+ export type SettlementMode = "verify" | "settle" | "async";
9
+ export type PayToAddress = Address | CAIP2ChainId | CAIPAssetId;
10
+
11
+ export interface MiddlewareConfig {
12
+ payTo: PayToAddress;
13
+ network: string | number;
14
+ amount: string;
15
+ facilitator?: FacilitatorConfig;
16
+ settlementMode?: SettlementMode;
17
+ }
18
+
19
+ export interface HttpRequest {
20
+ headers: Record<string, string | string[] | undefined>;
21
+ body?: unknown;
22
+ method?: string;
23
+ url?: string;
24
+ }
25
+
26
+ export interface HttpResponse {
27
+ status: number;
28
+ headers: Record<string, string>;
29
+ body?: unknown;
30
+ }
31
+
32
+ export interface FacilitatorVerifyResult {
33
+ success: boolean;
34
+ payerAddress?: string;
35
+ balance?: string;
36
+ requiredAmount?: string;
37
+ error?: string;
38
+ }
39
+
40
+ export interface FacilitatorSettleResult {
41
+ success: boolean;
42
+ txHash?: string;
43
+ error?: string;
44
+ }