From b8fcf688825a28ff6d18da3d179a248f9924f06a Mon Sep 17 00:00:00 2001 From: Simantak Dabhade Date: Tue, 5 May 2026 23:31:33 -0700 Subject: [PATCH] Add x402-pay skill to skill/ for public distribution via skill.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes the x402-pay Claude Code skill at skill/x402-pay/ so it can be discovered and installed by anyone via skill.sh, which indexes skills from public GitHub repos. The skill lets any Claude Code agent make x402 payments to access paid API endpoints — handles the full flow (request, 402, EIP-712 signing, retry) with the private key stored securely in macOS Keychain. --- skill/x402-pay/SKILL.md | 141 +++++++ skill/x402-pay/scripts/.gitignore | 2 + skill/x402-pay/scripts/package.json | 14 + skill/x402-pay/scripts/pnpm-lock.yaml | 513 ++++++++++++++++++++++++++ skill/x402-pay/scripts/setup.ts | 176 +++++++++ skill/x402-pay/scripts/tsconfig.json | 12 + skill/x402-pay/scripts/x402-fetch.ts | 350 ++++++++++++++++++ 7 files changed, 1208 insertions(+) create mode 100644 skill/x402-pay/SKILL.md create mode 100644 skill/x402-pay/scripts/.gitignore create mode 100644 skill/x402-pay/scripts/package.json create mode 100644 skill/x402-pay/scripts/pnpm-lock.yaml create mode 100644 skill/x402-pay/scripts/setup.ts create mode 100644 skill/x402-pay/scripts/tsconfig.json create mode 100644 skill/x402-pay/scripts/x402-fetch.ts diff --git a/skill/x402-pay/SKILL.md b/skill/x402-pay/SKILL.md new file mode 100644 index 00000000..312a659a --- /dev/null +++ b/skill/x402-pay/SKILL.md @@ -0,0 +1,141 @@ +--- +name: x402-pay +description: Make x402 payments to access paid API endpoints. Use when fetching a URL that returns 402 Payment Required, or when the user asks to "pay for" or "access" an x402-protected resource. +argument-hint: [--method POST] [--body '{}'] [--headers '{}'] +arguments: url +allowed-tools: Bash(npx *) Bash(pnpm *) Bash(security find-generic-password *) +--- + +# x402-pay + +Makes a signed x402 payment to a paid API endpoint and returns the response. Handles the full flow: request, 402 detection, EIP-712 signing, retry with payment header. Supports both `exact` (fixed price) and `upto` (variable price) schemes automatically. + +The private key is stored in macOS Keychain and read by the script at runtime. It never enters the conversation. + +## Prerequisites + +```! +security find-generic-password -s x402-agent-key -a x402 -w 2>/dev/null && echo "KEYCHAIN: CONFIGURED" || echo "KEYCHAIN: NOT_CONFIGURED" +``` + +```! +test -d "${CLAUDE_SKILL_DIR}/scripts/node_modules" && echo "DEPS: INSTALLED" || echo "DEPS: MISSING" +``` + +## Setup (only if NOT_CONFIGURED or MISSING above) + +If **DEPS: MISSING**, install dependencies first: + +```bash +pnpm install --prefix "${CLAUDE_SKILL_DIR}/scripts" +``` + +If **KEYCHAIN: NOT_CONFIGURED**, the user needs to store their private key. Tell them: + +> Your x402 agent private key isn't configured yet. Please run this command — it will prompt you to paste your key securely (it won't be displayed): +> +> ``` +> ! security add-generic-password -s x402-agent-key -a x402 -U -w +> ``` +> +> The key should be a hex string with `0x` prefix. If you need to export one from CDP, run: +> `uv run python playground/agent-test/export-key.py ` + +After the key is stored, run the setup check to verify balances and Permit2 approval: + +```bash +npx --prefix "${CLAUDE_SKILL_DIR}/scripts" tsx "${CLAUDE_SKILL_DIR}/scripts/setup.ts" +``` + +If Permit2 is not approved (needed for `upto` scheme payments), run: + +```bash +npx --prefix "${CLAUDE_SKILL_DIR}/scripts" tsx "${CLAUDE_SKILL_DIR}/scripts/setup.ts" --approve +``` + +Once setup outputs `"status": "ready"`, proceed to usage. + +## Usage + +Run the payment script with the target URL: + +```bash +npx --prefix "${CLAUDE_SKILL_DIR}/scripts" tsx "${CLAUDE_SKILL_DIR}/scripts/x402-fetch.ts" "$url" +``` + +### Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--method` / `-m` | `GET` | HTTP method | +| `--body` / `-b` | none | JSON request body | +| `--headers` / `-H` | none | Extra headers as JSON object | + +### Examples + +```bash +# Simple GET to a paid endpoint +npx --prefix "${CLAUDE_SKILL_DIR}/scripts" tsx "${CLAUDE_SKILL_DIR}/scripts/x402-fetch.ts" "https://api.example.com/premium" + +# POST with body and custom headers +npx --prefix "${CLAUDE_SKILL_DIR}/scripts" tsx "${CLAUDE_SKILL_DIR}/scripts/x402-fetch.ts" "https://api.example.com/search" \ + --method POST \ + --body '{"query": "test"}' \ + --headers '{"X-Custom": "value"}' +``` + +## Output Format + +The script outputs a single JSON object to stdout. Parse it to determine the result. + +**Success** (payment completed, resource returned): +```json +{ + "status": "success", + "httpStatus": 200, + "body": { "...resource data..." }, + "settlement": { "txHash": "0x...", "network": "eip155:8453", "payer": "0x..." }, + "payment": { "scheme": "exact", "amountMicro": 10000, "amountUSD": "0.010000" } +} +``` + +**No payment needed** (endpoint didn't return 402): +```json +{ + "status": "no_payment_required", + "httpStatus": 200, + "body": { "...response data..." } +} +``` + +**Error** (something went wrong): +```json +{ + "status": "error", + "errorType": "INSUFFICIENT_BALANCE", + "message": "human-readable explanation", + "details": { "walletAddress": "0x...", "balanceUSDC": "0.5", "requiredUSDC": "1.0" } +} +``` + +## Error Handling + +| errorType | Meaning | What to do | +|-----------|---------|-----------| +| `KEYCHAIN_ERROR` | Private key not in Keychain | Run the setup flow above | +| `INSUFFICIENT_BALANCE` | USDC balance too low | Tell the user to fund their wallet (address is in details) | +| `PERMIT2_NOT_APPROVED` | Permit2 not approved for USDC | Run `setup.ts --approve` | +| `PAYMENT_REJECTED` | Server rejected the signed payment | Report the error body to the user | +| `SIGNING_FAILED` | EIP-712 signing failed | Likely a library or chain mismatch — report to user | +| `NETWORK_ERROR` | Could not reach the URL | Check URL and network connectivity | +| `INVALID_ARGS` | Bad CLI arguments | Check the command format | + +## Security Rules + +These rules are non-negotiable: + +1. **NEVER** attempt to read, display, or log the private key from Keychain +2. **NEVER** include the raw `PAYMENT-SIGNATURE` header value in conversation output +3. **NEVER** run `security find-generic-password -s x402-agent-key -w` directly — only the TypeScript script reads the key internally +4. **NEVER** ask the user to paste their private key into the chat — always direct them to use the `! security add-generic-password ...` command +5. All cryptographic operations happen inside `x402-fetch.ts` — the agent only sees the structured JSON output diff --git a/skill/x402-pay/scripts/.gitignore b/skill/x402-pay/scripts/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/skill/x402-pay/scripts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/skill/x402-pay/scripts/package.json b/skill/x402-pay/scripts/package.json new file mode 100644 index 00000000..e8371aad --- /dev/null +++ b/skill/x402-pay/scripts/package.json @@ -0,0 +1,14 @@ +{ + "name": "x402-pay-skill", + "private": true, + "type": "module", + "dependencies": { + "@x402/evm": "^2.11.0", + "viem": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + } +} diff --git a/skill/x402-pay/scripts/pnpm-lock.yaml b/skill/x402-pay/scripts/pnpm-lock.yaml new file mode 100644 index 00000000..779a40d2 --- /dev/null +++ b/skill/x402-pay/scripts/pnpm-lock.yaml @@ -0,0 +1,513 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@x402/evm': + specifier: ^2.11.0 + version: 2.11.0(typescript@5.9.3) + viem: + specifier: ^2.0.0 + version: 2.48.8(typescript@5.9.3)(zod@3.25.76) + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + +packages: + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@x402/core@2.11.0': + resolution: {integrity: sha512-aqTfZc/BULrlWnd3I0lsqRQaH4gjJd8CsPcL16XqK2Lx5c6QDm+zCljgUVS1yj9BGJoZeQWTzI5hE+SVFkqMTw==} + + '@x402/evm@2.11.0': + resolution: {integrity: sha512-F8uU1txDZA+wc/sEnmaHAyYvoTi/w39r7K3a44MmQHSxECDTEuB3A0FwbxOxUPLN1eyCxTAFKEiqlGe3bwybKA==} + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + viem@2.48.8: + resolution: {integrity: sha512-Xj3Nrt66SKtn06kczU91ELn9Difr84ZM5A62BTlaisT5lpgt058i2mBkfMZCXHGb1ocOLjzC2ztPhD0Lvky7uQ==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@x402/core@2.11.0': + dependencies: + zod: 3.25.76 + + '@x402/evm@2.11.0(typescript@5.9.3)': + dependencies: + '@x402/core': 2.11.0 + viem: 2.48.8(typescript@5.9.3)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + eventemitter3@5.0.1: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + isows@1.0.7(ws@8.18.3): + dependencies: + ws: 8.18.3 + + ox@0.14.20(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + resolve-pkg-maps@1.0.0: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + viem@2.48.8(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.3) + ox: 0.14.20(typescript@5.9.3)(zod@3.25.76) + ws: 8.18.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + ws@8.18.3: {} + + zod@3.25.76: {} diff --git a/skill/x402-pay/scripts/setup.ts b/skill/x402-pay/scripts/setup.ts new file mode 100644 index 00000000..1ffc0474 --- /dev/null +++ b/skill/x402-pay/scripts/setup.ts @@ -0,0 +1,176 @@ +/** + * x402-pay setup — checks Keychain, wallet balance, and Permit2 approval status. + * + * Usage: tsx setup.ts [--approve] + * + * Without --approve: read-only status check. + * With --approve: also submits a Permit2 USDC approval transaction if needed. + */ + +import { execSync } from "node:child_process"; +import { parseArgs } from "node:util"; +import { + createWalletClient, + http, + publicActions, + parseAbi, + formatUnits, + maxUint256, + formatEther, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base } from "viem/chains"; + +const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as const; +const KEYCHAIN_SERVICE = "x402-agent-key"; +const KEYCHAIN_ACCOUNT = "x402"; + +function log(msg: string) { + process.stderr.write(`[setup] ${msg}\n`); +} + +function output(data: Record) { + process.stdout.write(JSON.stringify(data, null, 2) + "\n"); +} + +async function main() { + const { values } = parseArgs({ + options: { + approve: { type: "boolean", default: false }, + }, + }); + + // ── 1. Check Keychain ────────────────────────────────────────────── + + log("Checking Keychain for private key..."); + + let privateKey: string; + try { + privateKey = execSync( + `security find-generic-password -s ${KEYCHAIN_SERVICE} -a ${KEYCHAIN_ACCOUNT} -w`, + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + if (!privateKey) throw new Error("empty"); + } catch { + output({ + status: "setup_required", + step: "keychain", + message: "Private key not found in macOS Keychain.", + instructions: [ + "Run this command in your terminal to store your private key securely:", + " security add-generic-password -s x402-agent-key -a x402 -U -w", + "(You will be prompted to enter the key — it won't be displayed)", + "", + "The key should be a hex string with 0x prefix (e.g., 0x1234...).", + "You can export it from CDP using: uv run python playground/agent-test/export-key.py
", + ], + }); + return; + } + + log("Key found in Keychain."); + + // ── 2. Build wallet client ───────────────────────────────────────── + + const account = privateKeyToAccount(privateKey as `0x${string}`); + const walletClient = createWalletClient({ + account, + chain: base, + transport: http(), + }).extend(publicActions); + + log(`Wallet address: ${account.address}`); + + // ── 3. Check balances ────────────────────────────────────────────── + + const [ethBalance, usdcBalance, permit2Allowance] = await Promise.all([ + walletClient.getBalance({ address: account.address }), + walletClient.readContract({ + address: USDC_BASE, + abi: parseAbi(["function balanceOf(address) view returns (uint256)"]), + functionName: "balanceOf", + args: [account.address], + }), + walletClient.readContract({ + address: USDC_BASE, + abi: parseAbi([ + "function allowance(address,address) view returns (uint256)", + ]), + functionName: "allowance", + args: [account.address, PERMIT2], + }), + ]); + + const ethFormatted = formatEther(ethBalance); + const usdcFormatted = formatUnits(usdcBalance, 6); + const permit2Approved = permit2Allowance > 0n; + + log(`ETH balance: ${ethFormatted}`); + log(`USDC balance: ${usdcFormatted}`); + log(`Permit2 approved: ${permit2Approved}`); + + // ── 4. Permit2 approval (if requested) ───────────────────────────── + + if (!permit2Approved && values.approve) { + if (ethBalance === 0n) { + output({ + status: "setup_required", + step: "fund_eth", + message: "Need ETH for gas to approve Permit2.", + walletAddress: account.address, + ethBalance: ethFormatted, + }); + return; + } + + log("Submitting Permit2 USDC approval..."); + const hash = await walletClient.writeContract({ + address: USDC_BASE, + abi: parseAbi(["function approve(address,uint256) returns (bool)"]), + functionName: "approve", + args: [PERMIT2, maxUint256], + }); + + log(`Approval tx submitted: ${hash}`); + const receipt = await walletClient.waitForTransactionReceipt({ hash }); + log(`Confirmed in block ${receipt.blockNumber}`); + + output({ + status: "ready", + walletAddress: account.address, + ethBalance: ethFormatted, + usdcBalance: usdcFormatted, + permit2Approved: true, + approvalTxHash: hash, + }); + return; + } + + // ── 5. Output status ─────────────────────────────────────────────── + + const result: Record = { + status: permit2Approved ? "ready" : "setup_required", + walletAddress: account.address, + ethBalance: ethFormatted, + usdcBalance: usdcFormatted, + permit2Approved, + }; + + if (!permit2Approved) { + result.step = "permit2"; + result.message = + "Permit2 is not approved for USDC. Re-run with --approve to submit the approval transaction."; + } + + output(result); +} + +main().catch((err) => { + output({ + status: "error", + errorType: "SETUP_FAILED", + message: err.message, + }); + process.exit(1); +}); diff --git a/skill/x402-pay/scripts/tsconfig.json b/skill/x402-pay/scripts/tsconfig.json new file mode 100644 index 00000000..64a9db62 --- /dev/null +++ b/skill/x402-pay/scripts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["."] +} diff --git a/skill/x402-pay/scripts/x402-fetch.ts b/skill/x402-pay/scripts/x402-fetch.ts new file mode 100644 index 00000000..21f27ce8 --- /dev/null +++ b/skill/x402-pay/scripts/x402-fetch.ts @@ -0,0 +1,350 @@ +/** + * x402-fetch — makes a signed x402 payment to access a paid API endpoint. + * + * Usage: tsx x402-fetch.ts [--method GET|POST] [--body '{}'] [--headers '{}'] + * + * Reads the private key from macOS Keychain (service: x402-agent-key). + * Outputs structured JSON to stdout. All diagnostics go to stderr. + */ + +import { execSync } from "node:child_process"; +import { parseArgs } from "node:util"; +import { + createWalletClient, + http, + publicActions, + parseAbi, + formatUnits, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base } from "viem/chains"; +import { toClientEvmSigner } from "@x402/evm"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { UptoEvmScheme } from "@x402/evm/upto/client"; + +const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as const; +const KEYCHAIN_SERVICE = "x402-agent-key"; +const KEYCHAIN_ACCOUNT = "x402"; +const MAX_BODY_SIZE = 100_000; // truncate response bodies above 100KB + +// ── Helpers ────────────────────────────────────────────────────────── + +function log(msg: string) { + process.stderr.write(`[x402] ${msg}\n`); +} + +function output(data: Record) { + process.stdout.write(JSON.stringify(data, null, 2) + "\n"); +} + +function fatal( + errorType: string, + message: string, + details?: Record, +): never { + output({ status: "error", errorType, message, ...(details && { details }) }); + process.exit(1); +} + +function readKeychain(): string { + try { + const key = execSync( + `security find-generic-password -s ${KEYCHAIN_SERVICE} -a ${KEYCHAIN_ACCOUNT} -w`, + { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + if (!key) throw new Error("empty"); + return key; + } catch { + fatal( + "KEYCHAIN_ERROR", + "Private key not found in macOS Keychain. Run the setup flow first.", + { + hint: "Ask the user to run: ! security add-generic-password -s x402-agent-key -a x402 -U -w", + }, + ); + } +} + +// ── Main ───────────────────────────────────────────────────────────── + +async function main() { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + method: { type: "string", short: "m", default: "GET" }, + body: { type: "string", short: "b" }, + headers: { type: "string", short: "H" }, + }, + }); + + const url = positionals[0]; + if (!url) { + fatal("INVALID_ARGS", "Usage: x402-fetch [--method GET|POST] [--body '{}'] [--headers '{}']"); + } + + const method = (values.method ?? "GET").toUpperCase(); + const extraHeaders: Record = values.headers + ? JSON.parse(values.headers) + : {}; + const bodyPayload = values.body ?? undefined; + + // ── 1. Read key from Keychain ────────────────────────────────────── + + log("Reading private key from Keychain..."); + const privateKey = readKeychain(); + + const account = privateKeyToAccount(privateKey as `0x${string}`); + const walletClient = createWalletClient({ + account, + chain: base, + transport: http(), + }).extend(publicActions); + + const signer = toClientEvmSigner( + { + address: account.address, + signTypedData: (msg) => walletClient.signTypedData(msg as any), + }, + walletClient as any, + ); + + log(`Wallet: ${account.address}`); + + // ── 2. Initial request ───────────────────────────────────────────── + + log(`${method} ${url}`); + + let resp1: Response; + try { + resp1 = await fetch(url, { + method, + headers: { + ...(bodyPayload ? { "Content-Type": "application/json" } : {}), + ...extraHeaders, + }, + body: bodyPayload, + }); + } catch (err: any) { + fatal("NETWORK_ERROR", `Failed to reach ${url}: ${err.message}`, { + url, + }); + } + + // Not a 402 — return the response as-is + if (resp1.status !== 402) { + const text = await resp1.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = text.length > MAX_BODY_SIZE ? text.slice(0, MAX_BODY_SIZE) : text; + } + output({ + status: "no_payment_required", + httpStatus: resp1.status, + body, + }); + return; + } + + // ── 3. Parse 402 payment requirements ────────────────────────────── + + log("Got 402 — parsing payment requirements..."); + + let paymentRequired: any; + + // x402 v2: requirements come in the `payment-required` header (base64 JSON) + const paymentRequiredHeader = resp1.headers.get("payment-required"); + if (paymentRequiredHeader) { + try { + paymentRequired = JSON.parse(atob(paymentRequiredHeader)); + } catch { + fatal("PAYMENT_REJECTED", "Could not decode payment-required header"); + } + } else { + // Fallback: parse from response body (Sangria-style) + try { + paymentRequired = await resp1.json(); + } catch { + fatal("PAYMENT_REJECTED", "402 response did not contain valid JSON payment requirements"); + } + } + + if (!paymentRequired.accepts?.length) { + fatal("PAYMENT_REJECTED", "402 response has no accepted payment methods", { + body: paymentRequired, + }); + } + + const requirements = paymentRequired.accepts[0]; + + // Normalize: backend sends maxAmountRequired for upto, x402 client expects amount + if (!requirements.amount && requirements.maxAmountRequired) { + requirements.amount = requirements.maxAmountRequired; + } + + const scheme = requirements.scheme ?? "exact"; + const amountMicro = parseInt(requirements.amount ?? "0", 10); + const amountUSD = (amountMicro / 1_000_000).toFixed(6); + + log(`Scheme: ${scheme} | Amount: ${amountMicro} microunits ($${amountUSD}) | Network: ${requirements.network}`); + + // ── 4. Pre-flight checks ─────────────────────────────────────────── + + try { + const balance = await walletClient.readContract({ + address: USDC_BASE, + abi: parseAbi(["function balanceOf(address) view returns (uint256)"]), + functionName: "balanceOf", + args: [account.address], + }); + + const balanceMicro = Number(balance); + log(`USDC balance: ${formatUnits(balance, 6)} USDC`); + + if (balanceMicro < amountMicro) { + fatal("INSUFFICIENT_BALANCE", `USDC balance too low: have ${formatUnits(balance, 6)}, need ${amountUSD}`, { + walletAddress: account.address, + balanceUSDC: formatUnits(balance, 6), + requiredUSDC: amountUSD, + }); + } + } catch (err: any) { + if (err.errorType) throw err; // re-throw our own fatal errors + log(`Warning: could not check balance (${err.message}). Proceeding anyway.`); + } + + // For upto scheme, check Permit2 allowance + if (scheme === "upto") { + try { + const allowance = await walletClient.readContract({ + address: USDC_BASE, + abi: parseAbi([ + "function allowance(address,address) view returns (uint256)", + ]), + functionName: "allowance", + args: [account.address, PERMIT2], + }); + + if (allowance === 0n) { + fatal("PERMIT2_NOT_APPROVED", "USDC is not approved for Permit2. Run the setup script to approve.", { + walletAddress: account.address, + permit2Address: PERMIT2, + hint: "Run setup.ts to approve Permit2", + }); + } + log(`Permit2 allowance: ${formatUnits(allowance, 6)} USDC`); + } catch (err: any) { + if (err.errorType) throw err; + log(`Warning: could not check Permit2 allowance (${err.message}). Proceeding anyway.`); + } + } + + // ── 5. Sign payment ──────────────────────────────────────────────── + + log(`Signing ${scheme} payment...`); + + let payloadResult: any; + try { + const schemeClient = + scheme === "upto" ? new UptoEvmScheme(signer) : new ExactEvmScheme(signer); + + payloadResult = await schemeClient.createPaymentPayload( + paymentRequired.x402Version ?? 2, + requirements, + paymentRequired.extensions + ? { extensions: paymentRequired.extensions } + : undefined, + ); + } catch (err: any) { + fatal("SIGNING_FAILED", `Failed to sign payment: ${err.message}`); + } + + const fullPayload = { + x402Version: payloadResult.x402Version, + payload: payloadResult.payload, + accepted: requirements, + }; + const encoded = btoa(JSON.stringify(fullPayload)); + log(`Payment signed (${encoded.length} chars)`); + + // ── 6. Retry with payment signature ──────────────────────────────── + + log(`Retrying ${method} ${url} with PAYMENT-SIGNATURE...`); + + let resp2: Response; + try { + resp2 = await fetch(url, { + method, + headers: { + "PAYMENT-SIGNATURE": encoded, + ...(bodyPayload ? { "Content-Type": "application/json" } : {}), + ...extraHeaders, + }, + body: bodyPayload, + }); + } catch (err: any) { + fatal("NETWORK_ERROR", `Retry request failed: ${err.message}`, { url }); + } + + // ── 7. Parse response ────────────────────────────────────────────── + + const text2 = await resp2.text(); + let body2: unknown; + let bodyTruncated = false; + try { + body2 = JSON.parse(text2); + } catch { + if (text2.length > MAX_BODY_SIZE) { + body2 = text2.slice(0, MAX_BODY_SIZE); + bodyTruncated = true; + } else { + body2 = text2; + } + } + + // Parse settlement info from payment-response header + let settlement: Record | null = null; + const paymentResponseHeader = resp2.headers.get("payment-response"); + if (paymentResponseHeader) { + try { + settlement = JSON.parse(atob(paymentResponseHeader)) as Record; + } catch { + settlement = { raw: paymentResponseHeader }; + } + } + + if (resp2.status >= 200 && resp2.status < 300) { + log(`Payment successful (${resp2.status})`); + output({ + status: "success", + httpStatus: resp2.status, + body: body2, + ...(bodyTruncated && { bodyTruncated: true }), + ...(settlement && { settlement }), + payment: { + scheme, + amountMicro, + amountUSD, + }, + }); + } else { + log(`Payment failed (${resp2.status})`); + output({ + status: "error", + errorType: "PAYMENT_REJECTED", + message: `Server returned ${resp2.status} after payment`, + details: { + httpStatus: resp2.status, + body: body2, + ...(settlement && { settlement }), + }, + }); + } +} + +main().catch((err) => { + if (err.errorType) process.exit(1); // already handled by fatal() + fatal("UNEXPECTED", `Unexpected error: ${err.message}`); +});