diff --git a/examples/typescript/clients/builder-code/.env-local b/examples/typescript/clients/builder-code/.env-local new file mode 100644 index 0000000000..8339ac0ab9 --- /dev/null +++ b/examples/typescript/clients/builder-code/.env-local @@ -0,0 +1,4 @@ +EVM_PRIVATE_KEY= +SVM_PRIVATE_KEY= +RESOURCE_SERVER_URL=http://localhost:4021 +ENDPOINT_PATH=/weather \ No newline at end of file diff --git a/examples/typescript/clients/builder-code/.prettierignore b/examples/typescript/clients/builder-code/.prettierignore new file mode 100644 index 0000000000..5bd240ba90 --- /dev/null +++ b/examples/typescript/clients/builder-code/.prettierignore @@ -0,0 +1,8 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +src/client +**/**/*.json +*.md \ No newline at end of file diff --git a/examples/typescript/clients/builder-code/.prettierrc b/examples/typescript/clients/builder-code/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/clients/builder-code/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/examples/typescript/clients/builder-code/README.md b/examples/typescript/clients/builder-code/README.md new file mode 100644 index 0000000000..7b3b0862a2 --- /dev/null +++ b/examples/typescript/clients/builder-code/README.md @@ -0,0 +1,42 @@ +# Builder Code Example Client + +Example client for the [builder-code server](../../servers/builder-code/). Makes a paid request and verifies that ERC-8021 builder-code attribution was appended to the settlement transaction calldata. + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; + +const client = new x402Client(); +client.register("eip155:*", new ExactEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY!))); + +const response = await wrapFetchWithPayment(fetch, client)("http://localhost:4021/weather"); +console.log(await response.json()); +``` + +## Prerequisites + +- Node.js v20+ +- pnpm v10 +- Running [builder-code server](../../servers/builder-code/) and [builder-code facilitator](../../facilitator/builder-code/) +- EVM private key funded on Base Sepolia + +## Setup + +1. Install and build from the typescript examples root: + +```bash +cd ../../ +pnpm install && pnpm build +cd clients/builder-code +``` + +2. Copy `.env-local` to `.env` and set `EVM_PRIVATE_KEY`. + +3. Run the client: + +```bash +pnpm dev +``` + +On success, the client prints the settlement transaction hash and the builder codes parsed from on-chain calldata (for example `a` for the service app code and `w` for the facilitator wallet code). diff --git a/examples/typescript/clients/builder-code/eslint.config.js b/examples/typescript/clients/builder-code/eslint.config.js new file mode 100644 index 0000000000..ca28b5c47f --- /dev/null +++ b/examples/typescript/clients/builder-code/eslint.config.js @@ -0,0 +1,72 @@ +import js from "@eslint/js"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import jsdoc from "eslint-plugin-jsdoc"; +import importPlugin from "eslint-plugin-import"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + process: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + exports: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + jsdoc: jsdoc, + import: importPlugin, + }, + rules: { + ...ts.configs.recommended.rules, + "import/first": "error", + "prettier/prettier": "error", + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_$" }], + "jsdoc/tag-lines": ["error", "any", { startLines: 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"], + }, + }, +]; diff --git a/examples/typescript/clients/builder-code/index.ts b/examples/typescript/clients/builder-code/index.ts new file mode 100644 index 0000000000..1a32516014 --- /dev/null +++ b/examples/typescript/clients/builder-code/index.ts @@ -0,0 +1,79 @@ +import { config } from "dotenv"; +import { x402Client, wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { + BuilderCodeClientExtension, + parseBuilderCodeSuffixFromCalldata, +} from "@x402/extensions/builder-code"; +import { privateKeyToAccount } from "viem/accounts"; +import { createPublicClient, http, type Hex } from "viem"; +import { baseSepolia } from "viem/chains"; + +config(); + +const evmPrivateKey = process.env.EVM_PRIVATE_KEY as `0x${string}`; +const evmRpcUrl = process.env.EVM_RPC_URL ?? "https://sepolia.base.org"; +const clientBuilderCode = process.env.CLIENT_BUILDER_CODE || "bc_example_client"; +const baseURL = process.env.RESOURCE_SERVER_URL || "http://localhost:4021"; +const endpointPath = process.env.ENDPOINT_PATH || "/weather"; +const url = `${baseURL}${endpointPath}`; + +/** + * Example client for builder-code attribution on x402-protected endpoints. + * + * Required environment variables: + * - EVM_PRIVATE_KEY: The private key of the EVM signer + * + * Optional environment variables: + * - EVM_RPC_URL: JSON-RPC endpoint for onchain verification (defaults to Base Sepolia) + * - CLIENT_BUILDER_CODE: Builder code for client attribution (defaults to "bc_example_client") + * - RESOURCE_SERVER_URL: Resource server base URL + * - ENDPOINT_PATH: Paid endpoint path + */ +async function main(): Promise { + const evmSigner = privateKeyToAccount(evmPrivateKey); + const rpcOptions = { rpcUrl: evmRpcUrl }; + + const client = new x402Client(); + client.register("eip155:*", new ExactEvmScheme(evmSigner, rpcOptions)); + client.registerExtension(new BuilderCodeClientExtension(clientBuilderCode)); + + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + + console.log(`Making request to: ${url}\n`); + const response = await fetchWithPayment(url, { method: "GET" }); + const contentType = response.headers.get("content-type") ?? ""; + const body = contentType.includes("application/json") + ? await response.json() + : await response.text(); + console.log("Response body:", body); + + const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse(name => + response.headers.get(name), + ); + console.log("\nPayment response:", JSON.stringify(paymentResponse, null, 2)); + + if (!paymentResponse?.success || !paymentResponse.transaction) { + throw new Error("Settlement did not return a transaction hash"); + } + + const txHash = paymentResponse.transaction as Hex; + const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(evmRpcUrl), + }); + const tx = await publicClient.getTransaction({ hash: txHash }); + + const attribution = parseBuilderCodeSuffixFromCalldata(tx.input); + if (!attribution) { + throw new Error(`ERC-8021 builder-code suffix not found in calldata for ${txHash}`); + } + + console.log("\nBuilder-code attribution verified onchain:", attribution); + console.log(`Explorer: https://sepolia.basescan.org/tx/${txHash}`); +} + +main().catch(error => { + console.error(error?.response?.data?.error ?? error); + process.exit(1); +}); diff --git a/examples/typescript/clients/builder-code/package.json b/examples/typescript/clients/builder-code/package.json new file mode 100644 index 0000000000..dc65f53c12 --- /dev/null +++ b/examples/typescript/clients/builder-code/package.json @@ -0,0 +1,33 @@ +{ + "name": "@x402/builder-code-client-example", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx index.ts", + "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", + "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", + "lint": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts" + }, + "dependencies": { + "@x402/evm": "workspace:*", + "@x402/extensions": "workspace:*", + "@x402/fetch": "workspace:*", + "dotenv": "^16.4.7", + "viem": "^2.48.11" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "3.5.2", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/clients/builder-code/tsconfig.json b/examples/typescript/clients/builder-code/tsconfig.json new file mode 100644 index 0000000000..78f9479b1b --- /dev/null +++ b/examples/typescript/clients/builder-code/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "baseUrl": ".", + "types": ["node"] + }, + "include": ["index.ts"] +} diff --git a/examples/typescript/facilitator/builder-code/.env-local b/examples/typescript/facilitator/builder-code/.env-local new file mode 100644 index 0000000000..7534548c3f --- /dev/null +++ b/examples/typescript/facilitator/builder-code/.env-local @@ -0,0 +1,3 @@ +PORT= +EVM_PRIVATE_KEY= +SVM_PRIVATE_KEY= \ No newline at end of file diff --git a/examples/typescript/facilitator/builder-code/README.md b/examples/typescript/facilitator/builder-code/README.md new file mode 100644 index 0000000000..4923465937 --- /dev/null +++ b/examples/typescript/facilitator/builder-code/README.md @@ -0,0 +1,202 @@ +# Builder Code Facilitator Example + +Express.js facilitator that verifies and settles payments and appends ERC-8021 wallet attribution (`w`) at settlement via `BuilderCodeFacilitatorExtension`. + +## Prerequisites + +- Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) +- pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) +- Dedicated EVM facilitator private key with Base Sepolia ETH for transaction fees + +## Setup + +1. Copy `.env-local` to `.env`: + +```bash +cp .env-local .env +``` + +and fill required environment variables: + +- `EVM_PRIVATE_KEY` - Base Sepolia facilitator private key +- `BUILDER_CODE` - Facilitator wallet builder code (e.g. `bc_example_facilitator`) +- `PORT` - Server port (optional, defaults to 4022) + +**⚠️ Security Note:** The facilitator key is the signer used to settle payments on-chain. Keep it separate from your seller `payTo` wallet and buyer test wallets, and make sure it is funded only for facilitator gas/fees. + +2. Install and build all packages from the typescript examples root: + +```bash +cd ../../ +pnpm install && pnpm build +cd facilitator/builder-code +``` + +3. Run the server: + +```bash +pnpm dev +``` + +## API Endpoints + +### GET /supported + +Returns payment schemes and networks this facilitator supports. + +```json +{ + "kinds": [ + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:84532" + } + ], + "extensions": [], + "signers": { + "eip155": ["0x..."] + } +} +``` + +### POST /verify + +Verifies a payment payload against requirements before settlement. + +Request: + +```json +{ + "paymentPayload": { + "x402Version": 2, + "resource": { + "url": "http://localhost:4021/weather", + "description": "Weather data", + "mimeType": "application/json" + }, + "accepted": { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount": "1000", + "payTo": "0x...", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2" + } + }, + "payload": { + "signature": "0x...", + "authorization": {} + } + }, + "paymentRequirements": { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount": "1000", + "payTo": "0x...", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2" + } + } +} +``` + +Response (success): + +```json +{ + "isValid": true, + "payer": "0x..." +} +``` + +Response (failure): + +```json +{ + "isValid": false, + "invalidReason": "invalid_signature" +} +``` + +### POST /settle + +Settles a verified payment by broadcasting the transaction on-chain. + +Request body is identical to `/verify`. + +Response (success): + +```json +{ + "success": true, + "transaction": "0x...", + "network": "eip155:84532", + "payer": "0x..." +} +``` + +Response (failure): + +```json +{ + "success": false, + "errorReason": "insufficient_balance", + "transaction": "", + "network": "eip155:84532" +} +``` + +## Extending the Example + +### Adding Networks + +Register additional schemes for other networks: + +```typescript +import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; + +const facilitator = new x402Facilitator(); + +facilitator.register("eip155:84532", new ExactEvmScheme(evmSigner)); +``` + +### Lifecycle Hooks + +Add custom logic before/after verify and settle operations: + +```typescript +const facilitator = new x402Facilitator() + .onBeforeVerify(async (context) => { + // Log or validate before verification + }) + .onAfterVerify(async (context) => { + // Track verified payments + }) + .onVerifyFailure(async (context) => { + // Handle verification failures + }) + .onBeforeSettle(async (context) => { + // Validate before settlement + // Return { abort: true, reason: "..." } to cancel + }) + .onAfterSettle(async (context) => { + // Track successful settlements + }) + .onSettleFailure(async (context) => { + // Handle settlement failures + }); +``` + +## Network Identifiers + +Networks use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) format: + +- `eip155:84532` — Base Sepolia +- `eip155:8453` — Base Mainnet diff --git a/examples/typescript/facilitator/builder-code/eslint.config.js b/examples/typescript/facilitator/builder-code/eslint.config.js new file mode 100644 index 0000000000..784ecd5435 --- /dev/null +++ b/examples/typescript/facilitator/builder-code/eslint.config.js @@ -0,0 +1,75 @@ +import js from "@eslint/js"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import jsdoc from "eslint-plugin-jsdoc"; +import importPlugin from "eslint-plugin-import"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + process: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + exports: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + jsdoc: jsdoc, + import: importPlugin, + }, + rules: { + ...ts.configs.recommended.rules, + "import/first": "error", + "prettier/prettier": "error", + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_$" }, + ], + "jsdoc/tag-lines": ["error", "any", { startLines: 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"], + }, + }, +]; diff --git a/examples/typescript/facilitator/builder-code/index.ts b/examples/typescript/facilitator/builder-code/index.ts new file mode 100644 index 0000000000..37d526305e --- /dev/null +++ b/examples/typescript/facilitator/builder-code/index.ts @@ -0,0 +1,198 @@ +import { x402Facilitator } from "@x402/core/facilitator"; +import { + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { toFacilitatorEvmSigner } from "@x402/evm"; +import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; +import { BuilderCodeFacilitatorExtension } from "@x402/extensions/builder-code"; +import dotenv from "dotenv"; +import express from "express"; +import { createWalletClient, http, publicActions } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +dotenv.config(); + +const PORT = process.env.PORT || "4022"; +const EVM_NETWORK = "eip155:84532"; + +if (!process.env.EVM_PRIVATE_KEY) { + console.error("❌ EVM_PRIVATE_KEY environment variable is required"); + process.exit(1); +} + +const facilitatorBuilderCode = process.env.BUILDER_CODE; +if (!facilitatorBuilderCode) { + console.error("❌ BUILDER_CODE environment variable is required"); + process.exit(1); +} + +const evmAccount = privateKeyToAccount( + process.env.EVM_PRIVATE_KEY as `0x${string}`, +); +console.info(`EVM Facilitator account: ${evmAccount.address}`); + +const viemClient = createWalletClient({ + account: evmAccount, + chain: baseSepolia, + transport: http(), +}).extend(publicActions); + +const evmSigner = toFacilitatorEvmSigner({ + getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), + address: evmAccount.address, + readContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + viemClient.readContract({ + ...args, + args: args.args || [], + }), + verifyTypedData: (args: { + address: `0x${string}`; + domain: Record; + types: Record; + primaryType: string; + message: Record; + signature: `0x${string}`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) => viemClient.verifyTypedData(args as any), + writeContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args: readonly unknown[]; + gas?: bigint; + dataSuffix?: `0x${string}`; + }) => + viemClient.writeContract({ + ...args, + args: args.args || [], + }), + sendTransaction: (args: { to: `0x${string}`; data: `0x${string}` }) => + viemClient.sendTransaction(args), + waitForTransactionReceipt: (args: { hash: `0x${string}` }) => + viemClient.waitForTransactionReceipt(args), +}); + +const facilitator = new x402Facilitator() + .onBeforeVerify(async (context) => { + console.log("Before verify", context); + }) + .onAfterVerify(async (context) => { + console.log("After verify", context); + }) + .onVerifyFailure(async (context) => { + console.log("Verify failure", context); + }) + .onBeforeSettle(async (context) => { + console.log("Before settle", context); + }) + .onAfterSettle(async (context) => { + console.log("After settle", context); + }) + .onSettleFailure(async (context) => { + console.log("Settle failure", context); + }) + .registerExtension( + new BuilderCodeFacilitatorExtension({ + builderCode: facilitatorBuilderCode, + }), + ); + +facilitator.register( + EVM_NETWORK, + new ExactEvmScheme(evmSigner, { deployERC4337WithEIP6492: true }), +); + +const app = express(); +app.use(express.json()); + +app.post("/verify", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body as { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; + }; + + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ + error: "Missing paymentPayload or paymentRequirements", + }); + } + + const response: VerifyResponse = await facilitator.verify( + paymentPayload, + paymentRequirements, + ); + + res.json(response); + } catch (error) { + console.error("Verify error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.post("/settle", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body as { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; + }; + + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ + error: "Missing paymentPayload or paymentRequirements", + }); + } + + const response: SettleResponse = await facilitator.settle( + paymentPayload, + paymentRequirements, + ); + + res.json(response); + } catch (error) { + console.error("Settle error:", error); + + if ( + error instanceof Error && + error.message.includes("Settlement aborted:") + ) { + return res.json({ + success: false, + errorReason: error.message.replace("Settlement aborted: ", ""), + network: req.body?.paymentPayload?.network || "unknown", + } as SettleResponse); + } + + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.get("/supported", async (_req, res) => { + try { + res.json(facilitator.getSupported()); + } catch (error) { + console.error("Supported error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.listen(parseInt(PORT), () => { + console.log( + `🚀 Facilitator listening on http://localhost:${PORT} (Base Sepolia)`, + ); +}); diff --git a/examples/typescript/facilitator/builder-code/package.json b/examples/typescript/facilitator/builder-code/package.json new file mode 100644 index 0000000000..ebc7b58cd1 --- /dev/null +++ b/examples/typescript/facilitator/builder-code/package.json @@ -0,0 +1,36 @@ +{ + "name": "@x402/facilitator-builder-code-typescript", + "version": "2.0.0", + "type": "module", + "private": true, + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts", + "build": "tsc", + "lint": "eslint .", + "format": "prettier --write .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@x402/core": "workspace:*", + "@x402/evm": "workspace:*", + "@x402/extensions": "workspace:*", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "viem": "^2.48.11" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@types/express": "^4.17.21", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "3.5.2", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/facilitator/builder-code/tsconfig.json b/examples/typescript/facilitator/builder-code/tsconfig.json new file mode 100644 index 0000000000..fc0e5250ed --- /dev/null +++ b/examples/typescript/facilitator/builder-code/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist" + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/typescript/fullstack/next/next-env.d.ts b/examples/typescript/fullstack/next/next-env.d.ts index c4b7818fbb..9edff1c7ca 100644 --- a/examples/typescript/fullstack/next/next-env.d.ts +++ b/examples/typescript/fullstack/next/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 7e4c3c3837..5ae8200237 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -1237,6 +1237,58 @@ importers: specifier: ^5.7.3 version: 5.9.3 + clients/builder-code: + dependencies: + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/extensions': + specifier: workspace:* + version: link:../../../../typescript/packages/extensions + '@x402/fetch': + specifier: workspace:* + version: link:../../../../typescript/packages/http/fetch + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + clients/custom: dependencies: '@scure/base': @@ -1838,6 +1890,64 @@ importers: specifier: ^5.7.3 version: 5.9.3 + facilitator/builder-code: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/extensions': + specifier: workspace:* + version: link:../../../../typescript/packages/extensions + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.19.2 + version: 4.21.2 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + fullstack/miniapp: dependencies: '@coinbase/onchainkit': @@ -2267,6 +2377,67 @@ importers: specifier: ^5.7.3 version: 5.9.3 + servers/builder-code: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/express': + specifier: workspace:* + version: link:../../../../typescript/packages/http/express + '@x402/extensions': + specifier: workspace:* + version: link:../../../../typescript/packages/extensions + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.1 + version: 5.0.3 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + servers/cloudfront-lambda-edge: {} servers/cloudfront-lambda-edge/cdk: diff --git a/examples/typescript/pnpm-workspace.yaml b/examples/typescript/pnpm-workspace.yaml index 550e02bcf9..f07bc19756 100644 --- a/examples/typescript/pnpm-workspace.yaml +++ b/examples/typescript/pnpm-workspace.yaml @@ -11,7 +11,7 @@ packages: - "../../typescript/packages/http/*" - "../../typescript/packages/mechanisms/*" minimumReleaseAge: 4320 # Only install package versions that have been published for at least 3 days. -minimumReleaseAgeStrict: true +minimumReleaseAgeStrict: false allowBuilds: bufferutil: true esbuild: true diff --git a/examples/typescript/servers/builder-code/.env-local b/examples/typescript/servers/builder-code/.env-local new file mode 100644 index 0000000000..4ddfc506b9 --- /dev/null +++ b/examples/typescript/servers/builder-code/.env-local @@ -0,0 +1,3 @@ +EVM_ADDRESS= +SVM_ADDRESS= +FACILITATOR_URL=https://x402.org/facilitator \ No newline at end of file diff --git a/examples/typescript/servers/builder-code/.prettierignore b/examples/typescript/servers/builder-code/.prettierignore new file mode 100644 index 0000000000..5bd240ba90 --- /dev/null +++ b/examples/typescript/servers/builder-code/.prettierignore @@ -0,0 +1,8 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +src/client +**/**/*.json +*.md \ No newline at end of file diff --git a/examples/typescript/servers/builder-code/.prettierrc b/examples/typescript/servers/builder-code/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/servers/builder-code/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/examples/typescript/servers/builder-code/README.md b/examples/typescript/servers/builder-code/README.md new file mode 100644 index 0000000000..93f9e31769 --- /dev/null +++ b/examples/typescript/servers/builder-code/README.md @@ -0,0 +1,225 @@ +# Builder Code Example Server + +Express.js server demonstrating ERC-8021 builder-code attribution on paid endpoints via `declareBuilderCodeExtension`. + +```typescript +import express from "express"; +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { HTTPFacilitatorClient } from "@x402/core/server"; + +const app = express(); + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { scheme: "exact", price: "$0.001", network: "eip155:84532", payTo: evmAddress }, + description: "Weather data", + mimeType: "application/json", + }, + }, + new x402ResourceServer(new HTTPFacilitatorClient({ url: facilitatorUrl })) + .register("eip155:84532", new ExactEvmScheme()), + ), +); + +app.get("/weather", (req, res) => res.json({ weather: "sunny", temperature: 70 })); +``` + +## Prerequisites + +- Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) +- pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) +- EVM address on Base Sepolia for receiving payments +- URL of a facilitator supporting Base Sepolia (`eip155:84532`); use the [builder-code facilitator](../facilitator/builder-code/) for full attribution + +## Setup + +1. Copy `.env-local` to `.env`: + +```bash +cp .env-local .env +``` + +and fill required environment variables: + +- `FACILITATOR_URL` - Facilitator endpoint URL (use the [builder-code facilitator](../facilitator/builder-code/) for full attribution) +- `EVM_ADDRESS` - Base Sepolia address to receive payments +- `APP_BUILDER_CODE` - Your service app builder code (e.g. `bc_weather_svc`) + +2. Install and build all packages from the typescript examples root: +```bash +cd ../../ +pnpm install && pnpm build +cd servers/express +``` + +3. Run the server +```bash +pnpm dev +``` + +## Testing the Server + +You can test the server using one of the example clients: + +### Using the Builder Code Client +```bash +cd ../clients/builder-code +# Ensure .env is setup +pnpm dev +``` + +These clients will demonstrate how to: +1. Make an initial request to get payment requirements +2. Process the payment requirements +3. Make a second request with the payment token + +## Example Endpoint + +The server includes a single example endpoint at `/weather` that requires a payment of 0.001 USDC on Base Sepolia to access. The endpoint returns a simple weather report. + +## Response Format + +### Payment Required (402) + +``` +HTTP/1.1 402 Payment Required +Content-Type: application/json; charset=utf-8 +PAYMENT-REQUIRED: + +{} +``` + +The `PAYMENT-REQUIRED` header contains base64-encoded JSON with the payment requirements. +Note: `amount` is in atomic units (e.g., 1000 = 0.001 USDC, since USDC has 6 decimals): + +```json +{ + "x402Version": 2, + "error": "Payment required", + "resource": { + "url": "http://localhost:4021/weather", + "description": "Weather data", + "mimeType": "application/json" + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x1c47E9C085c2B7458F5b6C16cCBD65A65255a9f6", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2", + "resourceUrl": "http://localhost:4021/weather" + } + }, + ] +} +``` + +### Successful Response + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +PAYMENT-RESPONSE: + +{"report":{"weather":"sunny","temperature":70}} +``` + +The `PAYMENT-RESPONSE` header contains base64-encoded JSON with the settlement details: + +```json +{ + "success": true, + "transaction": "0x...", + "network": "eip155:84532", + "payer": "0x...", + "requirements": { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x...", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2", + "resourceUrl": "http://localhost:4021/weather" + } + } +} +``` + +## Extending the Example + +To add more paid endpoints, follow this pattern: + +```typescript +// First, configure the payment middleware with your routes +app.use( + paymentMiddleware( + { + "GET /your-endpoint": { + accepts: { + scheme: "exact", + price: "$0.10", + network: "eip155:84532", + payTo: evmAddress, + }, + description: "Your endpoint description", + mimeType: "application/json", + }, + }, + resourceServer, + ), +); + +// Then define your routes as normal +app.get("/your-endpoint", (req, res) => { + res.json({ + // Your response data + }); +}); +``` + +**Network identifiers** use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) format, for example: +- `eip155:84532` — Base Sepolia +- `eip155:8453` — Base Mainnet + +## x402ResourceServer Config + +The `x402ResourceServer` uses a builder pattern to register payment schemes that declare how payments for each network should be processed: + +```typescript +const resourceServer = new x402ResourceServer(facilitatorClient) + .register("eip155:84532", new ExactEvmScheme()) // Base Sepolia +``` + +## Facilitator Config + +The `HTTPFacilitatorClient` connects to a facilitator service that verifies and settles payments on-chain: + +```typescript +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + +// Or use multiple facilitators for redundancy +const facilitatorClient = [ + new HTTPFacilitatorClient({ url: primaryFacilitatorUrl }), + new HTTPFacilitatorClient({ url: backupFacilitatorUrl }), +]; +``` + +## Next Steps + +See [Advanced Examples](../advanced/) for: +- **Bazaar discovery** — make your API discoverable +- **Dynamic pricing** — price based on request context +- **Dynamic payTo** — route payments to different recipients +- **Lifecycle hooks** — custom logic on verify/settle +- **Custom tokens** — accept payments in custom tokens diff --git a/examples/typescript/servers/builder-code/eslint.config.js b/examples/typescript/servers/builder-code/eslint.config.js new file mode 100644 index 0000000000..e2fde7b3b8 --- /dev/null +++ b/examples/typescript/servers/builder-code/eslint.config.js @@ -0,0 +1,73 @@ +import js from "@eslint/js"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import jsdoc from "eslint-plugin-jsdoc"; +import importPlugin from "eslint-plugin-import"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + process: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + console: "readonly", + exports: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + jsdoc: jsdoc, + import: importPlugin, + }, + rules: { + ...ts.configs.recommended.rules, + "import/first": "error", + "prettier/prettier": "error", + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_$" }], + "jsdoc/tag-lines": ["error", "any", { startLines: 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"], + }, + }, +]; diff --git a/examples/typescript/servers/builder-code/index.ts b/examples/typescript/servers/builder-code/index.ts new file mode 100644 index 0000000000..def18ff7b1 --- /dev/null +++ b/examples/typescript/servers/builder-code/index.ts @@ -0,0 +1,68 @@ +import { config } from "dotenv"; +import express from "express"; +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { BUILDER_CODE, declareBuilderCodeExtension } from "@x402/extensions/builder-code"; +config(); + +const evmAddress = process.env.EVM_ADDRESS as `0x${string}`; +if (!evmAddress) { + console.error("❌ EVM_ADDRESS environment variable is required"); + process.exit(1); +} + +const facilitatorUrl = process.env.FACILITATOR_URL; +if (!facilitatorUrl) { + console.error("❌ FACILITATOR_URL environment variable is required"); + process.exit(1); +} + +const appBuilderCode = process.env.APP_BUILDER_CODE; +if (!appBuilderCode) { + console.error("❌ APP_BUILDER_CODE environment variable is required"); + process.exit(1); +} + +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + +const resourceServer = new x402ResourceServer(facilitatorClient).register( + "eip155:84532", + new ExactEvmScheme(), +); + +const app = express(); + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", + payTo: evmAddress, + }, + description: "Weather data", + mimeType: "application/json", + extensions: { + [BUILDER_CODE]: declareBuilderCodeExtension(appBuilderCode), + }, + }, + }, + resourceServer, + ), +); + +app.get("/weather", (req, res) => { + res.send({ + report: { + weather: "sunny", + temperature: 70, + }, + }); +}); + +app.listen(4021, () => { + console.log(`Server listening at http://localhost:${4021}`); +}); diff --git a/examples/typescript/servers/builder-code/package.json b/examples/typescript/servers/builder-code/package.json new file mode 100644 index 0000000000..be2e37a0bc --- /dev/null +++ b/examples/typescript/servers/builder-code/package.json @@ -0,0 +1,35 @@ +{ + "name": "@x402/server-builder-code-typescript", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx index.ts", + "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", + "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", + "lint": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts" + }, + "dependencies": { + "@x402/core": "workspace:*", + "@x402/evm": "workspace:*", + "@x402/express": "workspace:*", + "@x402/extensions": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.18.2" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@types/express": "^5.0.1", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "3.5.2", + "tsup": "^8.4.0", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/servers/builder-code/tsconfig.json b/examples/typescript/servers/builder-code/tsconfig.json new file mode 100644 index 0000000000..99fe25e8e7 --- /dev/null +++ b/examples/typescript/servers/builder-code/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "baseUrl": ".", + "types": ["node"] + }, + "include": [ + "index.ts", + "bazaar.ts", + "custom-money-definition.ts", + "dynamic-pay-to.ts", + "dynamic-price.ts", + "hooks.ts" + ] +} diff --git a/specs/extensions/builder_code.md b/specs/extensions/builder_code.md index 05c55d4e40..b9c8c60fa4 100644 --- a/specs/extensions/builder_code.md +++ b/specs/extensions/builder_code.md @@ -31,7 +31,7 @@ Wire order: `[cborData][cborLength (2B)][schemaId (1B)][ercMarker (16B)]` | --- | --------------- | --------------------------------------------------------------- | | `a` | string | App code — the application that exposed the paid endpoint | | `w` | string | Wallet code — the facilitator that settled the payment on-chain | -| `s` | array of string | Service codes — clients and intermediaries that participated | +| `s` | string | Service code — client-provided attribution (wrapped in a single-element array on wire) | All fields are optional. @@ -92,14 +92,14 @@ The application declares its builder code per-route in the payment middleware co ## `PaymentPayload` -The client echoes the builder code extension from `PaymentRequired` into its `PaymentPayload`, and may append its own code(s) to the `s` array. +The client echoes the server's app code (`a`) and attaches its own service code (`s`). ```json { "extensions": { "builder-code": { "a": "my_app", - "s": ["my_client"] + "s": "my_client" } } } @@ -115,7 +115,7 @@ The `w` (wallet) field is **not** set by the client. It is added by the facilita | ----- | ----------- | ---------------------------------- | -------------------------------------------------------- | | `a` | Application | Per-route middleware configuration | Identifies the application exposing the paid endpoint | | `w` | Facilitator | Settlement | Identifies the facilitator settling the payment on-chain | -| `s` | Client | Payment payload construction | Identifies the client(s) and intermediaries that participated | +| `s` | Client | Payment payload construction | Identifies the client or intermediary that participated | --- @@ -158,12 +158,12 @@ Client (App) Resource Server Facilitator 4. |--- request + payment ------->| | | extensions.builder-code: | | | { a: "my_app", | | - | s: ["my_client"] } | | + | s: "my_client" } | | | | | 5. | |--- verify/settle ----------->| | | extensions.builder-code: | | | { a: "my_app", | - | | s: ["my_client"] } | + | | s: "my_client" } | | | | 6. | | Facilitator adds w, | | | encodes CBOR suffix, | @@ -244,6 +244,7 @@ Invalid codes must be rejected at declaration time (application) and at construc The facilitator MUST verify that the `a` field echoed by the client in `PaymentPayload.extensions["builder-code"]` exactly matches the `a` field declared by the application in `PaymentRequired.extensions["builder-code"].info`. A mismatch indicates the client tampered with the attribution and the payment MUST be rejected. + ### Schema Validation The `schema` field uses JSON Schema Draft 2020-12. Facilitators should validate `info` against the provided schema. @@ -268,5 +269,5 @@ Off-chain parsers can extract builder code attribution from settlement calldata | Role | Responsibility | | --------------- | ----------------------------------------------------------------------------------------------------------- | | **Application** | Declares `a` (app code) per-route in the payment middleware configuration | -| **Client** | Echoes `a` from `PaymentRequired`; optionally populates `s` with its own service code(s) in `PaymentPayload` | +| **Client** | Echoes `a` from `PaymentRequired`; attaches its own service code as `s` in `PaymentPayload` | | **Facilitator** | Adds `w` (wallet code) at settlement, encodes the full CBOR suffix (`a`, `s`, `w`), appends to calldata | diff --git a/typescript/.changeset/cute-dogs-leave.md b/typescript/.changeset/cute-dogs-leave.md new file mode 100644 index 0000000000..8d4ac48486 --- /dev/null +++ b/typescript/.changeset/cute-dogs-leave.md @@ -0,0 +1,5 @@ +--- +'@x402/extensions': minor +--- + +Implemented builder-code extension diff --git a/typescript/.changeset/open-bobcats-run.md b/typescript/.changeset/open-bobcats-run.md new file mode 100644 index 0000000000..77affe4739 --- /dev/null +++ b/typescript/.changeset/open-bobcats-run.md @@ -0,0 +1,5 @@ +--- +'@x402/evm': minor +--- + +Added calldataSuffix support for builder-code extension diff --git a/typescript/packages/core/src/http/x402HTTPResourceServer.ts b/typescript/packages/core/src/http/x402HTTPResourceServer.ts index b5e3f5d34c..44d643ecb8 100644 --- a/typescript/packages/core/src/http/x402HTTPResourceServer.ts +++ b/typescript/packages/core/src/http/x402HTTPResourceServer.ts @@ -561,6 +561,25 @@ export class x402HTTPResourceServer { }; } + const extensionResult = this.ResourceServer.validateExtensions( + paymentRequired, + paymentPayload, + ); + if (!extensionResult.valid) { + const errorResponse = await this.ResourceServer.createPaymentRequiredResponse( + requirements, + resourceInfo, + extensionResult.invalidReason, + extensions, + transportContext, + paymentPayload, + ); + return { + type: "payment-error", + response: this.createHTTPResponse(errorResponse, false, paywallConfig), + }; + } + const verifyResult = await this.ResourceServer.verifyPayment( paymentPayload, matchingRequirements, diff --git a/typescript/packages/core/src/server/index.ts b/typescript/packages/core/src/server/index.ts index 8b1a80d139..7f4e53013c 100644 --- a/typescript/packages/core/src/server/index.ts +++ b/typescript/packages/core/src/server/index.ts @@ -13,6 +13,7 @@ export type { VerifiedPaymentCancelOptions, PaymentCancellationDispatcher, SettlementOverrides, + ExtensionValidationResult, SkipHandlerDirective, ResourceVerifyRespone, BeforeVerifyHook, diff --git a/typescript/packages/core/src/server/x402ResourceServer.ts b/typescript/packages/core/src/server/x402ResourceServer.ts index 6913d08876..504a0d7c0e 100644 --- a/typescript/packages/core/src/server/x402ResourceServer.ts +++ b/typescript/packages/core/src/server/x402ResourceServer.ts @@ -166,6 +166,10 @@ export type OnVerifiedPaymentCanceledHook = ( * partial settlement, such as the `upto` scheme. Using this with standard * x402 schemes (e.g., `exact`) will likely cause settlement verification to fail. */ +export type ExtensionValidationResult = + | { valid: true } + | { valid: false; invalidReason: "extension_echo_mismatch"; extensionKey: string }; + export interface SettlementOverrides { /** * Amount to settle. Supports three formats: @@ -1250,6 +1254,58 @@ export class x402ResourceServer { * @param paymentPayload - The payment payload * @returns Matching payment requirements or undefined */ + /** + * Validates optional client extension echoes against server-advertised extension info. + * When the client omits extensions entirely, validation passes. + * + * @param paymentRequired - Server payment required response used for matching + * @param paymentPayload - Client payment payload + * @returns Whether echoed extension info preserves server-advertised values + */ + validateExtensions( + paymentRequired: PaymentRequired, + paymentPayload: PaymentPayload, + ): ExtensionValidationResult { + if (paymentPayload.x402Version !== 2) { + return { valid: true }; + } + + const serverExtensions = paymentRequired.extensions; + if (!serverExtensions || Object.keys(serverExtensions).length === 0) { + return { valid: true }; + } + + const clientExtensions = paymentPayload.extensions; + if (!clientExtensions || Object.keys(clientExtensions).length === 0) { + return { valid: true }; + } + + for (const [key, echoedValue] of Object.entries(clientExtensions)) { + if (!Object.prototype.hasOwnProperty.call(serverExtensions, key)) { + continue; + } + + const advertisedInfo = getExtensionInfo(serverExtensions[key]); + const echoedInfo = getExtensionInfo(echoedValue); + if (!extensionInfoMatchesAdvertised(advertisedInfo, echoedInfo)) { + return { + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: key, + }; + } + } + + return { valid: true }; + } + + /** + * Finds the server-advertised requirement that matches a client payment payload. + * + * @param availableRequirements - Payment requirements advertised for the resource. + * @param paymentPayload - Signed payment payload from the client. + * @returns The matching requirement, or undefined when none match. + */ findMatchingRequirements( availableRequirements: PaymentRequirements[], paymentPayload: PaymentPayload, @@ -1517,6 +1573,35 @@ export class x402ResourceServer { } } +/** + * Normalizes an extension declaration or echo to its comparable `info` payload. + * + * @param value - Extension value that may wrap its payload under `info`. + * @returns The nested `info` value when present; otherwise `value` unchanged. + */ +function getExtensionInfo(value: unknown): unknown { + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.prototype.hasOwnProperty.call(value, "info") + ) { + return (value as Record).info; + } + return value; +} + +/** + * Returns whether a client-echoed extension payload preserves the server advertisement. + * + * @param advertised - Extension info advertised by the server. + * @param echoed - Extension info echoed back by the client. + * @returns True when `echoed` contains every field from `advertised`. + */ +function extensionInfoMatchesAdvertised(advertised: unknown, echoed: unknown): boolean { + return objectContainsSubset(advertised, echoed); +} + /** * Returns whether a client-selected requirement satisfies a server-advertised requirement. * diff --git a/typescript/packages/core/test/unit/http/x402HTTPResourceServer.errors.test.ts b/typescript/packages/core/test/unit/http/x402HTTPResourceServer.errors.test.ts index 0d7e3c1c6d..80ba196acf 100644 --- a/typescript/packages/core/test/unit/http/x402HTTPResourceServer.errors.test.ts +++ b/typescript/packages/core/test/unit/http/x402HTTPResourceServer.errors.test.ts @@ -113,4 +113,47 @@ describe("x402HTTPResourceServer facilitator response errors", () => { httpServer.processSettlement(buildPaymentPayload({ x402Version: 2, accepted }), accepted), ).rejects.toThrow(FacilitatorResponseError); }); + + it("returns payment-error when client extension echo mismatches before facilitator verify", async () => { + const httpServerWithExtensions = new x402HTTPResourceServer(resourceServer, { + "/api/test": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$1.00", + network, + }, + extensions: { + bazaar: { info: { tool: "search" } }, + }, + }, + }); + + const accepted = buildPaymentRequirements({ + scheme: "exact", + network, + payTo: "0xabc", + asset: "USDC", + amount: "1000000", + }); + const payload = buildPaymentPayload({ + x402Version: 2, + accepted, + extensions: { + bazaar: { info: { tool: "modified" } }, + }, + }); + + const result = await httpServerWithExtensions.processHTTPRequest({ + adapter: new MockHTTPAdapter({ + "payment-signature": encodePaymentSignatureHeader(payload), + }), + path: "/api/test", + method: "GET", + paymentHeader: encodePaymentSignatureHeader(payload), + }); + + expect(result.type).toBe("payment-error"); + expect(facilitator.verifyCalls).toHaveLength(0); + }); }); diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts index 8dbb3b591f..81fadbac1d 100644 --- a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts @@ -8,6 +8,7 @@ import { MockSchemeNetworkServer, buildPaymentPayload, buildPaymentRequirements, + buildPaymentRequired, buildSupportedResponse, buildVerifyResponse, buildSettleResponse, @@ -1534,6 +1535,122 @@ describe("x402ResourceServer", () => { }); }); + describe("validateExtensions", () => { + const serverExtensions = { + bazaar: { info: { tool: "search", version: 1 } }, + builder: { info: { code: "abc" } }, + }; + + it("passes when server has no extensions", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: undefined }); + const payload = buildPaymentPayload({ + extensions: { bazaar: { info: { tool: "wrong" } } }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("passes when client omits extensions", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload(); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("passes when client echoes with additive info fields", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + extensions: { + bazaar: { info: { tool: "search", version: 1, extraField: "ok" } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("passes when client echoes subset of server keys only", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + extensions: { + bazaar: { info: { tool: "search", version: 1 } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("passes when client includes client-only extension key", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + extensions: { + clientOnly: { info: { anything: true } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("passes with flat extension values and additive fields", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ + extensions: { bazaar: { tool: "search", version: 1 } }, + }); + const payload = buildPaymentPayload({ + extensions: { bazaar: { tool: "search", version: 1, extra: "ok" } }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("fails when client changes a server info field value", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + extensions: { + bazaar: { info: { tool: "search", version: 2 } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: "bazaar", + }); + }); + + it("fails when client deletes a server info field", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + extensions: { + bazaar: { info: { tool: "search" } }, + }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: "bazaar", + }); + }); + + it("passes for v1 payloads", () => { + const server = new x402ResourceServer(); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + x402Version: 1, + extensions: { bazaar: { info: { tool: "wrong" } } }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + }); + describe("findMatchingRequirements", () => { it("should match v2 requirements when server-declared terms are unchanged", () => { const server = new x402ResourceServer(); diff --git a/typescript/packages/extensions/src/builder-code/cbor.ts b/typescript/packages/extensions/src/builder-code/cbor.ts index d45ba272e1..36a200930a 100644 --- a/typescript/packages/extensions/src/builder-code/cbor.ts +++ b/typescript/packages/extensions/src/builder-code/cbor.ts @@ -22,6 +22,9 @@ import { ERC_8021_MARKER, SCHEMA_2_ID, type BuilderCodeExtensionData } from "./t * - "s" key (major type 3, length 1) → array of strings (related services) * * Uses hand-rolled CBOR to avoid adding a dependency. + * + * @param data - Builder code extension fields to encode + * @returns CBOR-encoded map bytes */ function encodeCborMap(data: BuilderCodeExtensionData): Uint8Array { const entries: Uint8Array[] = []; @@ -39,10 +42,10 @@ function encodeCborMap(data: BuilderCodeExtensionData): Uint8Array { entries.push(encodeCborString(data.w)); } - if (data.s && data.s.length > 0) { + if (data.s) { mapSize++; entries.push(encodeCborString("s")); - entries.push(encodeCborArray(data.s)); + entries.push(encodeCborArray([data.s])); } // CBOR map header @@ -65,6 +68,9 @@ function encodeCborMap(data: BuilderCodeExtensionData): Uint8Array { /** * Encodes a CBOR text string (major type 3). + * + * @param value - UTF-8 text to encode + * @returns CBOR-encoded text string bytes */ function encodeCborString(value: string): Uint8Array { const encoded = new TextEncoder().encode(value); @@ -77,6 +83,9 @@ function encodeCborString(value: string): Uint8Array { /** * Encodes a CBOR array of strings (major type 4). + * + * @param values - UTF-8 strings to encode as array elements + * @returns CBOR-encoded array bytes */ function encodeCborArray(values: string[]): Uint8Array { const header = encodeCborMajorType(4, values.length); // major type 4 = array @@ -104,6 +113,10 @@ function encodeCborArray(values: string[]): Uint8Array { * - 0-23: single byte (major type << 5 | value) * - 24-255: two bytes (major type << 5 | 24, value) * - 256-65535: three bytes (major type << 5 | 25, value high, value low) + * + * @param majorType - CBOR major type (0–7) + * @param value - Argument length or inline value for the header + * @returns CBOR header bytes */ function encodeCborMajorType(majorType: number, value: number): Uint8Array { const mt = majorType << 5; @@ -161,6 +174,12 @@ export function encodeBuilderCodeSuffix(data: BuilderCodeExtensionData): Hex { return `0x${bytesToHex(suffixBytes)}`; } +/** + * Converts a hex string (without 0x prefix) to bytes. + * + * @param hex - Hex-encoded string (two characters per byte) + * @returns Decoded byte array + */ function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { @@ -169,8 +188,124 @@ function hexToBytes(hex: string): Uint8Array { return bytes; } +/** + * Converts bytes to a lowercase hex string (without 0x prefix). + * + * @param bytes - Raw bytes to encode + * @returns Lowercase hex string + */ function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) + .map(b => b.toString(16).padStart(2, "0")) .join(""); } + +/** + * Parses ERC-8021 Schema 2 builder code attribution from settlement calldata. + * + * @param calldata - Full transaction input data + * @returns Decoded builder code fields, or undefined if no valid suffix is present + */ +export function parseBuilderCodeSuffixFromCalldata( + calldata: Hex, +): BuilderCodeExtensionData | undefined { + const hex = calldata.startsWith("0x") ? calldata.slice(2) : calldata; + const markerPos = hex.lastIndexOf(ERC_8021_MARKER.toLowerCase()); + if (markerPos < 6) { + return undefined; + } + + if (parseInt(hex.slice(markerPos - 2, markerPos), 16) !== SCHEMA_2_ID) { + return undefined; + } + + const cborLength = parseInt(hex.slice(markerPos - 6, markerPos - 2), 16); + const suffixStart = markerPos - 6 - cborLength * 2; + if (suffixStart < 0 || suffixStart + (cborLength + 19) * 2 !== hex.length) { + return undefined; + } + + const bytes = hexToBytes(hex.slice(suffixStart, markerPos - 6)); + let o = 0; + + if (bytes[o] >> 5 !== 5) { + return undefined; + } + + const mapInfo = bytes[o++] & 0x1f; + const mapSize = mapInfo <= 23 ? mapInfo : mapInfo === 24 ? bytes[o++] : undefined; + if (mapSize === undefined) { + return undefined; + } + + const result: BuilderCodeExtensionData = {}; + for (let entry = 0; entry < mapSize; entry++) { + if (bytes[o] >> 5 !== 3) { + return undefined; + } + + const keyInfo = bytes[o++] & 0x1f; + const keyLen = keyInfo <= 23 ? keyInfo : keyInfo === 24 ? bytes[o++] : undefined; + if (keyLen === undefined) { + return undefined; + } + + const key = new TextDecoder().decode(bytes.subarray(o, o + keyLen)); + o += keyLen; + + if (key === "a" || key === "w") { + if (bytes[o] >> 5 !== 3) { + return undefined; + } + + const valueInfo = bytes[o++] & 0x1f; + const valueLen = valueInfo <= 23 ? valueInfo : valueInfo === 24 ? bytes[o++] : undefined; + if (valueLen === undefined) { + return undefined; + } + + result[key] = new TextDecoder().decode(bytes.subarray(o, o + valueLen)); + o += valueLen; + continue; + } + + if (key === "s") { + if (bytes[o] >> 5 !== 4) { + return undefined; + } + + const arrayInfo = bytes[o++] & 0x1f; + const arraySize = arrayInfo <= 23 ? arrayInfo : arrayInfo === 24 ? bytes[o++] : undefined; + if (arraySize === undefined) { + return undefined; + } + + let firstCode: string | undefined; + for (let i = 0; i < arraySize; i++) { + if (bytes[o] >> 5 !== 3) { + return undefined; + } + + const itemInfo = bytes[o++] & 0x1f; + const itemLen = itemInfo <= 23 ? itemInfo : itemInfo === 24 ? bytes[o++] : undefined; + if (itemLen === undefined) { + return undefined; + } + + if (i === 0) { + firstCode = new TextDecoder().decode(bytes.subarray(o, o + itemLen)); + } + o += itemLen; + } + + if (firstCode) { + result.s = firstCode; + } + continue; + } + + return undefined; + } + + return result; +} diff --git a/typescript/packages/extensions/src/builder-code/client.ts b/typescript/packages/extensions/src/builder-code/client.ts new file mode 100644 index 0000000000..6e2770437f --- /dev/null +++ b/typescript/packages/extensions/src/builder-code/client.ts @@ -0,0 +1,67 @@ +/** + * Client-side extension for the Builder Code Extension. + * + * Echoes the server's app code (`a`) and attaches the client's + * service code (`s`) to the payment payload. + */ + +import type { ClientExtension } from "@x402/core/client"; +import type { PaymentPayload, PaymentRequired } from "@x402/core/types"; +import { BUILDER_CODE, BUILDER_CODE_PATTERN } from "./types"; + +/** + * Client extension that adds builder-code attribution to payment payloads. + * + * @example + * ```typescript + * import { BuilderCodeClientExtension } from '@x402/extensions/builder-code'; + * + * const client = new x402Client(); + * client.registerExtension(new BuilderCodeClientExtension("bc_my_client")); + * ``` + */ +export class BuilderCodeClientExtension implements ClientExtension { + readonly key = BUILDER_CODE; + private readonly serviceCode: string; + + /** + * Creates a client extension that attaches the given service code to payments. + * + * @param serviceCode - Client service code (`s`), 1-32 lowercase alphanumeric/underscore characters + */ + constructor(serviceCode: string) { + if (!BUILDER_CODE_PATTERN.test(serviceCode)) { + throw new Error( + `Invalid builder code: "${serviceCode}". ` + + `Must be 1-32 characters, lowercase alphanumeric and underscores only.`, + ); + } + this.serviceCode = serviceCode; + } + + /** + * Echoes the server app code (`a`) and attaches this client's service code (`s`). + * + * @param payload - Payment payload to enrich + * @param paymentRequired - Server payment requirements (source of app code `a`) + * @returns Payment payload with builder-code extension data + */ + async enrichPaymentPayload( + payload: PaymentPayload, + paymentRequired: PaymentRequired, + ): Promise { + const serverExt = paymentRequired.extensions?.[BUILDER_CODE] as + | Record + | undefined; + const info = serverExt?.info as Record | undefined; + const a = typeof info?.a === "string" ? info.a : undefined; + + return { + ...payload, + extensions: { + ...payload.extensions, + [BUILDER_CODE]: { ...(a && { a }), s: this.serviceCode }, + }, + }; + } +} diff --git a/typescript/packages/extensions/src/builder-code/facilitator.ts b/typescript/packages/extensions/src/builder-code/facilitator.ts index 79b8300b7f..85c3c9ecc8 100644 --- a/typescript/packages/extensions/src/builder-code/facilitator.ts +++ b/typescript/packages/extensions/src/builder-code/facilitator.ts @@ -1,11 +1,9 @@ /** * Facilitator-side extension for the Builder Code Extension. * - * At settlement time, the facilitator: - * 1. Reads builder code data from the payment payload extensions - * 2. Adds its own builder code as the "w" (wallet) field - * 3. Encodes the combined data as an ERC-8021 Schema 2 CBOR suffix - * 4. The suffix is appended to the settlement transaction calldata + * At settlement time, the facilitator always encodes its wallet code into the + * ERC-8021 suffix. App code (`a`) and service code (`s`) are read from the + * client payment payload extensions. */ import type { FacilitatorExtension } from "@x402/core/types"; @@ -16,14 +14,42 @@ import { BUILDER_CODE_PATTERN, type BuilderCodeExtensionData, type BuilderCodeFacilitatorConfig, + type DataSuffixContext, } from "./types"; /** - * Facilitator extension that manages builder code attribution at settlement time. + * Reads the client builder-code extension object from payment-payload extensions. + * + * @param extensions - Extensions map from PaymentPayload + * @returns Raw builder-code extension object, or undefined if absent + */ +function extractClientExtension( + extensions?: Record, +): Record | undefined { + const raw = extensions?.[BUILDER_CODE]; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + return raw as Record; +} + +/** + * Normalizes `s` from the client payload — accepts a string or first-valid-entry from an array. * - * Register this with the x402Facilitator to enable builder code support. - * The extension reads builder code data from payment payloads and provides - * the encoded ERC-8021 suffix for the settlement mechanism to append. + * @param raw - Client-provided service code value (string or array of strings) + * @returns Valid service code, or undefined if missing or invalid + */ +function resolveServiceCode(raw: unknown): string | undefined { + if (typeof raw === "string" && BUILDER_CODE_PATTERN.test(raw)) return raw; + if (Array.isArray(raw)) { + const first = raw.find( + (v): v is string => typeof v === "string" && BUILDER_CODE_PATTERN.test(v), + ); + return first; + } + return undefined; +} + +/** + * Facilitator extension that manages builder code attribution at settlement time. * * @example * ```typescript @@ -39,6 +65,11 @@ export class BuilderCodeFacilitatorExtension implements FacilitatorExtension { readonly key = BUILDER_CODE; private readonly config: BuilderCodeFacilitatorConfig; + /** + * Creates a facilitator extension that encodes the facilitator wallet code at settlement. + * + * @param config - Facilitator builder-code configuration (wallet code `w`) + */ constructor(config: BuilderCodeFacilitatorConfig) { if (!BUILDER_CODE_PATTERN.test(config.builderCode)) { throw new Error( @@ -50,26 +81,29 @@ export class BuilderCodeFacilitatorExtension implements FacilitatorExtension { } /** - * Builds the ERC-8021 Schema 2 calldata suffix from payment payload extensions. + * Builds the ERC-8021 Schema 2 calldata suffix for a settlement transaction. * - * Reads "a" (app/service code) and "s" (related service codes) from the - * payment payload, adds the facilitator's own code as "w" (wallet), and - * encodes as Schema 2 CBOR. + * - `a` and `s` are read from the client's payment payload extensions. + * - `w` is always the facilitator's own code. * - * @param payloadExtensions - The extensions from the payment payload - * @returns Hex-encoded suffix to append to settlement calldata, or undefined if no builder codes + * @param ctx - Settlement context with payment-payload extensions + * @returns Hex-encoded ERC-8021 builder-code calldata suffix */ - buildCalldataSuffix(payloadExtensions?: Record): Hex | undefined { - const extData = payloadExtensions?.[BUILDER_CODE] as - | BuilderCodeExtensionData - | undefined; + buildDataSuffix(ctx: DataSuffixContext): Hex { + const clientExt = extractClientExtension(ctx.paymentPayload.extensions); + + const a = + typeof clientExt?.a === "string" && BUILDER_CODE_PATTERN.test(clientExt.a) + ? clientExt.a + : undefined; + const s = resolveServiceCode(clientExt?.s); - const suffixData: BuilderCodeExtensionData = { - a: extData?.a, + const data: BuilderCodeExtensionData = { w: this.config.builderCode, - s: extData?.s ? [...extData.s] : undefined, + ...(a && { a }), + ...(s && { s }), }; - return encodeBuilderCodeSuffix(suffixData); + return encodeBuilderCodeSuffix(data); } } diff --git a/typescript/packages/extensions/src/builder-code/index.ts b/typescript/packages/extensions/src/builder-code/index.ts index b10f0ecea6..0089dedda9 100644 --- a/typescript/packages/extensions/src/builder-code/index.ts +++ b/typescript/packages/extensions/src/builder-code/index.ts @@ -4,12 +4,10 @@ * Enables attribution tracking for x402 payments by appending ERC-8021 * Schema 2 builder codes to settlement transaction calldata. * - * Two parties attach their builder code: - * - Service (server): Declares as "a" (app) in 402 response via declareBuilderCodeExtension() - * - Facilitator: Adds as "w" (wallet) at settlement via BuilderCodeFacilitatorExtension - * - * The service can optionally include related on-chain services in the "s" array - * (e.g., Morpho, Aerodrome) to attribute protocols it depends on. + * Three parties attach their builder code: + * - Server: Declares `a` (app) in 402 response via declareBuilderCodeExtension() + * - Client: Echoes `a` and adds `s` (service) via BuilderCodeClientExtension + * - Facilitator: Adds `w` (wallet) at settlement via BuilderCodeFacilitatorExtension * * ## Usage * @@ -18,15 +16,17 @@ * ```typescript * import { declareBuilderCodeExtension, BUILDER_CODE } from '@x402/extensions/builder-code'; * - * // In paywall config extensions * extensions: { * [BUILDER_CODE]: declareBuilderCodeExtension("bc_my_service"), * } + * ``` * - * // With related on-chain services - * extensions: { - * [BUILDER_CODE]: declareBuilderCodeExtension("bc_my_service", ["bc_morpho", "bc_aerodrome"]), - * } + * ### For Clients + * + * ```typescript + * import { BuilderCodeClientExtension } from '@x402/extensions/builder-code'; + * + * client.registerExtension(new BuilderCodeClientExtension("bc_my_client")); * ``` * * ### For Facilitators @@ -34,7 +34,6 @@ * ```typescript * import { BuilderCodeFacilitatorExtension } from '@x402/extensions/builder-code'; * - * const facilitator = new x402Facilitator(); * facilitator.registerExtension(new BuilderCodeFacilitatorExtension({ * builderCode: "bc_my_facilitator", * })); @@ -45,23 +44,24 @@ export type { BuilderCodeExtensionData, BuilderCodeFacilitatorConfig, + DataSuffixContext, } from "./types"; -export { - BUILDER_CODE, - BUILDER_CODE_PATTERN, - ERC_8021_MARKER, - SCHEMA_2_ID, -} from "./types"; +export { BUILDER_CODE, BUILDER_CODE_PATTERN, ERC_8021_MARKER, SCHEMA_2_ID } from "./types"; // CBOR encoding -export { encodeBuilderCodeSuffix } from "./cbor"; +export { encodeBuilderCodeSuffix, parseBuilderCodeSuffixFromCalldata } from "./cbor"; // Resource Server export { + BUILDER_CODE_SCHEMA, + type BuilderCodeRequiredExtension, declareBuilderCodeExtension, builderCodeResourceServerExtension, -} from "./resourceServer"; +} from "./server"; + +// Client +export { BuilderCodeClientExtension } from "./client"; // Facilitator export { BuilderCodeFacilitatorExtension } from "./facilitator"; diff --git a/typescript/packages/extensions/src/builder-code/resourceServer.ts b/typescript/packages/extensions/src/builder-code/resourceServer.ts deleted file mode 100644 index 891ff97f43..0000000000 --- a/typescript/packages/extensions/src/builder-code/resourceServer.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Resource Server utilities for the Builder Code Extension. - * - * Services use this to declare their builder code in the 402 response. - * The service's code goes in the "a" (app) field since the service is - * the application exposing the x402 endpoint. Optionally, the service - * can include related on-chain services in the "s" array. - */ - -import type { ResourceServerExtension } from "@x402/core/types"; -import { - BUILDER_CODE, - BUILDER_CODE_PATTERN, - type BuilderCodeExtensionData, -} from "./types"; - -/** - * Declares the builder-code extension for inclusion in PaymentRequired.extensions. - * - * The service's builder code is placed in the "a" (app) field — the service - * is the application that exposed the x402 endpoint. Related on-chain services - * the app depends on (e.g., Morpho, Aerodrome) can be listed in the "s" array. - * - * @param appCode - The service's builder code (e.g., "bc_weather_svc") - * @param serviceCodes - Optional array of related service builder codes - * @returns A BuilderCodeExtensionData object for PaymentRequired.extensions - * - * @example - * ```typescript - * import { declareBuilderCodeExtension, BUILDER_CODE } from '@x402/extensions/builder-code'; - * - * // Simple: just the service's own code - * extensions: { - * [BUILDER_CODE]: declareBuilderCodeExtension("bc_weather_svc"), - * } - * - * // With related services - * extensions: { - * [BUILDER_CODE]: declareBuilderCodeExtension("bc_lending_app", ["bc_morpho", "bc_aerodrome"]), - * } - * ``` - */ -export function declareBuilderCodeExtension( - appCode: string, - serviceCodes?: string[], -): BuilderCodeExtensionData { - if (!BUILDER_CODE_PATTERN.test(appCode)) { - throw new Error( - `Invalid builder code: "${appCode}". ` + - `Must be 1-32 characters, lowercase alphanumeric and underscores only.`, - ); - } - - if (serviceCodes) { - for (const code of serviceCodes) { - if (!BUILDER_CODE_PATTERN.test(code)) { - throw new Error( - `Invalid service builder code: "${code}". ` + - `Must be 1-32 characters, lowercase alphanumeric and underscores only.`, - ); - } - } - } - - const data: BuilderCodeExtensionData = { - a: appCode, - }; - - if (serviceCodes && serviceCodes.length > 0) { - data.s = serviceCodes; - } - - return data; -} - -/** - * ResourceServerExtension implementation for builder-code. - * - * Register this with the resource server to advertise builder code support. - * The actual builder code value is set via declareBuilderCodeExtension() - * in the paywall configuration. - */ -export const builderCodeResourceServerExtension: ResourceServerExtension = { - key: BUILDER_CODE, -}; diff --git a/typescript/packages/extensions/src/builder-code/server.ts b/typescript/packages/extensions/src/builder-code/server.ts new file mode 100644 index 0000000000..0041647740 --- /dev/null +++ b/typescript/packages/extensions/src/builder-code/server.ts @@ -0,0 +1,61 @@ +/** + * Resource Server utilities for the Builder Code Extension. + */ + +import type { ResourceServerExtension } from "@x402/core/types"; +import { BUILDER_CODE, BUILDER_CODE_PATTERN, type BuilderCodeExtensionData } from "./types"; + +export const BUILDER_CODE_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + a: { + type: "string", + pattern: "^[a-z0-9_]{1,32}$", + description: "App builder code", + }, + w: { + type: "string", + pattern: "^[a-z0-9_]{1,32}$", + description: "Wallet builder code", + }, + s: { + type: "array", + items: { + type: "string", + pattern: "^[a-z0-9_]{1,32}$", + }, + description: "Service builder codes", + }, + }, + additionalProperties: false, +} as const; + +export interface BuilderCodeRequiredExtension { + info: BuilderCodeExtensionData; + schema: typeof BUILDER_CODE_SCHEMA; +} + +/** + * Declares the builder-code extension for inclusion in PaymentRequired.extensions. + * + * @param appCode - The service's builder code (e.g., "bc_weather_svc") + * @returns Extension declaration with info and schema for PaymentRequired.extensions + */ +export function declareBuilderCodeExtension(appCode: string): BuilderCodeRequiredExtension { + if (!BUILDER_CODE_PATTERN.test(appCode)) { + throw new Error( + `Invalid builder code: "${appCode}". ` + + `Must be 1-32 characters, lowercase alphanumeric and underscores only.`, + ); + } + + return { + info: { a: appCode }, + schema: BUILDER_CODE_SCHEMA, + }; +} + +export const builderCodeResourceServerExtension: ResourceServerExtension = { + key: BUILDER_CODE, +}; diff --git a/typescript/packages/extensions/src/builder-code/types.ts b/typescript/packages/extensions/src/builder-code/types.ts index 457c315e0f..42ea2697d9 100644 --- a/typescript/packages/extensions/src/builder-code/types.ts +++ b/typescript/packages/extensions/src/builder-code/types.ts @@ -5,6 +5,8 @@ * ERC-8021 Schema 2 builder codes to settlement transaction calldata. */ +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; + /** * Extension identifier constant */ @@ -49,12 +51,10 @@ export interface BuilderCodeExtensionData { w?: string; /** - * Service builder codes — related on-chain services the app depends on. - * Maps to the "s" field in ERC-8021 Schema 2. - * Optionally set by the service to attribute protocols it interacts with - * (e.g., Morpho for lending, Aerodrome for swaps). + * Service builder code — client-provided attribution code. + * Maps to the "s" field in ERC-8021 Schema 2 (wrapped in a single-element array on wire). */ - s?: string[]; + s?: string; } /** @@ -66,3 +66,8 @@ export interface BuilderCodeFacilitatorConfig { */ builderCode: string; } + +export interface DataSuffixContext { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; +} diff --git a/typescript/packages/extensions/test/builder-code.test.ts b/typescript/packages/extensions/test/builder-code.test.ts new file mode 100644 index 0000000000..04f6abb159 --- /dev/null +++ b/typescript/packages/extensions/test/builder-code.test.ts @@ -0,0 +1,240 @@ +/** + * Tests for Builder Code Extension (ERC-8021) + */ + +import { describe, it, expect } from "vitest"; +import type { PaymentPayload, PaymentRequired } from "@x402/core/types"; +import { + BUILDER_CODE, + declareBuilderCodeExtension, + BuilderCodeClientExtension, + BuilderCodeFacilitatorExtension, + encodeBuilderCodeSuffix, + parseBuilderCodeSuffixFromCalldata, + type DataSuffixContext, +} from "../src/builder-code"; + +const APP = "bc_my_app"; +const SERVICE = "bc_my_client"; +const WALLET = "bc_my_facilitator"; + +/** + * Builds a minimal PaymentRequired with an optional builder-code app declaration. + * + * @param appCode - Server app code; omitted when the extension should be absent + * @returns PaymentRequired for client enrichment tests + */ +function paymentRequiredWithApp(appCode?: string): PaymentRequired { + return { + x402Version: 2, + accepts: [], + extensions: appCode ? { [BUILDER_CODE]: declareBuilderCodeExtension(appCode) } : undefined, + }; +} + +/** + * Minimal payment payload for extension enrichment tests. + * + * @returns Base payment payload without extensions + */ +function basePayload(): PaymentPayload { + return { + x402Version: 2, + resource: { url: "https://example.com/resource" }, + accepted: { + scheme: "exact", + network: "eip155:8453", + amount: "1000", + asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + payTo: "0x0000000000000000000000000000000000000001", + }, + payload: {}, + }; +} + +/** + * Builds facilitator data-suffix context from optional extension maps. + * + * @param overrides - Extension maps for payment payload + * @param overrides.paymentPayloadExtensions - Client-side builder-code payload + * @returns Context passed to BuilderCodeFacilitatorExtension.buildDataSuffix + */ +function suffixContext(overrides: { + paymentPayloadExtensions?: Record; +}): DataSuffixContext { + return { + paymentPayload: { + ...basePayload(), + extensions: overrides.paymentPayloadExtensions, + }, + paymentRequirements: { + scheme: "exact", + network: "eip155:8453", + amount: "1000", + asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + payTo: "0x0000000000000000000000000000000000000001", + }, + }; +} + +/** + * Runs buildDataSuffix and parses attribution from synthetic calldata. + * + * @param ctx - Facilitator data-suffix context + * @returns Decoded builder-code fields from the produced suffix + */ +function parsedFromFacilitator( + ctx: DataSuffixContext, +): ReturnType { + const ext = new BuilderCodeFacilitatorExtension({ builderCode: WALLET }); + const suffix = ext.buildDataSuffix(ctx); + return parseBuilderCodeSuffixFromCalldata(`0xdeadbeef${suffix.slice(2)}` as `0x${string}`); +} + +describe("Builder Code Extension", () => { + describe("declareBuilderCodeExtension", () => { + it("rejects invalid app codes", () => { + expect(() => declareBuilderCodeExtension("INVALID")).toThrow(/Invalid builder code/); + }); + }); + + describe("BuilderCodeClientExtension", () => { + it("rejects invalid service codes", () => { + expect(() => new BuilderCodeClientExtension("Bad-Code")).toThrow(/Invalid builder code/); + }); + + it("echoes server app code and attaches service code", async () => { + const client = new BuilderCodeClientExtension(SERVICE); + const enriched = await client.enrichPaymentPayload!( + basePayload(), + paymentRequiredWithApp(APP), + ); + + expect(enriched.extensions?.[BUILDER_CODE]).toEqual({ a: APP, s: SERVICE }); + }); + + it("attaches only service code when server omits builder-code", async () => { + const client = new BuilderCodeClientExtension(SERVICE); + const enriched = await client.enrichPaymentPayload!(basePayload(), paymentRequiredWithApp()); + + expect(enriched.extensions?.[BUILDER_CODE]).toEqual({ s: SERVICE }); + }); + + it("does not echo non-string app codes from server info", async () => { + const client = new BuilderCodeClientExtension(SERVICE); + const paymentRequired: PaymentRequired = { + x402Version: 2, + accepts: [], + extensions: { + [BUILDER_CODE]: { info: { a: 123 }, schema: {} }, + }, + }; + + const enriched = await client.enrichPaymentPayload!(basePayload(), paymentRequired); + expect(enriched.extensions?.[BUILDER_CODE]).toEqual({ s: SERVICE }); + }); + + it("preserves unrelated payload extensions", async () => { + const client = new BuilderCodeClientExtension(SERVICE); + const payload = { + ...basePayload(), + extensions: { other: { kept: true } }, + }; + + const enriched = await client.enrichPaymentPayload!(payload, paymentRequiredWithApp(APP)); + + expect(enriched.extensions?.other).toEqual({ kept: true }); + expect(enriched.extensions?.[BUILDER_CODE]).toEqual({ a: APP, s: SERVICE }); + }); + }); + + describe("BuilderCodeFacilitatorExtension", () => { + it("rejects invalid wallet codes", () => { + expect(() => new BuilderCodeFacilitatorExtension({ builderCode: "X" })).toThrow( + /Invalid builder code/, + ); + }); + + it("always encodes the facilitator wallet code", () => { + const parsed = parsedFromFacilitator(suffixContext({})); + expect(parsed).toEqual({ w: WALLET }); + }); + + it("uses client app code and service code", () => { + const parsed = parsedFromFacilitator( + suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { a: APP, s: SERVICE }, + }, + }), + ); + + expect(parsed).toEqual({ w: WALLET, a: APP, s: SERVICE }); + }); + + it("accepts client app code when present in the payload", () => { + const parsed = parsedFromFacilitator( + suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { a: APP, s: SERVICE }, + }, + }), + ); + + expect(parsed).toEqual({ w: WALLET, a: APP, s: SERVICE }); + }); + + it("picks the first valid entry from a service code array", () => { + const parsed = parsedFromFacilitator( + suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { s: ["INVALID", SERVICE, "bc_other"] }, + }, + }), + ); + + expect(parsed).toEqual({ w: WALLET, s: SERVICE }); + }); + + it("ignores invalid client service codes", () => { + const parsed = parsedFromFacilitator( + suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { s: "Also_Invalid" }, + }, + }), + ); + + expect(parsed).toEqual({ w: WALLET }); + }); + + it("reads app code from the client payload extension", () => { + const parsed = parsedFromFacilitator( + suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { a: APP }, + }, + }), + ); + + expect(parsed).toEqual({ w: WALLET, a: APP }); + }); + }); + + describe("suffix encode and parse", () => { + it("round-trips all attribution fields through calldata", () => { + const suffix = encodeBuilderCodeSuffix({ a: APP, w: WALLET, s: SERVICE }); + const calldata = `0xdeadbeef${suffix.slice(2)}` as `0x${string}`; + + expect(parseBuilderCodeSuffixFromCalldata(calldata)).toEqual({ + a: APP, + w: WALLET, + s: SERVICE, + }); + }); + + it("returns undefined when calldata has no ERC-8021 suffix", () => { + expect(parseBuilderCodeSuffixFromCalldata("0xdeadbeef")).toBeUndefined(); + }); + }); +}); diff --git a/typescript/packages/extensions/test/integrations/builder-code.test.ts b/typescript/packages/extensions/test/integrations/builder-code.test.ts new file mode 100644 index 0000000000..d65c3ba549 --- /dev/null +++ b/typescript/packages/extensions/test/integrations/builder-code.test.ts @@ -0,0 +1,125 @@ +/** + * Integration tests for Builder Code Extension in the x402 payment flow. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { x402Client } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { x402ResourceServer } from "@x402/core/server"; +import { + buildCashPaymentRequirements, + CashFacilitatorClient, + CashSchemeNetworkClient, + CashSchemeNetworkFacilitator, + CashSchemeNetworkServer, +} from "../../../core/test/mocks"; +import { + BUILDER_CODE, + BuilderCodeClientExtension, + BuilderCodeFacilitatorExtension, + declareBuilderCodeExtension, + parseBuilderCodeSuffixFromCalldata, + type BuilderCodeFacilitatorExtension, +} from "../../src/builder-code"; + +const APP = "bc_weather_svc"; +const SERVICE = "bc_mobile_app"; +const WALLET = "bc_facilitator"; + +describe("Builder Code Integration Tests", () => { + let client: x402Client; + let server: x402ResourceServer; + let facilitator: x402Facilitator; + + beforeEach(async () => { + client = new x402Client() + .register("x402:cash", new CashSchemeNetworkClient("payer")) + .registerExtension(new BuilderCodeClientExtension(SERVICE)); + + facilitator = new x402Facilitator() + .register("x402:cash", new CashSchemeNetworkFacilitator()) + .registerExtension(new BuilderCodeFacilitatorExtension({ builderCode: WALLET })); + + const facilitatorClient = new CashFacilitatorClient(facilitator); + server = new x402ResourceServer(facilitatorClient); + server.register("x402:cash", new CashSchemeNetworkServer()); + await server.initialize(); + }); + + it("enriches payment payload when server declares builder-code", async () => { + const accepts = [buildCashPaymentRequirements("merchant@example.com", "USD", "1")]; + const resource = { + url: "https://example.com/api/weather", + description: "Weather API", + mimeType: "application/json", + }; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + paymentRequired.extensions = { + [BUILDER_CODE]: declareBuilderCodeExtension(APP), + }; + + const paymentPayload = await client.createPaymentPayload(paymentRequired); + + expect(paymentPayload.extensions?.[BUILDER_CODE]).toEqual({ a: APP, s: SERVICE }); + }); + + it("does not enrich when builder-code is absent from payment required", async () => { + const accepts = [buildCashPaymentRequirements("merchant@example.com", "USD", "1")]; + const resource = { + url: "https://example.com/api/weather", + description: "Weather API", + mimeType: "application/json", + }; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + + const paymentPayload = await client.createPaymentPayload(paymentRequired); + + expect(paymentPayload.extensions?.[BUILDER_CODE]).toBeUndefined(); + }); + + it("produces a parseable settlement suffix from client and server extensions", async () => { + const accepts = [buildCashPaymentRequirements("merchant@example.com", "USD", "1")]; + const resource = { + url: "https://example.com/api/weather", + description: "Weather API", + mimeType: "application/json", + }; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + paymentRequired.extensions = { + [BUILDER_CODE]: declareBuilderCodeExtension(APP), + }; + + const paymentPayload = await client.createPaymentPayload(paymentRequired); + const builderExt = facilitator.getExtension(BUILDER_CODE)!; + + const suffix = builderExt.buildDataSuffix!({ + paymentPayload, + paymentRequirements: paymentPayload.accepted, + }); + + const parsed = parseBuilderCodeSuffixFromCalldata(`0x${"00".repeat(4)}${suffix.slice(2)}`); + expect(parsed).toEqual({ w: WALLET, a: APP, s: SERVICE }); + }); + + it("settlement suffix encodes only wallet code when server did not declare builder-code", async () => { + const accepts = [buildCashPaymentRequirements("merchant@example.com", "USD", "1")]; + const resource = { + url: "https://example.com/api/weather", + description: "Weather API", + mimeType: "application/json", + }; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + + const paymentPayload = await client.createPaymentPayload(paymentRequired); + expect(paymentPayload.extensions?.[BUILDER_CODE]).toBeUndefined(); + + const builderExt = facilitator.getExtension(BUILDER_CODE)!; + const suffix = builderExt.buildDataSuffix!({ + paymentPayload, + paymentRequirements: paymentPayload.accepted, + }); + + const parsed = parseBuilderCodeSuffixFromCalldata(`0x${"00".repeat(4)}${suffix.slice(2)}`); + expect(parsed).toEqual({ w: WALLET }); + }); +}); diff --git a/typescript/packages/mcp/src/server/paymentWrapper.ts b/typescript/packages/mcp/src/server/paymentWrapper.ts index 14ff1a4d93..9a8ac3c3b6 100644 --- a/typescript/packages/mcp/src/server/paymentWrapper.ts +++ b/typescript/packages/mcp/src/server/paymentWrapper.ts @@ -246,6 +246,21 @@ export function createPaymentWrapper( ); } + const extensionResult = resourceServer.validateExtensions( + paymentRequiredForMatch, + paymentPayload, + ); + if (!extensionResult.valid) { + return createPaymentRequiredResult( + resourceServer, + toolName, + config, + extensionResult.invalidReason, + transportContext, + paymentPayload, + ); + } + const extMap = config.extensions ?? {}; const verifyResult = await resourceServer.verifyPayment( paymentPayload, diff --git a/typescript/packages/mcp/test/unit/server.test.ts b/typescript/packages/mcp/test/unit/server.test.ts index 25ee708038..e84e8ec3e9 100644 --- a/typescript/packages/mcp/test/unit/server.test.ts +++ b/typescript/packages/mcp/test/unit/server.test.ts @@ -17,6 +17,7 @@ import type { interface MockResourceServer { findMatchingRequirements: ReturnType; + validateExtensions: ReturnType; verifyPayment: ReturnType; settlePayment: ReturnType; createPaymentRequiredResponse: ReturnType; @@ -87,6 +88,7 @@ function createMockResourceServer(): MockResourceServer { const cancel = vi.fn().mockResolvedValue(undefined); return { findMatchingRequirements: vi.fn().mockReturnValue(mockPaymentRequirements), + validateExtensions: vi.fn().mockReturnValue({ valid: true }), verifyPayment: vi.fn().mockResolvedValue(mockVerifyResponse), settlePayment: vi.fn().mockResolvedValue(mockSettleResponse), createPaymentRequiredResponse: vi.fn().mockResolvedValue(mockPaymentRequired), diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts index 37054d9807..d1e2028444 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts @@ -38,6 +38,7 @@ export function buildVoucherClaimArgs(claims: BatchSettlementClaimPayload["claim * @param payload - Claim payload containing voucher claims and optional authorizer signature. * @param requirements - Payment requirements for network identification. * @param authorizerSigner - Dedicated key for producing `ClaimBatch` EIP-712 signatures. + * @param dataSuffix - Optional hex suffix appended to the claim transaction. * @returns A {@link SettleResponse} with the transaction hash on success. */ export async function executeClaimWithSignature( @@ -45,6 +46,7 @@ export async function executeClaimWithSignature( payload: BatchSettlementClaimPayload, requirements: PaymentRequirements, authorizerSigner: AuthorizerSigner, + dataSuffix?: `0x${string}`, ): Promise { const network = requirements.network; const claimArgs = buildVoucherClaimArgs(payload.claims); @@ -91,6 +93,7 @@ export async function executeClaimWithSignature( abi: batchSettlementABI, functionName: "claimWithSignature", args: [claimArgs, sig], + dataSuffix, }); const receipt = await signer.waitForTransactionReceipt({ hash: tx }); diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts index 989c6c4e94..68ed0c2d5d 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts @@ -14,6 +14,7 @@ import { type Erc20ApprovalGasSponsoringFacilitatorExtension, type Erc20ApprovalGasSponsoringSigner, } from "../../exact/extensions"; +import { appendDataSuffix } from "../../shared/extensions"; import { validateErc20ApprovalForPayment } from "../../shared/erc20approval"; import { validateEip2612PermitForPayment, splitEip2612Signature } from "../../shared/permit2"; import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../../constants"; @@ -230,24 +231,28 @@ export async function resolvePermit2DepositBranch( * * @param payload - Batch deposit payload. * @param collectorData - Encoded Permit2 collector data. + * @param dataSuffix - Optional hex suffix appended to the deposit calldata. * @returns Transaction request for the extension signer. */ export function buildDepositTransaction( payload: BatchSettlementDepositPayload, collectorData: `0x${string}`, + dataSuffix?: `0x${string}`, ): { to: `0x${string}`; data: `0x${string}`; gas: bigint } { + const data = encodeFunctionData({ + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.deposit.amount), + getPermit2DepositCollectorAddress(), + collectorData, + ], + }); + return { to: getAddress(BATCH_SETTLEMENT_ADDRESS), - data: encodeFunctionData({ - abi: batchSettlementABI, - functionName: "deposit", - args: [ - toContractChannelConfig(payload.channelConfig), - BigInt(payload.deposit.amount), - getPermit2DepositCollectorAddress(), - collectorData, - ], - }), + data: appendDataSuffix(data, dataSuffix), gas: 300_000n, }; } diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts index 36c9412532..18fae02127 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts @@ -283,6 +283,7 @@ async function verifySharedDepositState( * @param payload - The deposit payload (channelConfig, amount, authorization, voucher). * @param requirements - Server payment requirements. * @param context - Optional facilitator extension context. + * @param dataSuffix - Optional hex suffix appended to the deposit transaction. * @returns A {@link SettleResponse} with the transaction hash and updated channel state in `extra`. */ export async function settleDeposit( @@ -291,6 +292,7 @@ export async function settleDeposit( payload: BatchSettlementDepositPayload, requirements: PaymentRequirements, context?: FacilitatorContext, + dataSuffix?: `0x${string}`, ): Promise { const { deposit, voucher } = payload; const config = payload.channelConfig; @@ -329,7 +331,8 @@ export async function settleDeposit( }; } - const depositTx = buildDepositTransaction(payload, execution.collectorData); + const depositTx = buildDepositTransaction(payload, execution.collectorData, dataSuffix); + const tx = execution.kind === "erc20Approval" ? ( @@ -348,6 +351,7 @@ export async function settleDeposit( execution.collector, execution.collectorData, ], + dataSuffix, }); const receipt = await signer.waitForTransactionReceipt({ hash: tx }); diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts index 711670de1c..5e069ceaf5 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts @@ -188,6 +188,7 @@ function buildRefundExtraFromPostState( * @param payload - Refund payload with optional signatures, amount, and nonce. * @param requirements - Payment requirements for network identification. * @param authorizerSigner - Dedicated key for producing EIP-712 signatures. + * @param dataSuffix - Optional hex suffix appended to the refund transaction. * @returns A {@link SettleResponse} with the transaction hash on success. */ export async function executeRefundWithSignature( @@ -195,6 +196,7 @@ export async function executeRefundWithSignature( payload: BatchSettlementEnrichedRefundPayload, requirements: PaymentRequirements, authorizerSigner: AuthorizerSigner, + dataSuffix?: `0x${string}`, ): Promise { const network = requirements.network; @@ -278,6 +280,7 @@ export async function executeRefundWithSignature( abi: batchSettlementABI, functionName: "multicall", args: [[claimCalldata, refundCalldata]], + dataSuffix, }); } else { try { @@ -312,6 +315,7 @@ export async function executeRefundWithSignature( BigInt(payload.refundNonce), refundSig, ], + dataSuffix, }); } diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts index 521417ff33..32480b3e31 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts @@ -22,6 +22,7 @@ import { verifyVoucher } from "./voucher"; import { executeClaimWithSignature } from "./claim"; import { executeSettle } from "./settle"; import { executeRefundWithSignature } from "./refund"; +import { resolveDataSuffix } from "../../shared/extensions"; import * as Errors from "../errors"; /** @@ -76,12 +77,14 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { * @param payload - The x402 payment payload envelope. * @param requirements - Server payment requirements (scheme, network, asset, amount). * @param context - Optional facilitator extension context. + * @param _ - Payment required extensions (unused; reserved for interface parity) * @returns A {@link VerifyResponse} indicating validity with payer and channel state in `extra`. */ async verify( payload: PaymentPayload, requirements: PaymentRequirements, context?: FacilitatorContext, + _?: Record, ): Promise { const rawPayload = payload.payload; @@ -132,8 +135,13 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { ): Promise { const rawPayload = payload.payload; + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + if (isBatchSettlementDepositPayload(rawPayload)) { - return settleDeposit(this.signer, payload, rawPayload, requirements, context); + return settleDeposit(this.signer, payload, rawPayload, requirements, context, dataSuffix); } if (isBatchSettlementClaimPayload(rawPayload)) { @@ -142,6 +150,7 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { rawPayload, requirements, this.authorizerSigner, + dataSuffix, ); } @@ -151,11 +160,12 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { rawPayload, requirements, this.authorizerSigner, + dataSuffix, ); } if (isBatchSettlementSettlePayload(rawPayload)) { - return executeSettle(this.signer, rawPayload, requirements); + return executeSettle(this.signer, rawPayload, requirements, dataSuffix); } return { diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts index 999e6a91fb..03be3cbffc 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts @@ -15,12 +15,14 @@ import * as Errors from "../errors"; * @param signer - Facilitator signer used to submit the settlement transaction. * @param payload - Settle payload containing the receiver address and token address. * @param requirements - Payment requirements for network identification. + * @param dataSuffix - Optional hex suffix appended to the settlement transaction. * @returns A {@link SettleResponse} with the transaction hash on success. */ export async function executeSettle( signer: FacilitatorEvmSigner, payload: BatchSettlementSettlePayload, requirements: PaymentRequirements, + dataSuffix?: `0x${string}`, ): Promise { const network = requirements.network; const contractAddr = getAddress(BATCH_SETTLEMENT_ADDRESS); @@ -79,6 +81,7 @@ export async function executeSettle( abi: batchSettlementABI, functionName: "settle", args: [receiver, token], + dataSuffix, }); const receipt = await signer.waitForTransactionReceipt({ hash: tx }); diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts index cc4b70527a..e98796ce1d 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts @@ -218,15 +218,10 @@ export function parseEip3009TransferError(error: unknown): string { /** * Executes transferWithAuthorization onchain. * - * When a calldataSuffix is provided (e.g., ERC-8021 builder code attribution), - * the function manually encodes the calldata, appends the suffix, and uses - * sendTransaction instead of writeContract. The EVM ignores trailing calldata - * bytes, so the transfer executes normally while indexers can read the suffix. - * * @param signer - EVM signer for contract writes * @param erc20Address - ERC-20 token contract address * @param payload - EIP-3009 transfer authorization payload - * @param calldataSuffix - Optional hex bytes to append after the ABI-encoded calldata + * @param dataSuffix - Optional hex bytes to append after the ABI-encoded calldata * * @returns Transaction hash */ @@ -234,7 +229,7 @@ export async function executeTransferWithAuthorization( signer: FacilitatorEvmSigner, erc20Address: `0x${string}`, payload: ExactEIP3009Payload, - calldataSuffix?: Hex, + dataSuffix?: Hex, ): Promise { const { signature } = parseErc6492Signature(payload.signature!); const signatureLength = signature.startsWith("0x") ? signature.length - 2 : signature.length; @@ -250,59 +245,23 @@ export async function executeTransferWithAuthorization( auth.nonce, ] as const; - // If no suffix, use the standard writeContract path (unchanged behavior) - if (!calldataSuffix) { - if (isECDSA) { - const parsedSig = parseSignature(signature); - return signer.writeContract({ - address: erc20Address, - abi: eip3009ABI, - functionName: "transferWithAuthorization", - args: [ - ...baseArgs, - (parsedSig.v as number | undefined) || parsedSig.yParity, - parsedSig.r, - parsedSig.s, - ], - }); - } - - return signer.writeContract({ - address: erc20Address, - abi: eip3009ABI, - functionName: "transferWithAuthorization", - args: [...baseArgs, signature], - }); - } - - // With suffix: encode calldata manually, append suffix, use sendTransaction - let calldata: Hex; + let signatureArgs: readonly unknown[]; if (isECDSA) { const parsedSig = parseSignature(signature); - calldata = encodeFunctionData({ - abi: eip3009ABI, - functionName: "transferWithAuthorization", - args: [ - ...baseArgs, - (parsedSig.v as number | undefined) || parsedSig.yParity, - parsedSig.r, - parsedSig.s, - ], - }); + signatureArgs = [ + (parsedSig.v as number | undefined) || parsedSig.yParity, + parsedSig.r, + parsedSig.s, + ]; } else { - calldata = encodeFunctionData({ - abi: eip3009ABI, - functionName: "transferWithAuthorization", - args: [...baseArgs, signature], - }); + signatureArgs = [signature]; } - // Append the suffix (strip 0x prefix from suffix before concatenating) - const suffixHex = calldataSuffix.startsWith("0x") ? calldataSuffix.slice(2) : calldataSuffix; - const calldataWithSuffix = `${calldata}${suffixHex}` as Hex; - - return signer.sendTransaction({ - to: erc20Address, - data: calldataWithSuffix, + return signer.writeContract({ + address: erc20Address, + abi: eip3009ABI, + functionName: "transferWithAuthorization", + args: [...baseArgs, ...signatureArgs], + dataSuffix, }); } diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts index 15e766a6c9..68195b5a96 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts @@ -11,6 +11,7 @@ import { FacilitatorEvmSigner } from "../../signer"; import { getEvmChainId } from "../../utils"; import { ExactEIP3009Payload } from "../../types"; import * as Errors from "./errors"; +import { resolveDataSuffix } from "../../shared/extensions"; import { diagnoseEip3009SimulationFailure, executeTransferWithAuthorization, @@ -237,10 +238,6 @@ export async function verifyEIP3009( /** * Settles an EIP-3009 payment by executing transferWithAuthorization. * - * If a BuilderCodeFacilitatorExtension is registered, the facilitator will - * append an ERC-8021 Schema 2 suffix to the settlement transaction calldata - * containing builder codes from the agent, service, and facilitator. - * * @param signer - The facilitator signer for contract writes * @param payload - The payment payload to settle * @param requirements - The payment requirements @@ -301,22 +298,16 @@ export async function settleEIP3009( } } - // Build ERC-8021 calldata suffix if builder code extension is registered - let calldataSuffix: Hex | undefined; - if (context) { - const builderCodeExt = context.getExtension("builder-code"); - if (builderCodeExt && "buildCalldataSuffix" in builderCodeExt) { - calldataSuffix = (builderCodeExt as { buildCalldataSuffix: (ext?: Record) => Hex | undefined }).buildCalldataSuffix( - payload.extensions as Record | undefined, - ); - } - } + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); const tx = await executeTransferWithAuthorization( signer, getAddress(requirements.asset), eip3009Payload, - calldataSuffix, + dataSuffix, ); // Wait for transaction confirmation diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts index ad74c0b217..5ce399dd4f 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts @@ -15,6 +15,7 @@ import { type Erc20ApprovalGasSponsoringSigner, } from "../extensions"; import { getAddress, encodeFunctionData } from "viem"; +import { appendDataSuffix, resolveDataSuffix } from "../../shared/extensions"; import { PERMIT2_ADDRESS, permit2WitnessTypes, @@ -353,10 +354,22 @@ export async function settlePermit2( }; } + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + // Branch: EIP-2612 gas sponsoring (atomic settleWithPermit via contract) const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { - return settlePermit2WithEIP2612(exactProxyConfig, signer, payload, permit2Payload, eip2612Info); + return settlePermit2WithEIP2612( + exactProxyConfig, + signer, + payload, + permit2Payload, + eip2612Info, + dataSuffix, + ); } // Branch: ERC-20 approval gas sponsoring (broadcast approval + settle via extension signer) @@ -377,12 +390,13 @@ export async function settlePermit2( payload, permit2Payload, erc20Info, + dataSuffix, ); } } // Branch: standard settle (allowance already on-chain) - return settlePermit2Direct(exactProxyConfig, signer, payload, permit2Payload); + return settlePermit2Direct(exactProxyConfig, signer, payload, permit2Payload, dataSuffix); } // --------------------------------------------------------------------------- @@ -397,6 +411,7 @@ export async function settlePermit2( * @param payload - The payment payload for network info * @param permit2Payload - The Permit2 payload with authorization and signature * @param eip2612Info - The EIP-2612 gas sponsoring info from the payload extension + * @param dataSuffix - Optional hex suffix appended to the settlement transaction * @returns Promise resolving to a settlement response */ async function settlePermit2WithEIP2612( @@ -405,6 +420,7 @@ async function settlePermit2WithEIP2612( payload: PaymentPayload, permit2Payload: ExactPermit2Payload, eip2612Info: Eip2612GasSponsoringInfo, + dataSuffix?: `0x${string}`, ): Promise { const payer = permit2Payload.permit2Authorization.from; try { @@ -424,6 +440,7 @@ async function settlePermit2WithEIP2612( }, ...buildExactPermit2SettleArgs(permit2Payload), ], + dataSuffix, }); return waitAndReturnSettleResponse(signer, tx, payload, payer); @@ -441,6 +458,7 @@ async function settlePermit2WithEIP2612( * @param permit2Payload - The Permit2 payload with authorization and signature * @param erc20Info - Object containing the signed approval transaction * @param erc20Info.signedTransaction - The RLP-encoded signed ERC-20 approve transaction + * @param dataSuffix - Optional hex suffix appended to the settlement transaction * @returns Promise resolving to a settlement response */ async function settlePermit2WithERC20Approval( @@ -449,15 +467,19 @@ async function settlePermit2WithERC20Approval( payload: PaymentPayload, permit2Payload: ExactPermit2Payload, erc20Info: { signedTransaction: string }, + dataSuffix?: `0x${string}`, ): Promise { const payer = permit2Payload.permit2Authorization.from; try { - const settleData = encodeFunctionData({ - abi: config.proxyABI, - functionName: "settle", - args: buildExactPermit2SettleArgs(permit2Payload), - }); + const settleData = appendDataSuffix( + encodeFunctionData({ + abi: config.proxyABI, + functionName: "settle", + args: buildExactPermit2SettleArgs(permit2Payload), + }), + dataSuffix, + ); const txHashes = await extensionSigner.sendTransactions([ erc20Info.signedTransaction as `0x${string}`, @@ -478,6 +500,7 @@ async function settlePermit2WithERC20Approval( * @param signer - The facilitator signer for contract writes * @param payload - The payment payload for network info * @param permit2Payload - The Permit2 payload with authorization and signature + * @param dataSuffix - Optional hex suffix appended to the settlement transaction * @returns Promise resolving to a settlement response */ async function settlePermit2Direct( @@ -485,6 +508,7 @@ async function settlePermit2Direct( signer: FacilitatorEvmSigner, payload: PaymentPayload, permit2Payload: ExactPermit2Payload, + dataSuffix?: `0x${string}`, ): Promise { const payer = permit2Payload.permit2Authorization.from; try { @@ -493,6 +517,7 @@ async function settlePermit2Direct( abi: config.proxyABI, functionName: "settle", args: buildExactPermit2SettleArgs(permit2Payload), + dataSuffix, }); return waitAndReturnSettleResponse(signer, tx, payload, payer); diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts index 8904333beb..98ceda195c 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts @@ -80,12 +80,14 @@ export class ExactEvmScheme implements SchemeNetworkFacilitator { * @param payload - The payment payload to verify * @param requirements - The payment requirements * @param context - Optional facilitator context for extension capabilities + * @param _ - Payment required extensions (unused; reserved for interface parity) * @returns Promise resolving to verification response */ async verify( payload: PaymentPayload, requirements: PaymentRequirements, context?: FacilitatorContext, + _?: Record, ): Promise { const rawPayload = payload.payload as ExactEvmPayloadV2; const isPermit2 = isPermit2Payload(rawPayload); diff --git a/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts index 381aa8ca2f..2d44b225e4 100644 --- a/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts @@ -3,9 +3,11 @@ import { PaymentPayloadV1, PaymentRequirements, SchemeNetworkFacilitator, + FacilitatorContext, SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { resolveDataSuffix } from "../../../shared/extensions"; import { PaymentRequirementsV1 } from "@x402/core/types/v1"; import { getAddress, Hex, isAddressEqual, parseErc6492Signature } from "viem"; import { authorizationTypes } from "../../../constants"; @@ -105,11 +107,13 @@ export class ExactEvmSchemeV1 implements SchemeNetworkFacilitator { * * @param payload - The payment payload to settle * @param requirements - The payment requirements + * @param context - Optional facilitator context for extension capabilities * @returns Promise resolving to settlement response */ async settle( payload: PaymentPayload, requirements: PaymentRequirements, + context?: FacilitatorContext, ): Promise { const payloadV1 = payload as unknown as PaymentPayloadV1; const exactEvmPayload = payload.payload as ExactEvmPayloadV1; @@ -158,10 +162,16 @@ export class ExactEvmSchemeV1 implements SchemeNetworkFacilitator { } } + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + const tx = await executeTransferWithAuthorization( this.signer, getAddress(requirements.asset), exactEvmPayload, + dataSuffix, ); // Wait for transaction confirmation diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index da0b194cc2..03ae105086 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -82,6 +82,10 @@ export { export { getDefaultAsset } from "./shared/defaultAssets"; export type { DefaultAssetInfo, ExactDefaultAssetInfo } from "./shared/defaultAssets"; +// Extension helpers (client + facilitator) +export { BUILDER_CODE_KEY, resolveDataSuffix, appendDataSuffix } from "./shared/extensions"; +export type { DataSuffixContext, BuilderCodeFacilitatorExtension } from "./shared/extensions"; + // Constants export { PERMIT2_ADDRESS, diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts b/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts new file mode 100644 index 0000000000..30f79207ad --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts @@ -0,0 +1,90 @@ +import type { + FacilitatorContext, + FacilitatorExtension, + PaymentPayload, + PaymentRequirements, +} from "@x402/core/types"; +import type { Hex } from "viem"; + +export const BUILDER_CODE_KEY = "builder-code" as const; + +export interface DataSuffixContext { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; +} + +export interface BuilderCodeFacilitatorExtension extends FacilitatorExtension { + key: typeof BUILDER_CODE_KEY; + buildDataSuffix?(ctx: DataSuffixContext): Hex | undefined | Promise; +} + +type DataSuffixResolver = ( + context: FacilitatorContext, + ctx: DataSuffixContext, +) => Promise; + +const BUILDER_CODE_RESOLVER: DataSuffixResolver = async (context, ctx) => { + const ext = context.getExtension(BUILDER_CODE_KEY); + if (!ext?.buildDataSuffix) { + return undefined; + } + + return ext.buildDataSuffix(ctx); +}; + +const DATA_SUFFIX_RESOLVERS: DataSuffixResolver[] = [BUILDER_CODE_RESOLVER]; + +/** + * Resolves and concatenates data suffixes from registered extensions. + * + * @param context - Facilitator context with registered extensions + * @param ctx - Data suffix context passed to extension resolvers + * @returns Hex-encoded suffix to append to settlement calldata, or undefined if none + */ +export async function resolveDataSuffix( + context: FacilitatorContext | undefined, + ctx: DataSuffixContext, +): Promise { + if (!context) { + return undefined; + } + + const parts: Hex[] = []; + for (const resolver of DATA_SUFFIX_RESOLVERS) { + const suffix = await resolver(context, ctx); + if (suffix && suffix !== "0x" && suffix.length > 2) { + parts.push(suffix); + } + } + + if (parts.length === 0) { + return undefined; + } + + if (parts.length === 1) { + return parts[0]; + } + + return parts.reduce((acc, part, index) => { + if (index === 0) { + return part; + } + const stripped = part.startsWith("0x") ? part.slice(2) : part; + return `${acc}${stripped}` as Hex; + }); +} + +/** + * Appends a hex data suffix to encoded contract calldata. + * + * @param calldata - Base encoded function calldata + * @param suffix - Optional hex suffix (with or without 0x prefix) + * @returns Calldata with suffix appended, or the original calldata when suffix is empty + */ +export function appendDataSuffix(calldata: Hex, suffix?: Hex): Hex { + if (!suffix || suffix === "0x" || suffix.length <= 2) { + return calldata; + } + const suffixHex = suffix.startsWith("0x") ? suffix.slice(2) : suffix; + return `${calldata}${suffixHex}` as Hex; +} diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions.ts b/typescript/packages/mechanisms/evm/src/shared/extensions/gasSponsoring.ts similarity index 89% rename from typescript/packages/mechanisms/evm/src/shared/extensions.ts rename to typescript/packages/mechanisms/evm/src/shared/extensions/gasSponsoring.ts index 44a4518076..1a7d40c3ea 100644 --- a/typescript/packages/mechanisms/evm/src/shared/extensions.ts +++ b/typescript/packages/mechanisms/evm/src/shared/extensions/gasSponsoring.ts @@ -1,12 +1,19 @@ -import { PaymentRequirements, PaymentPayloadResult, PaymentPayloadContext } from "@x402/core/types"; -import { EIP2612_GAS_SPONSORING_KEY, ERC20_APPROVAL_GAS_SPONSORING_KEY } from "../exact/extensions"; +import type { + PaymentRequirements, + PaymentPayloadResult, + PaymentPayloadContext, +} from "@x402/core/types"; +import { + EIP2612_GAS_SPONSORING_KEY, + ERC20_APPROVAL_GAS_SPONSORING_KEY, +} from "../../exact/extensions"; import { getAddress } from "viem"; -import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../constants"; -import { getEvmChainId } from "../utils"; -import { ClientEvmSigner } from "../signer"; -import { signEip2612Permit } from "../exact/client/eip2612"; -import { signErc20ApprovalTransaction } from "../exact/client/erc20approval"; -import { resolveExtensionRpcCapabilities, type ExactEvmSchemeOptions } from "./rpc"; +import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../../constants"; +import { getEvmChainId } from "../../utils"; +import { ClientEvmSigner } from "../../signer"; +import { signEip2612Permit } from "../../exact/client/eip2612"; +import { signErc20ApprovalTransaction } from "../../exact/client/erc20approval"; +import { resolveExtensionRpcCapabilities, type ExactEvmSchemeOptions } from "../rpc"; /** * Attempts to sign an EIP-2612 permit for gasless Permit2 approval. diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions/index.ts b/typescript/packages/mechanisms/evm/src/shared/extensions/index.ts new file mode 100644 index 0000000000..1e34751903 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/extensions/index.ts @@ -0,0 +1,5 @@ +export { trySignEip2612PermitExtension, trySignErc20ApprovalExtension } from "./gasSponsoring"; + +export { BUILDER_CODE_KEY, resolveDataSuffix, appendDataSuffix } from "./builderCode"; + +export type { DataSuffixContext, BuilderCodeFacilitatorExtension } from "./builderCode"; diff --git a/typescript/packages/mechanisms/evm/src/signer.ts b/typescript/packages/mechanisms/evm/src/signer.ts index 4ec807bdd6..f46e83a1aa 100644 --- a/typescript/packages/mechanisms/evm/src/signer.ts +++ b/typescript/packages/mechanisms/evm/src/signer.ts @@ -90,8 +90,8 @@ export type FacilitatorEvmSigner = { abi: readonly unknown[]; functionName: string; args: readonly unknown[]; - /** Optional gas limit. When provided, skips eth_estimateGas simulation. */ gas?: bigint; + dataSuffix?: `0x${string}`; }): Promise<`0x${string}`>; sendTransaction(args: { to: `0x${string}`; data: `0x${string}` }): Promise<`0x${string}`>; waitForTransactionReceipt(args: { hash: `0x${string}` }): Promise<{ diff --git a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts index c21e724a3d..a4301ee291 100644 --- a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts @@ -14,6 +14,7 @@ import { type Erc20ApprovalGasSponsoringSigner, } from "../../exact/extensions"; import { getAddress, encodeFunctionData } from "viem"; +import { appendDataSuffix, resolveDataSuffix } from "../../shared/extensions"; import { PERMIT2_ADDRESS, uptoPermit2WitnessTypes, @@ -410,6 +411,11 @@ export async function settleUptoPermit2( const facilitatorAddress = getAddress(permit2Payload.permit2Authorization.witness.facilitator); + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + // Branch: EIP-2612 gas sponsoring (atomic settleWithPermit via contract) const eip2612Info = extractEip2612GasSponsoringInfo(payload); if (eip2612Info) { @@ -420,6 +426,7 @@ export async function settleUptoPermit2( eip2612Info, settlementAmount, facilitatorAddress, + dataSuffix, ); } @@ -442,12 +449,20 @@ export async function settleUptoPermit2( erc20Info, settlementAmount, facilitatorAddress, + dataSuffix, ); } } // Branch: standard settle (allowance already on-chain) - return settleUptoDirect(signer, payload, permit2Payload, settlementAmount, facilitatorAddress); + return settleUptoDirect( + signer, + payload, + permit2Payload, + settlementAmount, + facilitatorAddress, + dataSuffix, + ); } /** @@ -459,6 +474,7 @@ export async function settleUptoPermit2( * @param eip2612Info - The EIP-2612 gas sponsoring info from the payload extension * @param settlementAmount - The amount to settle on-chain * @param facilitatorAddress - The facilitator address authorized in the witness + * @param dataSuffix - Optional hex suffix appended to the settlement transaction * @returns Promise resolving to a settlement response */ async function settleUptoWithEIP2612( @@ -468,6 +484,7 @@ async function settleUptoWithEIP2612( eip2612Info: Eip2612GasSponsoringInfo, settlementAmount: bigint, facilitatorAddress: `0x${string}`, + dataSuffix?: `0x${string}`, ): Promise { const payer = permit2Payload.permit2Authorization.from; try { @@ -487,6 +504,7 @@ async function settleUptoWithEIP2612( }, ...buildUptoPermit2SettleArgs(permit2Payload, settlementAmount, facilitatorAddress), ], + dataSuffix, }); const response = await waitAndReturnSettleResponse(signer, tx, payload, payer); @@ -509,6 +527,7 @@ async function settleUptoWithEIP2612( * @param erc20Info.signedTransaction - The RLP-encoded signed ERC-20 approve transaction hex string * @param settlementAmount - The amount to settle on-chain * @param facilitatorAddress - The facilitator address authorized in the witness + * @param dataSuffix - Optional hex suffix appended to the settlement transaction * @returns Promise resolving to a settlement response */ async function settleUptoWithERC20Approval( @@ -518,15 +537,19 @@ async function settleUptoWithERC20Approval( erc20Info: { signedTransaction: string }, settlementAmount: bigint, facilitatorAddress: `0x${string}`, + dataSuffix?: `0x${string}`, ): Promise { const payer = permit2Payload.permit2Authorization.from; try { - const settleData = encodeFunctionData({ - abi: uptoProxyConfig.proxyABI, - functionName: "settle", - args: buildUptoPermit2SettleArgs(permit2Payload, settlementAmount, facilitatorAddress), - }); + const settleData = appendDataSuffix( + encodeFunctionData({ + abi: uptoProxyConfig.proxyABI, + functionName: "settle", + args: buildUptoPermit2SettleArgs(permit2Payload, settlementAmount, facilitatorAddress), + }), + dataSuffix, + ); const txHashes = await extensionSigner.sendTransactions([ erc20Info.signedTransaction as `0x${string}`, @@ -554,6 +577,7 @@ async function settleUptoWithERC20Approval( * @param permit2Payload - The upto Permit2 specific payload with authorization and signature * @param settlementAmount - The amount to settle on-chain * @param facilitatorAddress - The facilitator address authorized in the witness + * @param dataSuffix - Optional hex suffix appended to the settlement transaction * @returns Promise resolving to a settlement response */ async function settleUptoDirect( @@ -562,6 +586,7 @@ async function settleUptoDirect( permit2Payload: UptoPermit2Payload, settlementAmount: bigint, facilitatorAddress: `0x${string}`, + dataSuffix?: `0x${string}`, ): Promise { const payer = permit2Payload.permit2Authorization.from; try { @@ -570,6 +595,7 @@ async function settleUptoDirect( abi: uptoProxyConfig.proxyABI, functionName: "settle", args: buildUptoPermit2SettleArgs(permit2Payload, settlementAmount, facilitatorAddress), + dataSuffix, }); const response = await waitAndReturnSettleResponse(signer, tx, payload, payer); diff --git a/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts index b49eb9f9d5..9f2a32b8eb 100644 --- a/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/upto/facilitator/scheme.ts @@ -55,12 +55,14 @@ export class UptoEvmScheme implements SchemeNetworkFacilitator { * @param payload - The payment payload to verify * @param requirements - The payment requirements to verify against * @param context - Optional facilitator context + * @param _ - Payment required extensions (unused; reserved for interface parity) * @returns Promise resolving to a verification response */ async verify( payload: PaymentPayload, requirements: PaymentRequirements, context?: FacilitatorContext, + _?: Record, ): Promise { const rawPayload = payload.payload as Record; if (!isUptoPermit2Payload(rawPayload)) {