@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 +36 -0
- package/src/core.ts +292 -0
- package/src/index.ts +79 -0
- package/src/middleware-config.ts +279 -0
- package/src/payment-utils.ts +183 -0
- package/src/routes.ts +139 -0
- package/src/types.ts +44 -0
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
|
+
}
|