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}`); +});