create-mantle-facilitator 0.1.0
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/dist/index.js +62 -0
- package/dist/index.mjs +62 -0
- package/package.json +28 -0
- package/template/.env.example +17 -0
- package/template/README.md +25 -0
- package/template/package.json +25 -0
- package/template/src/blockchain.ts +29 -0
- package/template/src/config.ts +23 -0
- package/template/src/index.ts +21 -0
- package/template/src/routes/health.ts +33 -0
- package/template/src/routes/settle.ts +89 -0
- package/template/src/routes/supported.ts +18 -0
- package/template/src/routes/verify.ts +27 -0
- package/template/src/x402.ts +147 -0
- package/template/tsconfig.json +12 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import path from "path";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import fse from "fs-extra";
|
|
8
|
+
function log(msg) {
|
|
9
|
+
console.log(msg);
|
|
10
|
+
}
|
|
11
|
+
function fail(msg) {
|
|
12
|
+
console.error(msg);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
function cleanName(name) {
|
|
16
|
+
return name.replace(/[^a-zA-Z0-9-_]/g, "");
|
|
17
|
+
}
|
|
18
|
+
async function main() {
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const rawProjectName = args[0];
|
|
21
|
+
if (!rawProjectName) {
|
|
22
|
+
fail("Usage: create-mantle-facilitator <project-name>");
|
|
23
|
+
}
|
|
24
|
+
const projectName = cleanName(rawProjectName);
|
|
25
|
+
if (!projectName) {
|
|
26
|
+
fail("Invalid project name.");
|
|
27
|
+
}
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
const targetDir = path.join(cwd, projectName);
|
|
30
|
+
if (fs.existsSync(targetDir)) {
|
|
31
|
+
fail(`Target directory already exists: ${targetDir}`);
|
|
32
|
+
}
|
|
33
|
+
const templateDir = path.join(
|
|
34
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
35
|
+
"..",
|
|
36
|
+
"template"
|
|
37
|
+
);
|
|
38
|
+
if (!fs.existsSync(templateDir)) {
|
|
39
|
+
fail("Template folder not found in CLI package.");
|
|
40
|
+
}
|
|
41
|
+
await fse.copy(templateDir, targetDir);
|
|
42
|
+
const pkgPath = path.join(targetDir, "package.json");
|
|
43
|
+
if (fs.existsSync(pkgPath)) {
|
|
44
|
+
const pkg = JSON.parse(await fse.readFile(pkgPath, "utf8"));
|
|
45
|
+
pkg.name = projectName;
|
|
46
|
+
if (pkg.private === true) delete pkg.private;
|
|
47
|
+
await fse.writeFile(pkgPath, JSON.stringify(pkg, null, 2), "utf8");
|
|
48
|
+
}
|
|
49
|
+
log("");
|
|
50
|
+
log("\u2705 Mantle facilitator scaffolded!");
|
|
51
|
+
log("");
|
|
52
|
+
log("Next steps:");
|
|
53
|
+
log(` cd ${projectName}`);
|
|
54
|
+
log(" cp .env.example .env");
|
|
55
|
+
log(" npm install");
|
|
56
|
+
log(" npm run dev");
|
|
57
|
+
log("");
|
|
58
|
+
}
|
|
59
|
+
main().catch((err) => {
|
|
60
|
+
console.error(err);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import path from "path";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import fse from "fs-extra";
|
|
8
|
+
function log(msg) {
|
|
9
|
+
console.log(msg);
|
|
10
|
+
}
|
|
11
|
+
function fail(msg) {
|
|
12
|
+
console.error(msg);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
function cleanName(name) {
|
|
16
|
+
return name.replace(/[^a-zA-Z0-9-_]/g, "");
|
|
17
|
+
}
|
|
18
|
+
async function main() {
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const rawProjectName = args[0];
|
|
21
|
+
if (!rawProjectName) {
|
|
22
|
+
fail("Usage: create-mantle-facilitator <project-name>");
|
|
23
|
+
}
|
|
24
|
+
const projectName = cleanName(rawProjectName);
|
|
25
|
+
if (!projectName) {
|
|
26
|
+
fail("Invalid project name.");
|
|
27
|
+
}
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
const targetDir = path.join(cwd, projectName);
|
|
30
|
+
if (fs.existsSync(targetDir)) {
|
|
31
|
+
fail(`Target directory already exists: ${targetDir}`);
|
|
32
|
+
}
|
|
33
|
+
const templateDir = path.join(
|
|
34
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
35
|
+
"..",
|
|
36
|
+
"template"
|
|
37
|
+
);
|
|
38
|
+
if (!fs.existsSync(templateDir)) {
|
|
39
|
+
fail("Template folder not found in CLI package.");
|
|
40
|
+
}
|
|
41
|
+
await fse.copy(templateDir, targetDir);
|
|
42
|
+
const pkgPath = path.join(targetDir, "package.json");
|
|
43
|
+
if (fs.existsSync(pkgPath)) {
|
|
44
|
+
const pkg = JSON.parse(await fse.readFile(pkgPath, "utf8"));
|
|
45
|
+
pkg.name = projectName;
|
|
46
|
+
if (pkg.private === true) delete pkg.private;
|
|
47
|
+
await fse.writeFile(pkgPath, JSON.stringify(pkg, null, 2), "utf8");
|
|
48
|
+
}
|
|
49
|
+
log("");
|
|
50
|
+
log("\u2705 Mantle facilitator scaffolded!");
|
|
51
|
+
log("");
|
|
52
|
+
log("Next steps:");
|
|
53
|
+
log(` cd ${projectName}`);
|
|
54
|
+
log(" cp .env.example .env");
|
|
55
|
+
log(" npm install");
|
|
56
|
+
log(" npm run dev");
|
|
57
|
+
log("");
|
|
58
|
+
}
|
|
59
|
+
main().catch((err) => {
|
|
60
|
+
console.error(err);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
|
|
2
|
+
{
|
|
3
|
+
"name": "create-mantle-facilitator",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"create-mantle-facilitator": "./dist/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"template"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"dev": "node dist/index.mjs",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"fs-extra": "^11.2.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/fs-extra": "^11.0.4",
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"tsup": "^8.0.0",
|
|
26
|
+
"typescript": "^5.6.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Server
|
|
2
|
+
PORT=8080
|
|
3
|
+
|
|
4
|
+
# Network
|
|
5
|
+
NETWORK_ID=mantle-mainnet
|
|
6
|
+
CHAIN_ID=5000
|
|
7
|
+
RPC_URL=https://rpc.mantle.xyz
|
|
8
|
+
|
|
9
|
+
# Asset (USDC on Mantle)
|
|
10
|
+
USDC_ADDRESS=0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9
|
|
11
|
+
USDC_DECIMALS=6
|
|
12
|
+
|
|
13
|
+
# Facilitator signer (pays gas for transferWithAuthorization)
|
|
14
|
+
FACILITATOR_PRIVATE_KEY=0xYOUR_PRIVATE_KEY
|
|
15
|
+
|
|
16
|
+
# Optional: enable verbose logs
|
|
17
|
+
LOG_LEVEL=debug
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Mantle x402 Facilitator (Self-hosted Template)
|
|
2
|
+
|
|
3
|
+
A minimal, self-hosted x402 facilitator for **Mantle mainnet** using **USDC (EIP-3009)**.
|
|
4
|
+
|
|
5
|
+
This facilitator:
|
|
6
|
+
- verifies x402 payment headers
|
|
7
|
+
- settles payments on-chain via `transferWithAuthorization`
|
|
8
|
+
- pays gas from the facilitator wallet
|
|
9
|
+
|
|
10
|
+
## Endpoints
|
|
11
|
+
|
|
12
|
+
- `GET /health`
|
|
13
|
+
- `GET /supported`
|
|
14
|
+
- `POST /verify`
|
|
15
|
+
- `POST /settle`
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone <this-repo>
|
|
21
|
+
cd mantle-x402-facilitator-template
|
|
22
|
+
npm install
|
|
23
|
+
cp .env.example .env
|
|
24
|
+
# Fill RPC_URL and FACILITATOR_PRIVATE_KEY
|
|
25
|
+
npm start
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-mantle-facilitator-template",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"description": "Self-hosted x402 facilitator template for Mantle + USDC (TypeScript)",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "tsx src/index.ts",
|
|
10
|
+
"build": "tsup src/index.ts --format esm --dts false",
|
|
11
|
+
"start": "node dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"dotenv": "^16.4.5",
|
|
15
|
+
"ethers": "^6.16.0",
|
|
16
|
+
"express": "^5.2.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/express": "^5.0.6",
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"tsup": "^8.0.0",
|
|
22
|
+
"tsx": "^4.19.2",
|
|
23
|
+
"typescript": "^5.6.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/blockchain.ts
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import { CONFIG } from "./config";
|
|
4
|
+
|
|
5
|
+
// Minimal ABI for USDC EIP-3009
|
|
6
|
+
const USDC_EIP3009_ABI = [
|
|
7
|
+
"function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,uint8 v,bytes32 r,bytes32 s) external",
|
|
8
|
+
"function balanceOf(address account) view returns (uint256)",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function getProvider(): ethers.JsonRpcProvider {
|
|
12
|
+
return new ethers.JsonRpcProvider(CONFIG.rpcUrl, CONFIG.chainId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getFacilitatorSigner(): ethers.Wallet {
|
|
16
|
+
const provider = getProvider();
|
|
17
|
+
return new ethers.Wallet(CONFIG.facilitatorPrivateKey, provider);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getUsdcContract(
|
|
21
|
+
signerOrProvider: ethers.ContractRunner
|
|
22
|
+
): ethers.Contract {
|
|
23
|
+
return new ethers.Contract(CONFIG.usdcAddress, USDC_EIP3009_ABI, signerOrProvider);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getMntBalance(address: string): Promise<bigint> {
|
|
27
|
+
const provider = getProvider();
|
|
28
|
+
return provider.getBalance(address);
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
|
|
4
|
+
function required(name: string): string {
|
|
5
|
+
const v = process.env[name];
|
|
6
|
+
if (!v) throw new Error(`Missing env var: ${name}`);
|
|
7
|
+
return v;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const CONFIG = {
|
|
11
|
+
port: Number(process.env.PORT ?? 8080),
|
|
12
|
+
|
|
13
|
+
networkId: process.env.NETWORK_ID ?? "mantle-mainnet",
|
|
14
|
+
chainId: Number(process.env.CHAIN_ID ?? 5000),
|
|
15
|
+
rpcUrl: required("RPC_URL"),
|
|
16
|
+
|
|
17
|
+
usdcAddress: required("USDC_ADDRESS"),
|
|
18
|
+
usdcDecimals: Number(process.env.USDC_DECIMALS ?? 6),
|
|
19
|
+
|
|
20
|
+
facilitatorPrivateKey: required("FACILITATOR_PRIVATE_KEY"),
|
|
21
|
+
|
|
22
|
+
logLevel: process.env.LOG_LEVEL ?? "info",
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { CONFIG } from "./config";
|
|
4
|
+
|
|
5
|
+
import { healthRoute } from "./routes/health";
|
|
6
|
+
import { supportedRoute } from "./routes/supported";
|
|
7
|
+
import { verifyRoute } from "./routes/verify";
|
|
8
|
+
import { settleRoute } from "./routes/settle";
|
|
9
|
+
|
|
10
|
+
const app = express();
|
|
11
|
+
app.use(express.json({ limit: "1mb" }));
|
|
12
|
+
|
|
13
|
+
app.get("/health", healthRoute);
|
|
14
|
+
app.get("/supported", supportedRoute);
|
|
15
|
+
app.post("/verify", verifyRoute);
|
|
16
|
+
app.post("/settle", settleRoute);
|
|
17
|
+
|
|
18
|
+
app.listen(CONFIG.port, () => {
|
|
19
|
+
console.log(`Facilitator server listening on http://localhost:${CONFIG.port}`);
|
|
20
|
+
console.log(`Network: ${CONFIG.networkId} (chainId=${CONFIG.chainId})`);
|
|
21
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/routes/health.ts
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
import { ethers } from "ethers";
|
|
4
|
+
import { CONFIG } from "../config";
|
|
5
|
+
import { getFacilitatorSigner, getMntBalance } from "../blockchain";
|
|
6
|
+
|
|
7
|
+
export async function healthRoute(_req: Request, res: Response) {
|
|
8
|
+
try {
|
|
9
|
+
const signer = getFacilitatorSigner();
|
|
10
|
+
const provider = signer.provider;
|
|
11
|
+
|
|
12
|
+
if (!provider) {
|
|
13
|
+
res.status(500).json({ ok: false, error: "No provider available" });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const currentBlock = await provider.getBlockNumber();
|
|
18
|
+
const mntBalanceWei = await getMntBalance(signer.address);
|
|
19
|
+
|
|
20
|
+
res.status(200).json({
|
|
21
|
+
ok: true,
|
|
22
|
+
network: CONFIG.networkId,
|
|
23
|
+
chainId: CONFIG.chainId,
|
|
24
|
+
facilitatorAddress: signer.address,
|
|
25
|
+
currentBlock,
|
|
26
|
+
mntBalanceWei: mntBalanceWei.toString(),
|
|
27
|
+
mntBalance: ethers.formatEther(mntBalanceWei),
|
|
28
|
+
});
|
|
29
|
+
} catch (err) {
|
|
30
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
31
|
+
res.status(500).json({ ok: false, error: msg });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/routes/settle.ts
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
import { ethers } from "ethers";
|
|
4
|
+
import { decodePaymentHeader, verifyPayment, type PaymentRequirements } from "../x402";
|
|
5
|
+
import { getFacilitatorSigner, getUsdcContract } from "../blockchain";
|
|
6
|
+
|
|
7
|
+
function signatureToVRS(signature: string) {
|
|
8
|
+
const sig = ethers.Signature.from(signature);
|
|
9
|
+
return { v: sig.v, r: sig.r, s: sig.s };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Send transferWithAuthorization on-chain using facilitator gas. */
|
|
13
|
+
export async function settleRoute(req: Request, res: Response) {
|
|
14
|
+
const raw = req.body ?? {};
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const { x402Version, paymentHeader, paymentRequirements } = raw as {
|
|
18
|
+
x402Version?: number;
|
|
19
|
+
paymentHeader?: string;
|
|
20
|
+
paymentRequirements?: PaymentRequirements;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (x402Version !== 1) {
|
|
24
|
+
res.status(400).json({
|
|
25
|
+
success: false,
|
|
26
|
+
error: "Unsupported x402Version",
|
|
27
|
+
txHash: null,
|
|
28
|
+
networkId: paymentRequirements?.network,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!paymentHeader || !paymentRequirements) {
|
|
34
|
+
res.status(400).json({
|
|
35
|
+
success: false,
|
|
36
|
+
error: "Missing paymentHeader or paymentRequirements",
|
|
37
|
+
txHash: null,
|
|
38
|
+
networkId: paymentRequirements?.network,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const headerObj = decodePaymentHeader(paymentHeader);
|
|
44
|
+
const verify = verifyPayment(headerObj, paymentRequirements);
|
|
45
|
+
|
|
46
|
+
if (!verify.isValid) {
|
|
47
|
+
res.status(400).json({
|
|
48
|
+
success: false,
|
|
49
|
+
error: verify.invalidReason ?? "Invalid payment",
|
|
50
|
+
txHash: null,
|
|
51
|
+
networkId: paymentRequirements.network,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { authorization, signature } = headerObj.payload;
|
|
57
|
+
const { v, r, s } = signatureToVRS(signature);
|
|
58
|
+
|
|
59
|
+
const signer = getFacilitatorSigner();
|
|
60
|
+
const usdc = getUsdcContract(signer);
|
|
61
|
+
|
|
62
|
+
const tx = await usdc.transferWithAuthorization(
|
|
63
|
+
authorization.from,
|
|
64
|
+
authorization.to,
|
|
65
|
+
authorization.value,
|
|
66
|
+
authorization.validAfter,
|
|
67
|
+
authorization.validBefore,
|
|
68
|
+
authorization.nonce,
|
|
69
|
+
v, r, s
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const receipt = await tx.wait();
|
|
73
|
+
|
|
74
|
+
res.status(200).json({
|
|
75
|
+
success: true,
|
|
76
|
+
error: null,
|
|
77
|
+
txHash: receipt?.hash ?? tx.hash,
|
|
78
|
+
networkId: paymentRequirements.network,
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
82
|
+
res.status(500).json({
|
|
83
|
+
success: false,
|
|
84
|
+
error: msg,
|
|
85
|
+
txHash: null,
|
|
86
|
+
networkId: (raw as { paymentRequirements?: { network?: string } })?.paymentRequirements?.network,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/routes/supported.ts
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
import { CONFIG } from "../config";
|
|
4
|
+
|
|
5
|
+
export function supportedRoute(_req: Request, res: Response) {
|
|
6
|
+
res.status(200).json({
|
|
7
|
+
networkId: CONFIG.networkId,
|
|
8
|
+
chainId: CONFIG.chainId,
|
|
9
|
+
schemes: ["exact"],
|
|
10
|
+
assets: [
|
|
11
|
+
{
|
|
12
|
+
symbol: "USDC",
|
|
13
|
+
address: CONFIG.usdcAddress,
|
|
14
|
+
decimals: CONFIG.usdcDecimals,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// src/routes/verify.ts
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
import { decodePaymentHeader, verifyPayment, type PaymentRequirements } from "../x402";
|
|
4
|
+
|
|
5
|
+
export async function verifyRoute(req: Request, res: Response) {
|
|
6
|
+
try {
|
|
7
|
+
const { x402Version, paymentHeader, paymentRequirements } = req.body ?? {};
|
|
8
|
+
|
|
9
|
+
if (x402Version !== 1) {
|
|
10
|
+
res.status(400).json({ isValid: false, invalidReason: "Unsupported x402Version" });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!paymentHeader || !paymentRequirements) {
|
|
15
|
+
res.status(400).json({ isValid: false, invalidReason: "Missing paymentHeader or paymentRequirements" });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const headerObj = decodePaymentHeader(paymentHeader as string);
|
|
20
|
+
const result = verifyPayment(headerObj, paymentRequirements as PaymentRequirements);
|
|
21
|
+
|
|
22
|
+
res.status(200).json(result);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
25
|
+
res.status(500).json({ isValid: false, invalidReason: msg });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/x402.ts
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import { CONFIG } from "./config";
|
|
4
|
+
|
|
5
|
+
/** Payment requirements expected by facilitator endpoints. */
|
|
6
|
+
export interface PaymentRequirements {
|
|
7
|
+
scheme: "exact";
|
|
8
|
+
network: string;
|
|
9
|
+
asset: string;
|
|
10
|
+
maxAmountRequired: string;
|
|
11
|
+
payTo: string;
|
|
12
|
+
price?: string;
|
|
13
|
+
currency?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** EIP-3009 authorization payload. */
|
|
17
|
+
export interface Authorization {
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
value: string;
|
|
21
|
+
validAfter: string;
|
|
22
|
+
validBefore: string;
|
|
23
|
+
nonce: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** x402 header object structure before base64 encoding. */
|
|
27
|
+
export interface PaymentHeaderObject {
|
|
28
|
+
x402Version: number;
|
|
29
|
+
scheme: "exact";
|
|
30
|
+
network: string;
|
|
31
|
+
payload: {
|
|
32
|
+
signature: string;
|
|
33
|
+
authorization: Authorization;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Decode base64 payment header into JSON object. */
|
|
38
|
+
export function decodePaymentHeader(paymentHeader: string): PaymentHeaderObject {
|
|
39
|
+
try {
|
|
40
|
+
const json = Buffer.from(paymentHeader, "base64").toString("utf8");
|
|
41
|
+
return JSON.parse(json) as PaymentHeaderObject;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
44
|
+
throw new Error(`Failed to decode paymentHeader: ${msg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Basic structural validation of header. */
|
|
49
|
+
export function validateHeaderShape(headerObj: PaymentHeaderObject) {
|
|
50
|
+
if (!headerObj || typeof headerObj !== "object") {
|
|
51
|
+
return { ok: false, reason: "Header is not an object" as const };
|
|
52
|
+
}
|
|
53
|
+
if (headerObj.x402Version !== 1) {
|
|
54
|
+
return { ok: false, reason: "Unsupported x402Version" as const };
|
|
55
|
+
}
|
|
56
|
+
if (headerObj.scheme !== "exact") {
|
|
57
|
+
return { ok: false, reason: "Unsupported scheme" as const };
|
|
58
|
+
}
|
|
59
|
+
if (!headerObj.network) {
|
|
60
|
+
return { ok: false, reason: "Missing network" as const };
|
|
61
|
+
}
|
|
62
|
+
if (!headerObj.payload?.authorization || !headerObj.payload?.signature) {
|
|
63
|
+
return { ok: false, reason: "Missing payload.authorization or payload.signature" as const };
|
|
64
|
+
}
|
|
65
|
+
return { ok: true as const };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build EIP-712 domain/types for USDC TransferWithAuthorization. */
|
|
69
|
+
export function getUsdcTypedData(authorization: Authorization) {
|
|
70
|
+
const domain = {
|
|
71
|
+
name: "USD Coin",
|
|
72
|
+
version: "2",
|
|
73
|
+
chainId: CONFIG.chainId,
|
|
74
|
+
verifyingContract: CONFIG.usdcAddress,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const types = {
|
|
78
|
+
TransferWithAuthorization: [
|
|
79
|
+
{ name: "from", type: "address" },
|
|
80
|
+
{ name: "to", type: "address" },
|
|
81
|
+
{ name: "value", type: "uint256" },
|
|
82
|
+
{ name: "validAfter", type: "uint256" },
|
|
83
|
+
{ name: "validBefore", type: "uint256" },
|
|
84
|
+
{ name: "nonce", type: "bytes32" },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return { domain, types, primaryType: "TransferWithAuthorization", message: authorization };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Verify that header signature matches authorization.from. */
|
|
92
|
+
export function verifyAuthorizationSignature(
|
|
93
|
+
authorization: Authorization,
|
|
94
|
+
signature: string
|
|
95
|
+
): string {
|
|
96
|
+
const { domain, types, message } = getUsdcTypedData(authorization);
|
|
97
|
+
return ethers.verifyTypedData(domain, types, message, signature);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify payment header against paymentRequirements.
|
|
102
|
+
* This checks:
|
|
103
|
+
* - network/scheme
|
|
104
|
+
* - authorization.to/value equals requirements fields
|
|
105
|
+
* - EIP-712 signature is valid
|
|
106
|
+
*/
|
|
107
|
+
export function verifyPayment(
|
|
108
|
+
headerObj: PaymentHeaderObject,
|
|
109
|
+
paymentRequirements: PaymentRequirements
|
|
110
|
+
): { isValid: boolean; invalidReason: string | null } {
|
|
111
|
+
const shape = validateHeaderShape(headerObj);
|
|
112
|
+
if (!shape.ok) return { isValid: false, invalidReason: shape.reason };
|
|
113
|
+
|
|
114
|
+
const { authorization, signature } = headerObj.payload;
|
|
115
|
+
|
|
116
|
+
if (headerObj.network !== paymentRequirements.network) {
|
|
117
|
+
return { isValid: false, invalidReason: "Network mismatch" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (paymentRequirements.scheme !== "exact") {
|
|
121
|
+
return { isValid: false, invalidReason: "Only exact scheme supported" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (authorization.to.toLowerCase() !== paymentRequirements.payTo.toLowerCase()) {
|
|
125
|
+
return { isValid: false, invalidReason: "Authorization.to does not match payTo" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const authValue = BigInt(authorization.value);
|
|
129
|
+
const maxValue = BigInt(paymentRequirements.maxAmountRequired);
|
|
130
|
+
|
|
131
|
+
// In our current exact mode we keep strict equality (same as your working stack)
|
|
132
|
+
if (authValue !== maxValue) {
|
|
133
|
+
return { isValid: false, invalidReason: "Authorization.value does not match maxAmountRequired" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const recovered = verifyAuthorizationSignature(authorization, signature);
|
|
138
|
+
if (recovered.toLowerCase() !== authorization.from.toLowerCase()) {
|
|
139
|
+
return { isValid: false, invalidReason: "Signature does not match authorization.from" };
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
143
|
+
return { isValid: false, invalidReason: `Signature verification failed: ${msg}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { isValid: true, invalidReason: null };
|
|
147
|
+
}
|