diff --git a/examples/typescript/clients/allowance/.env-local b/examples/typescript/clients/allowance/.env-local new file mode 100644 index 0000000000..5d39b580d5 --- /dev/null +++ b/examples/typescript/clients/allowance/.env-local @@ -0,0 +1,5 @@ +RESOURCE_SERVER_URL=http://localhost:4025 +ENDPOINT_PATH=/demo +PRIVATE_KEY= +TOKEN_ADDRESS= +FACILITATOR_ADDRESS= diff --git a/examples/typescript/clients/allowance/.prettierignore b/examples/typescript/clients/allowance/.prettierignore new file mode 100644 index 0000000000..3049672b5c --- /dev/null +++ b/examples/typescript/clients/allowance/.prettierignore @@ -0,0 +1,8 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +src/client +**/**/*.json +*.md diff --git a/examples/typescript/clients/allowance/.prettierrc b/examples/typescript/clients/allowance/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/clients/allowance/.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/allowance/README.md b/examples/typescript/clients/allowance/README.md new file mode 100644 index 0000000000..87e1ebd2c2 --- /dev/null +++ b/examples/typescript/clients/allowance/README.md @@ -0,0 +1,41 @@ +# Allowance Based Client Example + +This example demonstrates how to pre-approve a payment facilitator using the ERC-20 `approve` function so that future x402 payments can be automatically charged using `transferFrom` without additional user signatures. + +The demo pairs with the example server in `../servers/allowance` which expects a simple JSON payment header containing the sender address and amount. + +## Prerequisites + +- Node.js v20+ +- pnpm v10 +- A running allowance example server (`examples/typescript/servers/allowance`) +- A funded Ethereum private key on Base Sepolia + +## Setup + +1. Install dependencies from the examples root and build the packages: + +```bash +cd ../../ +pnpm install +pnpm build +cd clients/allowance +``` + +2. Copy `.env-local` to `.env` and fill in the required values: + +```bash +cp .env-local .env +``` + +3. Approve the facilitator address to spend your USDC: + +```bash +pnpm approve +``` + +4. Run the demo client: + +```bash +pnpm dev +``` diff --git a/examples/typescript/clients/allowance/approve.ts b/examples/typescript/clients/allowance/approve.ts new file mode 100644 index 0000000000..37c8fd6944 --- /dev/null +++ b/examples/typescript/clients/allowance/approve.ts @@ -0,0 +1,40 @@ +import { config } from "dotenv"; +import { createWalletClient, http, publicActions } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; +import { Hex, Address } from "viem"; +import { erc20Abi } from "viem"; + +config(); + +const { PRIVATE_KEY, TOKEN_ADDRESS, FACILITATOR_ADDRESS } = process.env; + +if (!PRIVATE_KEY || !TOKEN_ADDRESS || !FACILITATOR_ADDRESS) { + console.error("Missing environment variables in .env"); + process.exit(1); +} + +const account = privateKeyToAccount(PRIVATE_KEY as Hex); + +const wallet = createWalletClient({ + account, + chain: baseSepolia, + transport: http(), +}).extend(publicActions); + +/** + * Approves the facilitator to spend USDC on behalf of the user. + */ +async function main() { + const maxUint256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + const tx = await wallet.writeContract({ + address: TOKEN_ADDRESS as Address, + abi: erc20Abi, + functionName: "approve", + args: [FACILITATOR_ADDRESS as Address, maxUint256], + }); + await wallet.waitForTransactionReceipt({ hash: tx }); + console.log(`Approved ${FACILITATOR_ADDRESS}`); +} + +main(); diff --git a/examples/typescript/clients/allowance/eslint.config.js b/examples/typescript/clients/allowance/eslint.config.js new file mode 100644 index 0000000000..ca28b5c47f --- /dev/null +++ b/examples/typescript/clients/allowance/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/allowance/index.ts b/examples/typescript/clients/allowance/index.ts new file mode 100644 index 0000000000..72bbe1f1a3 --- /dev/null +++ b/examples/typescript/clients/allowance/index.ts @@ -0,0 +1,82 @@ +import { config } from "dotenv"; +import axios from "axios"; +import { createWalletClient, http, publicActions } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { Hex, Address } from "viem"; +import { baseSepolia } from "viem/chains"; +import { erc20Abi } from "viem"; + +config(); + +const { RESOURCE_SERVER_URL, ENDPOINT_PATH, PRIVATE_KEY, TOKEN_ADDRESS, FACILITATOR_ADDRESS } = + process.env; + +if ( + !RESOURCE_SERVER_URL || + !ENDPOINT_PATH || + !PRIVATE_KEY || + !TOKEN_ADDRESS || + !FACILITATOR_ADDRESS +) { + console.error("Missing environment variables in .env"); + process.exit(1); +} + +const account = privateKeyToAccount(PRIVATE_KEY as Hex); +const wallet = createWalletClient({ + account, + chain: baseSepolia, + transport: http(), +}).extend(publicActions); + +const api = axios.create({ baseURL: RESOURCE_SERVER_URL }); + +api.interceptors.response.use( + response => response, + async error => { + if (!error.response || error.response.status !== 402) { + return Promise.reject(error); + } + + const { accepts } = error.response.data as { + accepts: Array<{ + maxAmountRequired: string; + scheme: string; + }>; + }; + const req = accepts[0]; + const amount = BigInt(req.maxAmountRequired); + + const allowance: bigint = await wallet.readContract({ + address: TOKEN_ADDRESS as Address, + abi: erc20Abi, + functionName: "allowance", + args: [account.address as Address, FACILITATOR_ADDRESS as Address], + }); + + if (allowance < amount) { + throw new Error("Insufficient allowance, please run pnpm approve first."); + } + + const paymentHeader = JSON.stringify({ + from: account.address, + amount: req.maxAmountRequired, + }); + + error.config.headers = error.config.headers || {}; + error.config.headers["X-PAYMENT"] = paymentHeader; + error.config.headers["Access-Control-Expose-Headers"] = "X-PAYMENT-RESPONSE"; + + return api.request(error.config); + }, +); + +api + .get(ENDPOINT_PATH) + .then(res => { + console.log(res.data); + console.log(res.headers["x-payment-response"]); + }) + .catch(err => { + console.error(err.message); + }); diff --git a/examples/typescript/clients/allowance/package.json b/examples/typescript/clients/allowance/package.json new file mode 100644 index 0000000000..7b344ebdbb --- /dev/null +++ b/examples/typescript/clients/allowance/package.json @@ -0,0 +1,30 @@ +{ + "name": "allowance-client-example", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx index.ts", + "approve": "tsx approve.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": { + "axios": "^1.7.9", + "dotenv": "^16.5.0", + "viem": "^2.23.1" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@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.7.0", + "typescript": "^5.3.0" + } +} diff --git a/examples/typescript/clients/allowance/tsconfig.json b/examples/typescript/clients/allowance/tsconfig.json new file mode 100644 index 0000000000..691faa43fa --- /dev/null +++ b/examples/typescript/clients/allowance/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", "approve.ts"] +} diff --git a/examples/typescript/servers/allowance/.env-local b/examples/typescript/servers/allowance/.env-local new file mode 100644 index 0000000000..3cb609ee58 --- /dev/null +++ b/examples/typescript/servers/allowance/.env-local @@ -0,0 +1,3 @@ +PRIVATE_KEY= +PAY_TO= +TOKEN_ADDRESS= diff --git a/examples/typescript/servers/allowance/.prettierignore b/examples/typescript/servers/allowance/.prettierignore new file mode 100644 index 0000000000..3049672b5c --- /dev/null +++ b/examples/typescript/servers/allowance/.prettierignore @@ -0,0 +1,8 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +src/client +**/**/*.json +*.md diff --git a/examples/typescript/servers/allowance/.prettierrc b/examples/typescript/servers/allowance/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/servers/allowance/.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/allowance/README.md b/examples/typescript/servers/allowance/README.md new file mode 100644 index 0000000000..8e725382f4 --- /dev/null +++ b/examples/typescript/servers/allowance/README.md @@ -0,0 +1,21 @@ +# Allowance Based Server Example + +This server works with the allowance client example. It requires callers to pre-approve a facilitator address for USDC transfers. Clients send a simple JSON payment header containing the sender address and amount. The server verifies the allowance and pulls payment using `transferFrom`. + +## Setup + +1. Copy `.env-local` to `.env` and configure the addresses. +2. Install dependencies from the examples root and build the packages: + +```bash +cd ../../ +pnpm install +pnpm build +cd servers/allowance +``` + +3. Start the server: + +```bash +pnpm dev +``` diff --git a/examples/typescript/servers/allowance/eslint.config.js b/examples/typescript/servers/allowance/eslint.config.js new file mode 100644 index 0000000000..ca28b5c47f --- /dev/null +++ b/examples/typescript/servers/allowance/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/servers/allowance/index.ts b/examples/typescript/servers/allowance/index.ts new file mode 100644 index 0000000000..1b098c2bca --- /dev/null +++ b/examples/typescript/servers/allowance/index.ts @@ -0,0 +1,96 @@ +import { config } from "dotenv"; +import express from "express"; +import { createWalletClient, createPublicClient, http, publicActions } from "viem"; +import { baseSepolia } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; +import { Hex, Address } from "viem"; +import { erc20Abi } from "viem"; + +config(); + +const { PRIVATE_KEY, PAY_TO, TOKEN_ADDRESS } = process.env; + +if (!PRIVATE_KEY || !PAY_TO || !TOKEN_ADDRESS) { + console.error("Missing environment variables in .env"); + process.exit(1); +} + +const account = privateKeyToAccount(PRIVATE_KEY as Hex); +const wallet = createWalletClient({ + account, + chain: baseSepolia, + transport: http(), +}).extend(publicActions); +const client = createPublicClient({ chain: baseSepolia, transport: http() }); + +const app = express(); + +app.get("/demo", async (req, res) => { + const paymentHeader = req.header("X-PAYMENT"); + const paymentRequirements = { + scheme: "allowance", + network: "base-sepolia", + maxAmountRequired: "1000", + resource: `${req.protocol}://${req.get("host")}${req.originalUrl}`, + description: "Demo access", + mimeType: "", + payTo: PAY_TO, + maxTimeoutSeconds: 60, + asset: TOKEN_ADDRESS, + }; + + if (!paymentHeader) { + res.status(402).json({ x402Version: 1, accepts: [paymentRequirements] }); + return; + } + + let payment: { from: string; amount: string }; + try { + payment = JSON.parse(paymentHeader); + } catch { + res.status(402).json({ + x402Version: 1, + error: "malformed payment header", + accepts: [paymentRequirements], + }); + return; + } + + const allowance: bigint = await client.readContract({ + address: TOKEN_ADDRESS as Address, + abi: erc20Abi, + functionName: "allowance", + args: [payment.from as Address, PAY_TO as Address], + }); + + if (allowance < BigInt(payment.amount)) { + res.status(402).json({ + x402Version: 1, + error: "insufficient_allowance", + accepts: [paymentRequirements], + }); + return; + } + + try { + const tx = await wallet.writeContract({ + address: TOKEN_ADDRESS as Address, + abi: erc20Abi, + functionName: "transferFrom", + args: [payment.from as Address, PAY_TO as Address, BigInt(payment.amount)], + }); + await wallet.waitForTransactionReceipt({ hash: tx }); + res.setHeader("X-PAYMENT-RESPONSE", tx); + res.json({ success: true }); + } catch (err) { + res.status(402).json({ + x402Version: 1, + error: (err as Error).message, + accepts: [paymentRequirements], + }); + } +}); + +app.listen(4025, () => { + console.log("Server listening on http://localhost:4025"); +}); diff --git a/examples/typescript/servers/allowance/package.json b/examples/typescript/servers/allowance/package.json new file mode 100644 index 0000000000..eca36c9c6b --- /dev/null +++ b/examples/typescript/servers/allowance/package.json @@ -0,0 +1,31 @@ +{ + "name": "allowance-server-example", + "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": { + "dotenv": "^16.5.0", + "express": "^4.18.2", + "viem": "^2.23.1" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@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": "^7.2.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0", + "@types/express": "^5.0.1" + } +} diff --git a/examples/typescript/servers/allowance/tsconfig.json b/examples/typescript/servers/allowance/tsconfig.json new file mode 100644 index 0000000000..78f9479b1b --- /dev/null +++ b/examples/typescript/servers/allowance/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"] +}