From 4ed8253cfb4ed27e9b74d9f4f7b47a1719d471e4 Mon Sep 17 00:00:00 2001 From: Miguel Piedrafita Date: Tue, 11 Aug 2026 16:42:47 -0700 Subject: [PATCH 1/2] new cli :3 --- cli/README.md | 37 ++++-- cli/REGISTRATION.md | 223 +++++---------------------------- cli/package.json | 5 +- cli/src/index.ts | 211 +++++++++++++++++++------------ cli/src/key.ts | 94 ++++++++++++++ cli/src/prove.ts | 148 ++++++++++++++++++++++ cli/test/key.test.ts | 86 +++++++++++++ cli/test/prove.test.ts | 114 +++++++++++++++++ skills/agentkit-x402/SKILL.md | 229 ++++++++++------------------------ 9 files changed, 699 insertions(+), 448 deletions(-) create mode 100644 cli/src/key.ts create mode 100644 cli/src/prove.ts create mode 100644 cli/test/key.test.ts create mode 100644 cli/test/prove.test.ts diff --git a/cli/README.md b/cli/README.md index 6d2e43c..92aa775 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,26 +1,39 @@ # @worldcoin/agentkit-cli -Register agent wallet addresses with World ID-verified humans via the [AgentBook](../contracts/src/AgentBook.sol) smart contract. +Register an agent with a World ID-verified human through AgentBook. -## Quick Start +## Usage + +```bash +agentkit register +``` -Prompt your agent: +That is the entire registration command. On its first run, the CLI creates a local identity, checks whether it is already registered, and only starts World ID verification when registration is still needed. Successful registrations are submitted through the hosted relay. +The private key is stored at: + +```text +$XDG_CONFIG_HOME/agentkit/key ``` -Run `npx @worldcoin/agentkit-cli --llms`, then help me register your wallet address in the AgentBook. + +When `XDG_CONFIG_HOME` is not set to an absolute path, the CLI uses: + +```text +~/.config/agentkit/key ``` -Use this by default. Do not ask the agent to inspect the repo to infer the flow. +Keep this file private and back it up. It is the durable identity for this agent. The private key never leaves the machine; registration sends the derived public address and World ID proof to the hosted relay. -## Usage +Full registration guide: [REGISTRATION.md](./REGISTRATION.md) + +## Prove this agent is registered + +Pass the `agentkit` extension from an x402 response as JSON: ```bash -agentkit register
[options] -agentkit status
+agentkit prove '{"agentkit":{"info":{"domain":"api.example.com","uri":"https://api.example.com/data","version":"1","nonce":"abc123","issuedAt":"2025-01-01T00:00:00.000Z","statement":"Verify your agent is backed by a real human"},"supportedChains":[{"chainId":"eip155:8453","type":"eip191"}],"schema":{}}}' ``` -By default, `agentkit register
` uses Base mainnet and submits through the hosted relay. Use `--manual` to print raw registration call data instead. - -Use `agentkit status
` to check whether an agent wallet is already registered in the canonical AgentBook on World Chain. +The command requires the local key created by `agentkit register` and confirms that its derived address is registered before signing. It selects the first supported EVM `eip191` chain, constructs the documented SIWE message, and returns a `signature` field containing the complete base64 value for the `agentkit` HTTP header. -Full registration guide: [REGISTRATION.md](./REGISTRATION.md) +The encoded value contains the public challenge response and EIP-191 signature. The private key remains in the local key file. diff --git a/cli/REGISTRATION.md b/cli/REGISTRATION.md index e39ab4f..c042cb8 100644 --- a/cli/REGISTRATION.md +++ b/cli/REGISTRATION.md @@ -1,228 +1,65 @@ -# Register an Agent Wallet with AgentBook +# Register with AgentBook -This guide explains how to register an agent wallet address in AgentBook using a World ID proof. +AgentKit manages the agent identity and registration details for you. You only need World App on a mobile device to complete the World ID verification step. -AgentBook links an agent wallet to an anonymous human identifier derived from World ID. The registration is written on-chain and can then be checked by apps and servers using `@worldcoin/agentkit`. - -## Default Flow for AI Agents - -```text -Run `npx @worldcoin/agentkit-cli --llms`, then help me register your wallet address in the AgentBook. -``` - -Use this by default. Only fall back to the steps below if you need the explicit manual flow. - -## What You Need - -- A wallet address for the agent you want to register -- World App on a mobile device to complete the World ID verification step -- Bun or npm installed locally -- Optional: an API endpoint that can submit the on-chain transaction for you - -## Install the CLI +## Install ```bash npm install -g @worldcoin/agentkit-cli ``` -You can also run it directly without a global install: - -```bash -npx @worldcoin/agentkit-cli register -``` - -Check an existing wallet registration: - -```bash -npx @worldcoin/agentkit-cli status -``` - -## Supported Networks - -- `base` -- `base-sepolia` - -Current AgentBook deployments used by the CLI: - -- Base: `0xE1D1D3526A6FAa37eb36bD10B933C1b77f4561a4` -- Base Sepolia: `0xA23aB2712eA7BBa896930544C7d6636a96b944dA` -- Worldchain: `0xA23aB2712eA7BBa896930544C7d6636a96b944dA` - -## Registration Flow - -When you run the CLI: - -1. The CLI reads the next required nonce for the agent address from AgentBook. -2. It creates a World ID verification request for the tuple `(agent address, nonce)`. -3. It shows a QR code and deep link for World App. -4. After verification completes, it returns the proof payload needed for `register(...)`. -5. By default on Base mainnet, the CLI submits through the hosted relay. If you want raw call data instead, use `--manual`. - -## Option 1: Manual Registration - -Use this when you want the CLI to produce the registration payload and contract call inputs, but you will send the transaction yourself instead of using the default relay. - -```bash -agentkit register 0x1234567890abcdef1234567890abcdef12345678 --manual -``` - -For Base Sepolia: - -```bash -agentkit register 0x1234567890abcdef1234567890abcdef12345678 --network base-sepolia --manual -``` - -After the World ID check succeeds, the CLI returns: - -- `agent` -- `root` -- `nonce` -- `nullifierHash` -- `proof` -- `contract` -- `network` - -Submit those values to: - -```solidity -register(address agent, uint256 root, uint256 nonce, uint256 nullifierHash, uint256[8] proof) -``` - -## Option 2: Automatic Registration via API - -Use this when you want a backend to accept the registration payload and submit the transaction on the agent's behalf. -This is the path to make registration gasless for the end user: the backend pays the Base gas, not the agent. - -For Base mainnet, automatic registration uses the shared hosted relay by default: - -```bash -agentkit register 0x1234567890abcdef1234567890abcdef12345678 -``` - -You can also be explicit: - -```bash -agentkit register 0x1234567890abcdef1234567890abcdef12345678 --network base --auto -``` - -Or override the relay and use your own compatible service: +You can also run the CLI without installing it globally: ```bash -API_URL=https://your-api.example.com agentkit register 0x1234567890abcdef1234567890abcdef12345678 --network base --auto -``` - -The CLI will `POST` the registration payload to: - -```text -POST {API_URL}/register -Content-Type: application/json -``` - -The shared hosted relay base URL is: - -```text -https://x402-worldchain.vercel.app -``` - -Note: - -- `API_URL` is the service base URL, not the facilitator URL path -- if you use the shared hosted service, use `https://x402-worldchain.vercel.app`, not `https://x402-worldchain.vercel.app/facilitator` -- the relay endpoint is only for sponsoring `AgentBook.register(...)` on Base -- it is separate from the x402 facilitator endpoints - -Example request body: - -```json -{ - "agent": "0x1234567890abcdef1234567890abcdef12345678", - "root": "123456789", - "nonce": "0", - "nullifierHash": "987654321", - "proof": ["0x...", "0x...", "0x...", "0x...", "0x...", "0x...", "0x...", "0x..."], - "contract": "0xE1D1D3526A6FAa37eb36bD10B933C1b77f4561a4", - "network": "base" -} +npx @worldcoin/agentkit-cli register ``` -On success, the API can return a transaction hash: +## Register -```json -{ - "txHash": "0x..." -} -``` - -Relay implementations should: - -- check on-chain first and refuse to spend gas if the agent is already registered -- refuse to sponsor when gas is above their configured cap -- return the manual registration payload when sponsorship is refused so the agent can self-send or retry later - -### Minimal Relayer Example - -This repo includes a minimal relayer at [`./examples/register-relayer.mjs`](./examples/register-relayer.mjs). -It accepts `POST /register`, simulates the Base transaction, and if valid submits it with a server-funded key. - -Run it with: - -```bash -cd cli -RELAYER_PRIVATE_KEY=0xyourfundedserverkey node examples/register-relayer.mjs -``` - -Then agents can register without holding Base ETH: +Run: ```bash -API_URL=http://localhost:3000 agentkit register 0x1234567890abcdef1234567890abcdef12345678 --network base --auto +agentkit register ``` -Protect this endpoint before using it in production. At minimum, add rate limiting, origin checks, and whatever authentication or allowlisting matches your app. +The CLI will: -## Example User Experience +1. Create a local identity if one does not already exist. +2. Check whether that identity is already registered. +3. If needed, show a World App QR code and link. +4. Wait for World ID verification. +5. Submit the registration through the hosted relay. -```bash -agentkit register 0x1234567890abcdef1234567890abcdef12345678 -``` +If the identity is already registered, the command exits successfully without asking for World ID verification or contacting the registration relay. -The CLI will: +## Local identity -- look up the next nonce -- print a World App QR code -- wait for verification -- submit the registration through the hosted Base relay +The identity's private key is stored at `$XDG_CONFIG_HOME/agentkit/key`. If `XDG_CONFIG_HOME` is unset, empty, or relative, the path is `~/.config/agentkit/key`. -## Notes +The CLI creates the key with owner-only permissions. Do not share, delete, or replace this file: doing so would change the agent's identity. Back it up using the same care you would use for any application credential. -- The agent address must be a valid EVM address. -- Registration is nonce-based. Re-registering the same agent requires the next nonce from the contract. -- The World ID proof is bound to both the agent address and the current nonce, so you cannot reuse an old proof for a later registration. -- `register
` defaults to `base` and automatic relay submission. -- Use `--manual` to print call data instead of submitting through the relay. -- Set `API_URL` to override the relay or to use `--auto` on networks without a default relay. +The private key is never sent over the network. The CLI derives a public address from it and sends that address plus the World ID registration proof to the hosted relay when registration is required. ## Troubleshooting -### Invalid Ethereum address +### Identity setup failed -Make sure the agent address is a 20-byte hex EVM address such as `0x1234...`. +Make sure the configured directory is writable. If a key already exists, ensure it contains the original valid AgentKit key. The CLI will not replace a malformed key automatically because replacing it would change the agent identity. ### Verification timed out -Retry the command and complete the World App step within the session window. +Run `agentkit register` again and complete the World App step within five minutes. The same local identity will be reused. -### No default relay is configured for this network +### Registration lookup or submission failed -Base mainnet has a default hosted relay. Other networks require an explicit override: +Check the network connection and retry. The command checks registration before starting a new verification, so it is safe to rerun after an uncertain response. -```bash -API_URL=https://your-api.example.com agentkit register 0x1234567890abcdef1234567890abcdef12345678 --network base-sepolia --auto -``` +## Prove registration to an x402 service -### Transaction reverted +After registration, pass an x402 AgentKit extension to `prove` as JSON: -Check that: +```bash +agentkit prove '' +``` -- you submitted to the correct AgentBook contract for the selected network -- you used the exact `nonce` returned by the CLI -- the proof and root were submitted unchanged +The command does not create a missing key and will not sign for an unregistered identity. On success, its `signature` result is the base64-encoded AgentKit authorization value to send in the `agentkit` HTTP header. diff --git a/cli/package.json b/cli/package.json index cf94a98..ef829b8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@worldcoin/agentkit-cli", "version": "0.2.0", - "description": "Register agent wallets with World ID-verified humans via AgentBook.", + "description": "Register agents with World ID-verified humans via AgentBook.", "repository": { "type": "git", "url": "https://github.com/worldcoin/agentkit.git", @@ -21,7 +21,8 @@ }, "scripts": { "build": "tsc", - "cli": "tsx src/index.ts" + "cli": "tsx src/index.ts", + "test": "bun test" }, "dependencies": { "@worldcoin/idkit-core": "2.1.0", diff --git a/cli/src/index.ts b/cli/src/index.ts index 84a82ce..97b754b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -8,6 +8,13 @@ import { createWorldBridgeStore } from '@worldcoin/idkit-core' import type { ISuccessResult } from '@worldcoin/idkit-core' import { solidityEncode } from '@worldcoin/idkit-core/hashing' import qrcode from 'qrcode-terminal' +import { loadAgentSigner, loadOrCreateAgentIdentity } from './key.js' +import { + AgentkitPayloadError, + agentkitExtensionInputSchema, + createAgentkitProof, + parseAgentkitExtension, +} from './prove.js' // ─── Config ────────────────────────────────────────────────────────────────── @@ -32,13 +39,13 @@ const AGENT_BOOK_ABI = [ const APP_ID = 'app_a7c3e2b6b83927251a0db5345bd7146a' const ACTION = 'agentbook-registration' -const DEFAULT_API_URL = 'https://x402-worldchain.vercel.app' +const REGISTRATION_RELAY_URL = 'https://x402-worldchain.vercel.app/register' const AGENT_BOOK_NETWORK = 'eip155:480' // ─── CLI ───────────────────────────────────────────────────────────────────── const cli = Cli.create('agentkit', { - description: 'Register agent wallets with World ID-verified humans via AgentBook.', + description: 'Register an agent with a World ID-verified human via AgentBook.', version: '0.1.0', }) @@ -108,42 +115,59 @@ cli.command('status', { }) cli.command('register', { - description: 'Register an agent wallet address with a World ID proof.', - args: z.object({ - address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address').describe('Agent wallet address'), - }), - options: z.object({ - auto: z.boolean().default(true).describe('Submit registration to the default relay or API_URL override'), - manual: z.boolean().optional().describe('Print manual call data instead of submitting through a relay'), - }), - alias: { auto: 'a', manual: 'm' }, - env: z.object({ - API_URL: z - .string() - .optional() - .describe('Override API base URL for registration relay; defaults to https://x402-worldchain.vercel.app'), - }), + description: 'Register this agent with a World ID proof.', outputPolicy: 'agent-only', output: z.object({ - agent: z.string(), - root: z.string(), - nonce: z.string(), - nullifierHash: z.string(), - proof: z.array(z.string()), - contract: z.string(), - txHash: z.string().optional(), + registered: z.boolean().describe('Whether this agent is registered'), + alreadyRegistered: z.boolean().describe('Whether registration was already complete before this run'), }), - examples: [ - { args: { address: '0x1234567890abcdef1234567890abcdef12345678' }, description: 'Register on World Chain' }, - ], async run(c) { - const agentAddress = c.args.address as `0x${string}` - const shouldAuto = c.options.manual ? false : c.options.auto + if (!c.agent) console.log(' Preparing this agent...') - // 1. Read next nonce from AgentBook contract - if (!c.agent) console.log(` Looking up next nonce for ${agentAddress}...`) + let agentAddress: `0x${string}` + try { + const identity = await loadOrCreateAgentIdentity() + agentAddress = identity.address + if (!c.agent && identity.created) console.log(' \x1b[32m✓ Local identity created\x1b[0m') + } catch (err) { + return c.error({ + code: 'IDENTITY_SETUP_FAILED', + message: err instanceof Error ? err.message : 'Unable to set up the local agent identity', + }) + } const client = createPublicClient({ chain: worldchain, transport: http() }) + + if (!c.agent) console.log(' Checking registration status...') + + let existingHumanId: bigint + try { + existingHumanId = await client.readContract({ + address: AGENT_BOOK_CONTRACT, + abi: AGENT_BOOK_ABI, + functionName: 'lookupHuman', + args: [agentAddress], + }) + } catch (err) { + return c.error({ + code: 'REGISTRATION_LOOKUP_FAILED', + message: err instanceof Error ? err.message : 'Unable to check registration status', + retryable: true, + }) + } + + if (existingHumanId !== 0n) { + if (!c.agent) { + console.log() + console.log(' \x1b[32m\x1b[1m✓ This agent is already registered\x1b[0m') + console.log() + } + return { registered: true, alreadyRegistered: true } + } + + // 1. Read next nonce from AgentBook contract + if (!c.agent) console.log(' Starting registration...') + const nonce = await client.readContract({ address: AGENT_BOOK_CONTRACT, abi: AGENT_BOOK_ABI, @@ -151,8 +175,6 @@ cli.command('register', { args: [agentAddress], }) - if (!c.agent) console.log(` Nonce: ${nonce}`) - // 2. Build the signal payload const signal = solidityEncode(['address', 'uint256'], [agentAddress, nonce]) @@ -189,8 +211,6 @@ cli.command('register', { if (!c.agent) { console.log() console.log(' \x1b[32m\x1b[1m✓ World ID verified\x1b[0m') - console.log(` Merkle root: ${completion.proof.merkle_root}`) - console.log(` Nullifier hash: ${completion.proof.nullifier_hash}`) } const proof = normalizeProof(completion.proof) @@ -208,31 +228,12 @@ cli.command('register', { contract: AGENT_BOOK_CONTRACT, } - if (!shouldAuto) { - if (!c.agent) { - console.log() - console.log(' Submit this transaction on-chain:') - console.log() - console.log(` Contract: ${AGENT_BOOK_CONTRACT}`) - console.log( - ' Function: register(address agent, uint256 root, uint256 nonce, uint256 nullifierHash, uint256[8] proof)' - ) - } - - return registration - } - - const apiUrl = c.env.API_URL ?? DEFAULT_API_URL - - const registerUrl = `${apiUrl.replace(/\/$/, '')}/register` - if (!c.agent) { console.log() - console.log(` Registering agent ${agentAddress}...`) - console.log(` Relay: \x1b[90m${apiUrl}\x1b[0m`) + console.log(' Completing registration...') } - const response = await fetch(registerUrl, { + const response = await fetch(REGISTRATION_RELAY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(registration), @@ -243,33 +244,83 @@ cli.command('register', { return c.error({ code: 'REGISTRATION_FAILED', message: `${response.status}: ${body}`, retryable: true }) } - const result = (await response.json()) as { txHash?: string } - if (!c.agent) { - const lines = [ - '', - '\x1b[32m\x1b[1m✓ Agent registered on World Chain\x1b[0m', - '', - `Agent \x1b[36m${agentAddress}\x1b[0m`, - ...(result.txHash ? [`Tx \x1b[90m${result.txHash}\x1b[0m`] : []), - '', - ] - - // Measure visible width (strip ANSI codes) - const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '') - const maxWidth = Math.max(...lines.map(l => strip(l).length)) - const pad = (s: string) => s + ' '.repeat(maxWidth - strip(s).length) - console.log() - console.log(` ┌${'─'.repeat(maxWidth + 6)}┐`) - for (const line of lines) { - console.log(` │ ${pad(line)} │`) - } - console.log(` └${'─'.repeat(maxWidth + 6)}┘`) + console.log(' \x1b[32m\x1b[1m✓ This agent is registered\x1b[0m') console.log() } - return { ...registration, txHash: result.txHash } + return { registered: true, alreadyRegistered: false } + }, +}) + +cli.command('prove', { + description: 'Sign an x402 AgentKit challenge with this registered agent.', + args: z.object({ + payload: agentkitExtensionInputSchema, + }), + output: z.object({ + signature: z.string().describe('Base64-encoded AgentKit authorization value'), + }), + async run(c) { + let extension + try { + extension = parseAgentkitExtension(c.args.payload) + } catch (err) { + return c.error({ + code: 'INVALID_AGENTKIT_PAYLOAD', + message: err instanceof Error ? err.message : 'Invalid AgentKit extension payload', + }) + } + + let signer + try { + signer = await loadAgentSigner() + } catch (err) { + const keyMissing = hasErrorCode(err, 'ENOENT') + return c.error({ + code: keyMissing ? 'KEY_NOT_FOUND' : 'IDENTITY_LOAD_FAILED', + message: keyMissing + ? 'No AgentKit key is available. Run `agentkit register` first.' + : err instanceof Error + ? err.message + : 'Unable to load the local agent identity', + }) + } + + const client = createPublicClient({ chain: worldchain, transport: http() }) + let humanId: bigint + try { + humanId = await client.readContract({ + address: AGENT_BOOK_CONTRACT, + abi: AGENT_BOOK_ABI, + functionName: 'lookupHuman', + args: [signer.address], + }) + } catch (err) { + return c.error({ + code: 'REGISTRATION_LOOKUP_FAILED', + message: err instanceof Error ? err.message : 'Unable to check registration status', + retryable: true, + }) + } + + if (humanId === 0n) { + return c.error({ + code: 'AGENT_NOT_REGISTERED', + message: 'This agent is not registered. Run `agentkit register` first.', + }) + } + + try { + const proof = await createAgentkitProof(extension, signer) + return { signature: proof.encoded } + } catch (err) { + return c.error({ + code: err instanceof AgentkitPayloadError ? 'INVALID_AGENTKIT_PAYLOAD' : 'SIGNING_FAILED', + message: err instanceof Error ? err.message : 'Unable to sign the AgentKit challenge', + }) + } }, }) @@ -285,6 +336,10 @@ function bigintToHex(value: bigint): string { return `0x${value.toString(16)}` } +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code +} + async function waitForCompletion( worldID: ReturnType, timeoutMs: number diff --git a/cli/src/key.ts b/cli/src/key.ts new file mode 100644 index 0000000..c88c106 --- /dev/null +++ b/cli/src/key.ts @@ -0,0 +1,94 @@ +import { randomUUID } from 'node:crypto' +import { chmod, link, mkdir, readFile, unlink, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' + +export type AgentIdentity = { + address: `0x${string}` + created: boolean + keyPath: string +} + +export type AgentSigner = { + address: `0x${string}` + signMessage: (message: string) => Promise<`0x${string}`> +} + +export function getAgentkitKeyPath( + env: NodeJS.ProcessEnv = process.env, + homeDirectory: string = homedir() +): string { + const configuredHome = env.XDG_CONFIG_HOME?.trim() + const configHome = configuredHome && isAbsolute(configuredHome) ? configuredHome : join(homeDirectory, '.config') + return join(configHome, 'agentkit', 'key') +} + +export async function loadOrCreateAgentIdentity(keyPath: string = getAgentkitKeyPath()): Promise { + const keyDirectory = dirname(keyPath) + await mkdir(keyDirectory, { recursive: true, mode: 0o700 }) + await chmod(keyDirectory, 0o700) + + try { + return await readIdentity(keyPath, false) + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') throw error + } + + const privateKey = generatePrivateKey() + const temporaryKeyPath = `${keyPath}.${process.pid}.${randomUUID()}.tmp` + try { + await writeFile(temporaryKeyPath, `${privateKey}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }) + await link(temporaryKeyPath, keyPath) + return identityFromPrivateKey(privateKey, keyPath, true) + } catch (error) { + if (!isNodeError(error) || error.code !== 'EEXIST') throw error + return await readIdentity(keyPath, false) + } finally { + try { + await unlink(temporaryKeyPath) + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') throw error + } + } +} + +export async function loadAgentSigner(keyPath: string = getAgentkitKeyPath()): Promise { + const account = accountFromPrivateKey(await readPrivateKey(keyPath), keyPath) + return { + address: account.address, + signMessage: message => account.signMessage({ message }), + } +} + +async function readIdentity(keyPath: string, created: boolean): Promise { + return identityFromPrivateKey(await readPrivateKey(keyPath), keyPath, created) +} + +async function readPrivateKey(keyPath: string): Promise<`0x${string}`> { + const contents = await readFile(keyPath, 'utf8') + const privateKey = contents.trim() + + if (!/^0x[0-9a-fA-F]{64}$/.test(privateKey)) { + throw new Error(`Invalid AgentKit key at ${keyPath}`) + } + + await chmod(keyPath, 0o600) + return privateKey as `0x${string}` +} + +function identityFromPrivateKey(privateKey: `0x${string}`, keyPath: string, created: boolean): AgentIdentity { + return { address: accountFromPrivateKey(privateKey, keyPath).address, created, keyPath } +} + +function accountFromPrivateKey(privateKey: `0x${string}`, keyPath: string) { + try { + return privateKeyToAccount(privateKey) + } catch { + throw new Error(`Invalid AgentKit key at ${keyPath}`) + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/cli/src/prove.ts b/cli/src/prove.ts new file mode 100644 index 0000000..fa9edd3 --- /dev/null +++ b/cli/src/prove.ts @@ -0,0 +1,148 @@ +import { z } from 'incur' + +const singleLine = z + .string() + .min(1) + .refine(value => !/[\r\n]/.test(value), 'Must not contain line breaks') + +const agentkitInfoSchema = z + .object({ + domain: singleLine, + uri: singleLine, + version: singleLine, + nonce: singleLine, + issuedAt: singleLine, + statement: singleLine.optional(), + expirationTime: singleLine.optional(), + notBefore: singleLine.optional(), + requestId: singleLine.optional(), + resources: z.array(singleLine).min(1).optional(), + }) + .passthrough() + +const supportedChainSchema = z + .object({ + chainId: singleLine, + type: singleLine, + }) + .passthrough() + +export const agentkitExtensionSchema = z + .object({ + agentkit: z + .object({ + info: agentkitInfoSchema, + supportedChains: z.array(supportedChainSchema).min(1), + schema: z.unknown().optional(), + }) + .passthrough(), + }) + .passthrough() + +export const agentkitExtensionInputSchema = z + .union([z.string(), agentkitExtensionSchema]) + .describe('AgentKit extension payload as JSON or a structured object') + +export type AgentkitExtension = z.infer +export type AgentkitInfo = z.infer + +export type MessageSigner = { + address: `0x${string}` + signMessage: (message: string) => Promise<`0x${string}`> +} + +export type AgentkitProof = { + encoded: string + message: string + authorization: AgentkitInfo & { + address: `0x${string}` + chainId: string + type: 'eip191' + signature: `0x${string}` + } +} + +export class AgentkitPayloadError extends Error { + constructor(message: string) { + super(message) + this.name = 'AgentkitPayloadError' + } +} + +export function parseAgentkitExtension(input: unknown): AgentkitExtension { + let payload = input + if (typeof input === 'string') { + try { + payload = JSON.parse(input) + } catch { + throw new AgentkitPayloadError('AgentKit extension payload is not valid JSON') + } + } + + const parsed = agentkitExtensionSchema.safeParse(payload) + if (!parsed.success) { + const details = parsed.error.issues + .map(issue => `${issue.path.join('.') || 'payload'}: ${issue.message}`) + .join('; ') + throw new AgentkitPayloadError(`Invalid AgentKit extension payload: ${details}`) + } + + return parsed.data +} + +export function selectEip191Chain( + supportedChains: AgentkitExtension['agentkit']['supportedChains'] +): { chainId: string; numericChainId: string } { + for (const supportedChain of supportedChains) { + if (supportedChain.type !== 'eip191') continue + const match = /^eip155:(0|[1-9]\d*)$/.exec(supportedChain.chainId) + if (match) return { chainId: supportedChain.chainId, numericChainId: match[1]! } + } + + throw new AgentkitPayloadError('The AgentKit extension does not support an EVM EIP-191 signer') +} + +export function createSiweMessage( + info: AgentkitInfo, + address: `0x${string}`, + numericChainId: string +): string { + const lines = [`${info.domain} wants you to sign in with your Ethereum account:`, address, ''] + + if (info.statement !== undefined) lines.push(info.statement, '') + + lines.push( + `URI: ${info.uri}`, + `Version: ${info.version}`, + `Chain ID: ${numericChainId}`, + `Nonce: ${info.nonce}`, + `Issued At: ${info.issuedAt}` + ) + + if (info.expirationTime !== undefined) lines.push(`Expiration Time: ${info.expirationTime}`) + if (info.notBefore !== undefined) lines.push(`Not Before: ${info.notBefore}`) + if (info.requestId !== undefined) lines.push(`Request ID: ${info.requestId}`) + if (info.resources !== undefined) { + lines.push('Resources:') + for (const resource of info.resources) lines.push(`- ${resource}`) + } + + return lines.join('\n') +} + +export async function createAgentkitProof(input: unknown, signer: MessageSigner): Promise { + const extension = parseAgentkitExtension(input) + const { chainId, numericChainId } = selectEip191Chain(extension.agentkit.supportedChains) + const message = createSiweMessage(extension.agentkit.info, signer.address, numericChainId) + const signature = await signer.signMessage(message) + const authorization = { + ...extension.agentkit.info, + address: signer.address, + chainId, + type: 'eip191' as const, + signature, + } + const encoded = Buffer.from(JSON.stringify(authorization), 'utf8').toString('base64') + + return { encoded, message, authorization } +} diff --git a/cli/test/key.test.ts b/cli/test/key.test.ts new file mode 100644 index 0000000..10f4d19 --- /dev/null +++ b/cli/test/key.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { recoverMessageAddress } from 'viem' +import { getAgentkitKeyPath, loadAgentSigner, loadOrCreateAgentIdentity } from '../src/key.js' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +describe('getAgentkitKeyPath', () => { + test('uses XDG_CONFIG_HOME when it is absolute', () => { + expect(getAgentkitKeyPath({ XDG_CONFIG_HOME: '/tmp/custom-config' }, '/tmp/home')).toBe( + '/tmp/custom-config/agentkit/key' + ) + }) + + test('falls back to the home config directory for an invalid relative XDG_CONFIG_HOME', () => { + expect(getAgentkitKeyPath({ XDG_CONFIG_HOME: 'relative' }, '/tmp/home')).toBe('/tmp/home/.config/agentkit/key') + }) +}) + +describe('loadOrCreateAgentIdentity', () => { + test('creates a private key once with restricted permissions', async () => { + const keyPath = await makeKeyPath() + + const first = await loadOrCreateAgentIdentity(keyPath) + const second = await loadOrCreateAgentIdentity(keyPath) + + expect(first.created).toBe(true) + expect(second.created).toBe(false) + expect(second.address).toBe(first.address) + expect(await readFile(keyPath, 'utf8')).toMatch(/^0x[0-9a-f]{64}\n$/) + expect((await stat(keyPath)).mode & 0o777).toBe(0o600) + expect((await stat(join(keyPath, '..'))).mode & 0o777).toBe(0o700) + }) + + test('concurrent first runs converge on the same identity', async () => { + const keyPath = await makeKeyPath() + const identities = await Promise.all(Array.from({ length: 8 }, () => loadOrCreateAgentIdentity(keyPath))) + + expect(new Set(identities.map(identity => identity.address)).size).toBe(1) + expect(identities.filter(identity => identity.created)).toHaveLength(1) + }) + + test('rejects an invalid existing key without replacing it', async () => { + const keyPath = await makeKeyPath() + await loadOrCreateAgentIdentity(keyPath) + await writeFile(keyPath, 'not-a-private-key\n') + + await expect(loadOrCreateAgentIdentity(keyPath)).rejects.toThrow(`Invalid AgentKit key at ${keyPath}`) + expect(await readFile(keyPath, 'utf8')).toBe('not-a-private-key\n') + }) + + test('loads the existing identity as an EIP-191 signer', async () => { + const keyPath = await makeKeyPath() + const identity = await loadOrCreateAgentIdentity(keyPath) + const signer = await loadAgentSigner(keyPath) + const message = 'AgentKit signing test' + + const signature = await signer.signMessage(message) + + expect(signer.address).toBe(identity.address) + expect(await recoverMessageAddress({ message, signature })).toBe(identity.address) + }) + + test('does not create a key when loading a signer', async () => { + const keyPath = await makeKeyPath() + + try { + await loadAgentSigner(keyPath) + throw new Error('Expected signer loading to fail') + } catch (error) { + expect(error).toHaveProperty('code', 'ENOENT') + } + }) +}) + +async function makeKeyPath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'agentkit-cli-')) + temporaryDirectories.push(directory) + return join(directory, 'config', 'agentkit', 'key') +} diff --git a/cli/test/prove.test.ts b/cli/test/prove.test.ts new file mode 100644 index 0000000..effe4d0 --- /dev/null +++ b/cli/test/prove.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'bun:test' +import { verifyMessage } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { + AgentkitPayloadError, + createAgentkitProof, + createSiweMessage, + parseAgentkitExtension, + selectEip191Chain, +} from '../src/prove.js' + +const payload = { + agentkit: { + info: { + domain: 'api.example.com', + uri: 'https://api.example.com/data', + version: '1', + nonce: 'abc123', + issuedAt: '2025-01-01T00:00:00.000Z', + statement: 'Verify your agent is backed by a real human', + }, + supportedChains: [ + { chainId: 'eip155:8453', type: 'eip1271' }, + { chainId: 'eip155:8453', type: 'eip191' }, + ], + schema: { type: 'object' }, + }, +} + +describe('AgentKit extension parsing', () => { + test('accepts structured and JSON payloads', () => { + expect(parseAgentkitExtension(payload)).toEqual(payload) + expect(parseAgentkitExtension(JSON.stringify(payload))).toEqual(payload) + }) + + test('rejects invalid JSON and line-break injection', () => { + expect(() => parseAgentkitExtension('{')).toThrow(AgentkitPayloadError) + expect(() => + parseAgentkitExtension({ + ...payload, + agentkit: { ...payload.agentkit, info: { ...payload.agentkit.info, nonce: 'abc\nURI: injected' } }, + }) + ).toThrow('Must not contain line breaks') + }) +}) + +describe('SIWE construction', () => { + test('selects the first EVM EIP-191 chain', () => { + expect(selectEip191Chain(parseAgentkitExtension(payload).agentkit.supportedChains)).toEqual({ + chainId: 'eip155:8453', + numericChainId: '8453', + }) + }) + + test('rejects payloads that do not support this EOA signer', () => { + expect(() => selectEip191Chain([{ chainId: 'eip155:8453', type: 'eip1271' }])).toThrow( + 'The AgentKit extension does not support an EVM EIP-191 signer' + ) + }) + + test('formats required and optional fields in the documented order', () => { + const info = { + ...payload.agentkit.info, + expirationTime: '2025-01-01T00:05:00.000Z', + notBefore: '2025-01-01T00:00:01.000Z', + requestId: 'req-456', + resources: ['https://api.example.com/tos'], + } + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + + expect(createSiweMessage(info, address, '8453')).toBe( + 'api.example.com wants you to sign in with your Ethereum account:\n' + + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\n' + + '\n' + + 'Verify your agent is backed by a real human\n' + + '\n' + + 'URI: https://api.example.com/data\n' + + 'Version: 1\n' + + 'Chain ID: 8453\n' + + 'Nonce: abc123\n' + + 'Issued At: 2025-01-01T00:00:00.000Z\n' + + 'Expiration Time: 2025-01-01T00:05:00.000Z\n' + + 'Not Before: 2025-01-01T00:00:01.000Z\n' + + 'Request ID: req-456\n' + + 'Resources:\n' + + '- https://api.example.com/tos' + ) + }) + + test('uses one blank line between the address and URI when there is no statement', () => { + const { statement: _, ...info } = payload.agentkit.info + const message = createSiweMessage(info, '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', '8453') + + expect(message).toContain('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\n\nURI:') + expect(message.endsWith('\n')).toBe(false) + }) +}) + +test('creates a base64 AgentKit authorization with a valid EIP-191 signature', async () => { + const account = privateKeyToAccount(`0x${'01'.padStart(64, '0')}`) + const proof = await createAgentkitProof(payload, { + address: account.address, + signMessage: message => account.signMessage({ message }), + }) + const decoded = JSON.parse(Buffer.from(proof.encoded, 'base64').toString('utf8')) + + expect(decoded).toEqual(proof.authorization) + expect(decoded.address).toBe(account.address) + expect(decoded.chainId).toBe('eip155:8453') + expect(decoded.type).toBe('eip191') + expect( + await verifyMessage({ address: account.address, message: proof.message, signature: decoded.signature }) + ).toBe(true) +}) diff --git a/skills/agentkit-x402/SKILL.md b/skills/agentkit-x402/SKILL.md index d9319e1..a7fd1d1 100644 --- a/skills/agentkit-x402/SKILL.md +++ b/skills/agentkit-x402/SKILL.md @@ -1,70 +1,29 @@ --- name: agentkit-x402 -description: Use this skill when you receive a 402 Payment Required response that contains an `agentkit` extension. Covers checking 402 responses for the AgentKit extension before paying, constructing and signing a CAIP-122 challenge (SIWE for EVM), sending the signed `agentkit` HTTP header, and interpreting access modes (free, free-trial, discount). Supports both EOA wallets (EIP-191) and Smart Contract Wallets (ERC-1271, e.g. Coinbase Smart Wallet, Safe). +description: Use when an HTTP 402 Payment Required response contains an `agentkit` extension. Use the AgentKit CLI to prove the local agent is registered, attach the returned base64 authorization as the `agentkit` header, interpret free, free-trial, and discount modes, and fall back to x402 payment only when AgentKit access is unavailable. --- -# Using AgentKit as an Agent +# Authenticate to x402 with AgentKit -This skill is for AI agents that interact with websites and APIs protected by x402 + AgentKit. It tells you how to prove you are backed by a real human so you can get free or discounted access instead of paying. +## Always try AgentKit before paying -## Critical Rule: Always Check for AgentKit Before Paying +When an HTTP request returns `402 Payment Required`, inspect the response for an `agentkit` extension before authorizing payment. Attempt AgentKit authentication whenever the extension is present. -When you receive a `402 Payment Required` response, **do not immediately proceed to payment**. First inspect the response for an `agentkit` extension. If one is present, attempt the AgentKit authentication flow below. Only fall back to payment if: +Fall back to the normal x402 payment flow only when: -- There is no `agentkit` extension in the 402 response -- Your AgentKit authentication attempt fails and the server requires payment -- You have exhausted your free-trial uses +- the response has no `agentkit` extension; +- `agentkit prove` cannot authenticate this agent; or +- the service rejects the proof or reports that free-trial access is exhausted. -## Preferred Runtime Helper +Do not construct or sign SIWE messages manually. Do not read or request a private key. The AgentKit CLI owns identity loading, registration checks, challenge formatting, chain selection, signing, and authorization encoding. -If you can change the agent's HTTP client, use `createAgentkitClient` from `@worldcoin/agentkit` and route x402 API calls through `agentkit.fetch`. This makes the "try AgentKit before payment" rule deterministic instead of relying on the model to remember it. +## Authenticate the request -```typescript -import { createAgentkitClient } from '@worldcoin/agentkit' +### 1. Extract the complete extension -const agentkit = createAgentkitClient({ - signer: { - address: wallet.address, - chainId: 'eip155:8453', - type: 'eip191', - signMessage: message => wallet.signMessage(message), - }, -}) +Pass a JSON object with the top-level `agentkit` key to the CLI. If the 402 body nests the extension under an `extensions` object, wrap the extension value as `{ "agentkit": extensionValue }`. -const response = await agentkit.fetch('https://api.example.com/data') -``` - -If `agentkit.fetch` returns another 402, continue with the normal x402 payment flow. - -## Wallet Types - -Your wallet determines how you sign the challenge. There are two types: - -### EOA (Externally Owned Account) - -A standard wallet where you directly hold the private key (e.g. a raw private key, a mnemonic-derived wallet). - -- **Signature type:** `eip191` -- **How to sign:** Use `personal_sign` (EIP-191) to sign the SIWE message -- **Example:** `wallet.signMessage(siweMessage)` - -### Smart Contract Wallet (SCW) - -A wallet where the "account" is a smart contract and signing is done by an underlying owner key. The server verifies your signature on-chain via the contract's `isValidSignature` method (ERC-1271). - -Examples: Coinbase Smart Wallet, Safe, any ERC-4337 account. - -- **Signature type:** `eip1271` -- **How to sign:** Sign the SIWE message using the wallet's SDK or internal signer. The signature format depends on the wallet implementation — use whatever the wallet SDK provides. -- **Example (Coinbase CDP):** `account.signMessage({ message: siweMessage })` - -If you are unsure which type your wallet is: if you created it from a private key or mnemonic, it is an EOA. If you created it through a wallet SDK (Coinbase CDP, Safe SDK, etc.), it is likely an SCW. - -## The AgentKit Flow - -### Step 1: Parse the 402 Response - -When you receive a `402 Payment Required`, look for the `agentkit` extension in the response body. The 402 response contains `x402` data with extensions. The `agentkit` extension looks like: +Example payload: ```json { @@ -81,155 +40,99 @@ When you receive a `402 Payment Required`, look for the `agentkit` extension in { "chainId": "eip155:8453", "type": "eip191" }, { "chainId": "eip155:8453", "type": "eip1271" } ], - "schema": { ... } + "schema": {} } } ``` -Key fields to extract: - -- **`info`** — the challenge data you must sign -- **`supportedChains`** — which chains and signature types the server accepts -- **`mode`** (if present) — tells you the access policy: `free`, `free-trial`, or `discount` - -### Step 2: Pick a Chain and Signature Type +Preserve every `info` field exactly. Do not change the nonce, timestamps, URI, statement, resources, or supported chains. -Match your wallet to one of the `supportedChains` entries: +Before signing, confirm that `info.domain` and `info.uri` describe the service and request you intended to access. Treat a mismatch as an invalid or suspicious challenge. -| Your wallet | Match `chainId` | Use `type` | -|--------------------|----------------------|-------------| -| EVM EOA | `eip155:*` | `eip191` | -| EVM Smart Contract | `eip155:*` | `eip1271` | +### 2. Ask the CLI to prove the agent -Pick the entry that matches both your chain and wallet type. - -### Step 3: Construct and Sign the SIWE Message - -Construct a SIWE (EIP-4361) message string from the challenge `info` fields. The format is a plain text string with this exact structure: +Pass the serialized JSON payload as one argument: +```bash +agentkit prove '' ``` -{domain} wants you to sign in with your Ethereum account: -{address} -{statement} +If the CLI is not installed globally, use: -URI: {uri} -Version: {version} -Chain ID: {numericChainId} -Nonce: {nonce} -Issued At: {issuedAt} +```bash +npx @worldcoin/agentkit-cli prove '' ``` -Where `{numericChainId}` is extracted from the CAIP-2 chain ID (e.g. `eip155:8453` becomes `8453`), and `{address}` must be EIP-55 checksummed. - -If the challenge includes optional fields, append them in this order (only include lines for fields that are present): - -``` -Expiration Time: {expirationTime} -Not Before: {notBefore} -Request ID: {requestId} -Resources: -- {resources[0]} -- {resources[1]} -``` +The command: -Full example: +- loads the existing identity from the AgentKit XDG key file without creating a key; +- verifies that the derived address is registered in AgentBook; +- selects the first supported `eip155:*` chain with type `eip191`; +- constructs and signs the required SIWE message; and +- returns a `signature` field containing the complete base64 AgentKit authorization value. -``` -api.example.com wants you to sign in with your Ethereum account: -0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 - -Verify your agent is backed by a real human - -URI: https://api.example.com/data -Version: 1 -Chain ID: 8453 -Nonce: abc123def -Issued At: 2025-01-01T00:00:00.000Z -Expiration Time: 2025-01-01T00:05:00.000Z -Request ID: req-456 -Resources: -- https://api.example.com/tos -``` +Use the returned `signature` value directly as the HTTP header value. It is already an encoded JSON authorization containing the challenge information, public address, CAIP-2 chain ID, `eip191` type, and EIP-191 signature. -**Important formatting rules:** -- There must be a blank line before and after the `{statement}` line -- If there is no statement, there must be a single blank line between the address and `URI:` -- Each line ends with `\n` (LF, not CRLF) -- No trailing newline after the last line +Do not decode, edit, or re-encode it. Do not confuse it with the inner hexadecimal EIP-191 signature. -Then sign the message string: +### 3. Retry the original request -```typescript -// EOA — use personal_sign (EIP-191) -const signature = await wallet.signMessage(messageToSign) +Repeat the original request with the same method, URI, body, and non-AgentKit headers, adding: -// SCW — use the wallet SDK's signMessage -const signature = await smartWallet.signMessage({ message: messageToSign }) +```text +agentkit: ``` -### Step 4: Send the Request +Send the authorization only to the original challenge URI and domain. Treat it as an ephemeral credential: do not persist it, print it unnecessarily, or reuse it for another request or challenge. -Base64-encode a JSON object containing the challenge `info` fields plus your `address`, `type`, and `signature`, and send it as the `agentkit` HTTP header: +If the service grants access, return the resource without paying. If it responds with another 402, interpret the access mode and error before deciding whether to pay. -```typescript -const header = btoa(JSON.stringify({ - ...info, // all fields from the challenge - address: walletAddress, - chainId: "eip155:8453", // from the supportedChains entry you picked - type: "eip191", // "eip191" for EOA, "eip1271" for SCW - signature: signature, // hex string from signing -})) +## Handle access modes -const response = await fetch("https://api.example.com/data", { - headers: { - "agentkit": header, - }, -}) -``` +Read the extension's `mode` when present: + +| Mode | Behavior | +|---|---| +| `free` | Retry with only the `agentkit` header. Human-backed agents receive free access. | +| `free-trial` | Retry with only the `agentkit` header until the service reports that the per-human trial is exhausted. Then use the normal payment flow. | +| `discount` | Send both the `agentkit` header and the x402 payment header, paying the discounted amount specified by the service. | -If the server grants access (based on the mode), you will receive the resource directly — no payment needed. +If no mode is present, try the `agentkit` header without payment first. -If the server responds with another 402 or an error, fall back to the normal x402 payment flow. +## Recover from errors -## Access Modes +### `KEY_NOT_FOUND` -The `mode` field in the AgentKit extension tells you what to expect: +No local AgentKit identity exists. Run: -| Mode | What it means | -|--------------|----------------------------------------------------------------| -| `free` | Human-backed agents always get free access | -| `free-trial` | First N requests are free, then you must pay (N is per human, not per agent) | -| `discount` | You get N% off the price (pay the discounted amount via x402) | +```bash +agentkit register +``` -For `discount` mode: send **both** the `agentkit` header and the x402 payment header, but pay the discounted price. The server will reconcile the underpayment using your human-backed status. +Registration creates the local identity, checks AgentBook, and starts World ID verification only when needed. Human action may be required. After registration completes, fetch a new 402 challenge and run `prove` again. -## Common Errors and How to Handle Them +### `AGENT_NOT_REGISTERED` -### "Agent is not registered in the AgentBook" +The existing identity has not been registered. Run `agentkit register`, complete World ID verification, then fetch a fresh challenge and retry. -Your wallet address is not registered. You need to register first using the AgentKit CLI: +### `IDENTITY_LOAD_FAILED` -```bash -npx @worldcoin/agentkit-cli register -``` +The local key is inaccessible or invalid. Report the error. Do not replace, regenerate, expose, or repair the key unless the user explicitly asks; replacing it changes the agent identity. -This opens a World ID verification flow that ties your wallet to an anonymous human identifier on-chain. Registration only needs to happen once per wallet. +### `REGISTRATION_LOOKUP_FAILED` -### "Signature verification failed" +The CLI could not check AgentBook. Retry when connectivity is available. If authentication remains unavailable and the service requires payment, continue with the normal x402 payment flow. -- **Wrong signature type:** Make sure `type` matches your wallet. Use `eip191` for EOA, `eip1271` for SCW. -- **Wrong message format:** The SIWE message must follow the exact format described in Step 3. Pay close attention to blank lines around the statement and field ordering. -- **Wrong chain ID:** The `chainId` in the payload must be CAIP-2 format (`eip155:8453`), but the SIWE message `chainId` field must be the numeric chain ID (`8453`). +### `INVALID_AGENTKIT_PAYLOAD` -### "Invalid agentkit header: not valid base64" +Confirm that the complete top-level `{ "agentkit": ... }` wrapper was passed unchanged. The managed CLI identity is an EOA and requires at least one supported `eip155:*`/`eip191` entry; an `eip1271`-only challenge cannot be used by this CLI. -Your header is not properly base64-encoded. Ensure you are encoding the full JSON string: `btoa(JSON.stringify(payload))`. +Fetch a fresh 402 challenge if the nonce, timestamps, or payload may be stale. Do not attempt to recreate the SIWE message manually. -### "Message validation failed" / "issuedAt is too old" +### `SIGNING_FAILED` -The challenge has expired. Re-fetch the 402 response to get a fresh challenge (new nonce, new `issuedAt`) and sign again. Challenges expire after 5 minutes by default. +Report the signing failure and retry once with a fresh challenge. Never ask the user to paste the private key. -### "Unsupported chain namespace" +### Server rejects the authorization -You are using a chain that the server does not support. Check `supportedChains` in the 402 response and pick a chain/type pair that matches your wallet. +Fetch a new 402 response and retry once with its new AgentKit payload. Challenges are short-lived and must not be cached or reused. If the fresh proof is also rejected and the service still requires payment, continue with the normal x402 payment flow. From 82beecdc7608e0d739db7e727bc7f874ad9caa21 Mon Sep 17 00:00:00 2001 From: Miguel Piedrafita Date: Fri, 14 Aug 2026 11:39:53 -0700 Subject: [PATCH 2/2] more refactor --- README.md | 74 +--- bun.lock | 14 +- cli/README.md | 14 +- cli/REGISTRATION.md | 8 +- cli/src/index.ts | 41 +- cli/src/prove.ts | 143 +------ cli/test/prove.test.ts | 120 +----- core/package.json | 9 +- core/src/agent-book.ts | 56 +-- core/src/evm.ts | 49 --- core/src/index.ts | 42 +- core/src/parse.ts | 30 -- core/src/schema.ts | 27 -- core/src/solana.ts | 66 ---- core/src/types.ts | 96 ----- core/src/validate.ts | 79 ---- core/src/verify.ts | 154 ++------ core/src/viem-client.ts | 45 --- core/tests/agent-book.test.ts | 52 ++- core/tests/exports.test.ts | 9 + core/tests/solana.test.ts | 54 --- core/tests/validate.test.ts | 49 --- core/tests/verify.test.ts | 97 +++-- core/tests/viem-client.test.ts | 17 - core/tsconfig.json | 2 +- skills/agentkit-x402/SKILL.md | 116 +++--- skills/integrate-agentkit-x402/SKILL.md | 81 ++++ skills/integrate-agentkit/SKILL.md | 171 ++++---- x402/DOCS.md | 496 ++++++------------------ x402/package.json | 3 +- x402/src/client.ts | 102 ++--- x402/src/declare.ts | 47 +-- x402/src/hooks.ts | 100 +++-- x402/src/index.ts | 13 +- x402/src/protocol.ts | 52 +++ x402/src/server.ts | 69 +--- x402/src/storage.ts | 12 - x402/src/types.ts | 6 - x402/tests/client-e2e.test.ts | 89 ++--- x402/tests/client.test.ts | 155 ++++---- x402/tests/hooks.test.ts | 166 ++++---- 41 files changed, 942 insertions(+), 2083 deletions(-) delete mode 100644 core/src/evm.ts delete mode 100644 core/src/parse.ts delete mode 100644 core/src/schema.ts delete mode 100644 core/src/solana.ts delete mode 100644 core/src/types.ts delete mode 100644 core/src/validate.ts delete mode 100644 core/src/viem-client.ts create mode 100644 core/tests/exports.test.ts delete mode 100644 core/tests/solana.test.ts delete mode 100644 core/tests/validate.test.ts delete mode 100644 core/tests/viem-client.test.ts create mode 100644 skills/integrate-agentkit-x402/SKILL.md create mode 100644 x402/src/protocol.ts diff --git a/README.md b/README.md index 252e27e..0a4a99f 100644 --- a/README.md +++ b/README.md @@ -2,90 +2,52 @@ # **AgentKit** -**Verify that an agent is backed by a real, [World ID-verified human](https://docs.world.org/agents/agent-kit).** +**Let an agent show that a person with a verified [World ID](https://docs.world.org/agents/agent-kit) controls it.** -AgentKit Registration +AgentKit registration -## Skills -For your Agent to use your registration when accessing x402 endpoints -```bash -npx skills add worldcoin/agentkit agentkit-x402 -``` - -For developers building x402 servers, add the integration guide to your knowledge base: -```bash -npx skills add worldcoin/agentkit integrate-agentkit -``` - -## How it Works - -1. An agent wallet is registered in AgentBook using a World ID proof. -2. A website or API using x402 challenges the agent to sign a CAIP-122 message. -3. The server verifies the signature, resolves the registering human from AgentBook, and applies the configured access policy. - -This lets applications distinguish between arbitrary automation and automation acting on behalf of a real human, without exposing the human's underlying identity. - -## For Agents +AgentKit lets an agent show that a verified person controls it. A service can then give the agent free access, a trial, or a discount. AgentKit does not give the identity of the person to the service. -### Register +## Use AgentKit -Register your wallet in AgentBook so servers can verify you are human-backed. Registration is gasless by default (uses a hosted relay on Base mainnet). +Add the AgentKit x402 skill: ```bash -npx @worldcoin/agentkit-cli register +npx skills add worldcoin/agentkit agentkit-x402 ``` -This will prompt a World ID verification via World App. You only need to register once per wallet. +The skill gives instructions to the agent. The agent tries AgentKit before it makes an x402 payment. During the first registration, World App can ask you to complete a World ID check. -Check whether a wallet is already registered: +To start registration yourself, use this command: ```bash -npx @worldcoin/agentkit-cli status +npx @worldcoin/agentkit-cli register ``` -For the full registration guide (manual mode, custom relays, Base Sepolia): [`./cli/REGISTRATION.md`](./cli/REGISTRATION.md) +For more information, read the [registration guide](./cli/REGISTRATION.md). You can also read the [agent skill](./skills/agentkit-x402/SKILL.md). -### Use +## Build with AgentKit -Once registered, create an AgentKit client and use `agentkit.fetch` for x402 HTTP calls. It tries AgentKit verification before payment and only leaves the normal x402 payment flow in place when verification is unavailable, fails, or is exhausted. +Install the server package: ```bash npm install @worldcoin/agentkit ``` -```typescript -import { createAgentkitClient } from '@worldcoin/agentkit' - -const agentkit = createAgentkitClient({ - signer: { - address: agentWallet.address, - chainId: 'eip155:8453', - type: 'eip191', - signMessage: message => agentWallet.signMessage(message), - }, -}) +Use this package to add AgentKit to an x402 service. The service can give free access, trials, or discounts. -const response = await agentkit.fetch('https://api.example.com/data') -``` +Read the [x402 integration guide](./x402/DOCS.md) for setup instructions and examples. -If your agent cannot change its HTTP client code, install the agent skill as fallback guidance: +To help an agent protect a normal HTTP endpoint, add this skill: ```bash -npx skills add worldcoin/agentkit agentkit-x402 +npx skills add worldcoin/agentkit integrate-agentkit ``` -The full flow — parsing 402 responses, signing the CAIP-122 challenge, and sending the `agentkit` header — is documented in the agent skill: [`./skills/agentkit-x402/SKILL.md`](./skills/agentkit-x402/SKILL.md) - -## For x402 Developers - -### Integrate - -Add AgentKit to your x402 server to offer human-backed agents free access, free trials, or discounts. +To help an agent protect an x402 endpoint, add this skill: ```bash -npm install @worldcoin/agentkit +npx skills add worldcoin/agentkit integrate-agentkit-x402 ``` - -For the full integration guide (client wrapper, hooks setup, access modes, World Chain payments, AgentBook configuration): [`./x402/DOCS.md`](./x402/DOCS.md) diff --git a/bun.lock b/bun.lock index ec81a87..c4e0235 100644 --- a/bun.lock +++ b/bun.lock @@ -33,12 +33,9 @@ }, "core": { "name": "@worldcoin/agentkit-core", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { - "@noble/curves": "^1.9.1", - "@scure/base": "^1.2.6", "viem": "^2.46.2", - "zod": "^3.24.2", }, "devDependencies": { "tsup": "^8.5.1", @@ -47,10 +44,11 @@ }, "x402": { "name": "@worldcoin/agentkit", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@worldcoin/agentkit-core": "^0.1.8", "@x402/core": "^2.4.0", + "viem": "^2.46.2", }, "devDependencies": { "@types/node": "^25.5.0", @@ -616,7 +614,7 @@ "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], - "ox": ["ox@0.1.8", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-GJl6uKXxhPq/XgyvAnIokGuGU/pt9CU8reRJjzi4a02HOpLc2CEXXD4bRCITFFAzdRqHj3DQ6GDS7PlCytPM/A=="], + "ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@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", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="], "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], @@ -826,6 +824,8 @@ "@readme/openapi-parser/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@worldcoin/idkit-core/ox": ["ox@0.1.8", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-GJl6uKXxhPq/XgyvAnIokGuGU/pt9CU8reRJjzi4a02HOpLc2CEXXD4bRCITFFAzdRqHj3DQ6GDS7PlCytPM/A=="], + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -840,8 +840,6 @@ "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "viem/ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@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", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="], - "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/cli/README.md b/cli/README.md index 92aa775..4624430 100644 --- a/cli/README.md +++ b/cli/README.md @@ -26,14 +26,18 @@ Keep this file private and back it up. It is the durable identity for this agent Full registration guide: [REGISTRATION.md](./REGISTRATION.md) -## Prove this agent is registered +## Sign a request as this agent -Pass the `agentkit` extension from an x402 response as JSON: +Pass the exact UTF-8 request body to `prove`: ```bash -agentkit prove '{"agentkit":{"info":{"domain":"api.example.com","uri":"https://api.example.com/data","version":"1","nonce":"abc123","issuedAt":"2025-01-01T00:00:00.000Z","statement":"Verify your agent is backed by a real human"},"supportedChains":[{"chainId":"eip155:8453","type":"eip191"}],"schema":{}}}' +agentkit prove '{"query":"weather","city":"Lisbon"}' ``` -The command requires the local key created by `agentkit register` and confirms that its derived address is registered before signing. It selects the first supported EVM `eip191` chain, constructs the documented SIWE message, and returns a `signature` field containing the complete base64 value for the `agentkit` HTTP header. +For a request with no body, pass an empty string: -The encoded value contains the public challenge response and EIP-191 signature. The private key remains in the local key file. +```bash +agentkit prove '' +``` + +The command requires the key created by `agentkit register` and confirms that its address is registered before signing. It returns a `signature` field containing the hexadecimal value for the `X-AgentKit` request header. The retried request body must exactly match the body passed to `prove`. diff --git a/cli/REGISTRATION.md b/cli/REGISTRATION.md index c042cb8..2ad9dc1 100644 --- a/cli/REGISTRATION.md +++ b/cli/REGISTRATION.md @@ -54,12 +54,12 @@ Run `agentkit register` again and complete the World App step within five minute Check the network connection and retry. The command checks registration before starting a new verification, so it is safe to rerun after an uncertain response. -## Prove registration to an x402 service +## Sign an x402 request body -After registration, pass an x402 AgentKit extension to `prove` as JSON: +After registration, pass the exact UTF-8 request body to `prove`: ```bash -agentkit prove '' +agentkit prove '' ``` -The command does not create a missing key and will not sign for an unregistered identity. On success, its `signature` result is the base64-encoded AgentKit authorization value to send in the `agentkit` HTTP header. +Use `agentkit prove ''` for a request with no body. The command does not create a missing key and will not sign for an unregistered identity. On success, send its hexadecimal `signature` result in the `X-AgentKit` header and retry with the exact same body. diff --git a/cli/src/index.ts b/cli/src/index.ts index 97b754b..753a890 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,20 +1,15 @@ #!/usr/bin/env node import './polyfill.js' import { Cli, z } from 'incur' -import { createPublicClient, http, decodeAbiParameters } from 'viem' import type { Hex } from 'viem' +import qrcode from 'qrcode-terminal' import { worldchain } from 'viem/chains' -import { createWorldBridgeStore } from '@worldcoin/idkit-core' import type { ISuccessResult } from '@worldcoin/idkit-core' +import { createWorldBridgeStore } from '@worldcoin/idkit-core' import { solidityEncode } from '@worldcoin/idkit-core/hashing' -import qrcode from 'qrcode-terminal' +import { createPublicClient, http, decodeAbiParameters } from 'viem' +import { requestBodyInputSchema, signRequestBody } from './prove.js' import { loadAgentSigner, loadOrCreateAgentIdentity } from './key.js' -import { - AgentkitPayloadError, - agentkitExtensionInputSchema, - createAgentkitProof, - parseAgentkitExtension, -} from './prove.js' // ─── Config ────────────────────────────────────────────────────────────────── @@ -52,7 +47,10 @@ const cli = Cli.create('agentkit', { cli.command('status', { description: 'Check whether an agent wallet is registered in AgentBook.', args: z.object({ - address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address').describe('Agent wallet address'), + address: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address') + .describe('Agent wallet address'), }), outputPolicy: 'agent-only', output: z.object({ @@ -255,24 +253,14 @@ cli.command('register', { }) cli.command('prove', { - description: 'Sign an x402 AgentKit challenge with this registered agent.', + description: 'Sign a request body with this registered agent.', args: z.object({ - payload: agentkitExtensionInputSchema, + body: requestBodyInputSchema, }), output: z.object({ - signature: z.string().describe('Base64-encoded AgentKit authorization value'), + signature: z.string().describe('Hexadecimal X-AgentKit signature'), }), async run(c) { - let extension - try { - extension = parseAgentkitExtension(c.args.payload) - } catch (err) { - return c.error({ - code: 'INVALID_AGENTKIT_PAYLOAD', - message: err instanceof Error ? err.message : 'Invalid AgentKit extension payload', - }) - } - let signer try { signer = await loadAgentSigner() @@ -313,12 +301,11 @@ cli.command('prove', { } try { - const proof = await createAgentkitProof(extension, signer) - return { signature: proof.encoded } + return { signature: await signRequestBody(c.args.body, signer) } } catch (err) { return c.error({ - code: err instanceof AgentkitPayloadError ? 'INVALID_AGENTKIT_PAYLOAD' : 'SIGNING_FAILED', - message: err instanceof Error ? err.message : 'Unable to sign the AgentKit challenge', + code: 'SIGNING_FAILED', + message: err instanceof Error ? err.message : 'Unable to sign the request body', }) } }, diff --git a/cli/src/prove.ts b/cli/src/prove.ts index fa9edd3..0279ed8 100644 --- a/cli/src/prove.ts +++ b/cli/src/prove.ts @@ -1,148 +1,11 @@ import { z } from 'incur' -const singleLine = z - .string() - .min(1) - .refine(value => !/[\r\n]/.test(value), 'Must not contain line breaks') - -const agentkitInfoSchema = z - .object({ - domain: singleLine, - uri: singleLine, - version: singleLine, - nonce: singleLine, - issuedAt: singleLine, - statement: singleLine.optional(), - expirationTime: singleLine.optional(), - notBefore: singleLine.optional(), - requestId: singleLine.optional(), - resources: z.array(singleLine).min(1).optional(), - }) - .passthrough() - -const supportedChainSchema = z - .object({ - chainId: singleLine, - type: singleLine, - }) - .passthrough() - -export const agentkitExtensionSchema = z - .object({ - agentkit: z - .object({ - info: agentkitInfoSchema, - supportedChains: z.array(supportedChainSchema).min(1), - schema: z.unknown().optional(), - }) - .passthrough(), - }) - .passthrough() - -export const agentkitExtensionInputSchema = z - .union([z.string(), agentkitExtensionSchema]) - .describe('AgentKit extension payload as JSON or a structured object') - -export type AgentkitExtension = z.infer -export type AgentkitInfo = z.infer +export const requestBodyInputSchema = z.string().describe('Exact UTF-8 request body to sign') export type MessageSigner = { - address: `0x${string}` signMessage: (message: string) => Promise<`0x${string}`> } -export type AgentkitProof = { - encoded: string - message: string - authorization: AgentkitInfo & { - address: `0x${string}` - chainId: string - type: 'eip191' - signature: `0x${string}` - } -} - -export class AgentkitPayloadError extends Error { - constructor(message: string) { - super(message) - this.name = 'AgentkitPayloadError' - } -} - -export function parseAgentkitExtension(input: unknown): AgentkitExtension { - let payload = input - if (typeof input === 'string') { - try { - payload = JSON.parse(input) - } catch { - throw new AgentkitPayloadError('AgentKit extension payload is not valid JSON') - } - } - - const parsed = agentkitExtensionSchema.safeParse(payload) - if (!parsed.success) { - const details = parsed.error.issues - .map(issue => `${issue.path.join('.') || 'payload'}: ${issue.message}`) - .join('; ') - throw new AgentkitPayloadError(`Invalid AgentKit extension payload: ${details}`) - } - - return parsed.data -} - -export function selectEip191Chain( - supportedChains: AgentkitExtension['agentkit']['supportedChains'] -): { chainId: string; numericChainId: string } { - for (const supportedChain of supportedChains) { - if (supportedChain.type !== 'eip191') continue - const match = /^eip155:(0|[1-9]\d*)$/.exec(supportedChain.chainId) - if (match) return { chainId: supportedChain.chainId, numericChainId: match[1]! } - } - - throw new AgentkitPayloadError('The AgentKit extension does not support an EVM EIP-191 signer') -} - -export function createSiweMessage( - info: AgentkitInfo, - address: `0x${string}`, - numericChainId: string -): string { - const lines = [`${info.domain} wants you to sign in with your Ethereum account:`, address, ''] - - if (info.statement !== undefined) lines.push(info.statement, '') - - lines.push( - `URI: ${info.uri}`, - `Version: ${info.version}`, - `Chain ID: ${numericChainId}`, - `Nonce: ${info.nonce}`, - `Issued At: ${info.issuedAt}` - ) - - if (info.expirationTime !== undefined) lines.push(`Expiration Time: ${info.expirationTime}`) - if (info.notBefore !== undefined) lines.push(`Not Before: ${info.notBefore}`) - if (info.requestId !== undefined) lines.push(`Request ID: ${info.requestId}`) - if (info.resources !== undefined) { - lines.push('Resources:') - for (const resource of info.resources) lines.push(`- ${resource}`) - } - - return lines.join('\n') -} - -export async function createAgentkitProof(input: unknown, signer: MessageSigner): Promise { - const extension = parseAgentkitExtension(input) - const { chainId, numericChainId } = selectEip191Chain(extension.agentkit.supportedChains) - const message = createSiweMessage(extension.agentkit.info, signer.address, numericChainId) - const signature = await signer.signMessage(message) - const authorization = { - ...extension.agentkit.info, - address: signer.address, - chainId, - type: 'eip191' as const, - signature, - } - const encoded = Buffer.from(JSON.stringify(authorization), 'utf8').toString('base64') - - return { encoded, message, authorization } +export function signRequestBody(body: string, signer: MessageSigner): Promise<`0x${string}`> { + return signer.signMessage(body) } diff --git a/cli/test/prove.test.ts b/cli/test/prove.test.ts index effe4d0..38a1001 100644 --- a/cli/test/prove.test.ts +++ b/cli/test/prove.test.ts @@ -1,114 +1,26 @@ -import { describe, expect, test } from 'bun:test' import { verifyMessage } from 'viem' +import { describe, expect, test } from 'bun:test' +import { signRequestBody } from '../src/prove.js' import { privateKeyToAccount } from 'viem/accounts' -import { - AgentkitPayloadError, - createAgentkitProof, - createSiweMessage, - parseAgentkitExtension, - selectEip191Chain, -} from '../src/prove.js' - -const payload = { - agentkit: { - info: { - domain: 'api.example.com', - uri: 'https://api.example.com/data', - version: '1', - nonce: 'abc123', - issuedAt: '2025-01-01T00:00:00.000Z', - statement: 'Verify your agent is backed by a real human', - }, - supportedChains: [ - { chainId: 'eip155:8453', type: 'eip1271' }, - { chainId: 'eip155:8453', type: 'eip191' }, - ], - schema: { type: 'object' }, - }, -} - -describe('AgentKit extension parsing', () => { - test('accepts structured and JSON payloads', () => { - expect(parseAgentkitExtension(payload)).toEqual(payload) - expect(parseAgentkitExtension(JSON.stringify(payload))).toEqual(payload) - }) - test('rejects invalid JSON and line-break injection', () => { - expect(() => parseAgentkitExtension('{')).toThrow(AgentkitPayloadError) - expect(() => - parseAgentkitExtension({ - ...payload, - agentkit: { ...payload.agentkit, info: { ...payload.agentkit.info, nonce: 'abc\nURI: injected' } }, - }) - ).toThrow('Must not contain line breaks') - }) -}) - -describe('SIWE construction', () => { - test('selects the first EVM EIP-191 chain', () => { - expect(selectEip191Chain(parseAgentkitExtension(payload).agentkit.supportedChains)).toEqual({ - chainId: 'eip155:8453', - numericChainId: '8453', +describe('request body proof', () => { + test('returns a raw EIP-191 signature over the exact body', async () => { + const account = privateKeyToAccount(`0x${'01'.padStart(64, '0')}`) + const body = '{"hello":"world","unicode":"你好"}' + const signature = await signRequestBody(body, { + signMessage: message => account.signMessage({ message }), }) - }) - test('rejects payloads that do not support this EOA signer', () => { - expect(() => selectEip191Chain([{ chainId: 'eip155:8453', type: 'eip1271' }])).toThrow( - 'The AgentKit extension does not support an EVM EIP-191 signer' - ) + expect(signature).toMatch(/^0x[0-9a-f]{130}$/) + expect(await verifyMessage({ address: account.address, message: body, signature })).toBe(true) }) - test('formats required and optional fields in the documented order', () => { - const info = { - ...payload.agentkit.info, - expirationTime: '2025-01-01T00:05:00.000Z', - notBefore: '2025-01-01T00:00:01.000Z', - requestId: 'req-456', - resources: ['https://api.example.com/tos'], - } - const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' - - expect(createSiweMessage(info, address, '8453')).toBe( - 'api.example.com wants you to sign in with your Ethereum account:\n' + - '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\n' + - '\n' + - 'Verify your agent is backed by a real human\n' + - '\n' + - 'URI: https://api.example.com/data\n' + - 'Version: 1\n' + - 'Chain ID: 8453\n' + - 'Nonce: abc123\n' + - 'Issued At: 2025-01-01T00:00:00.000Z\n' + - 'Expiration Time: 2025-01-01T00:05:00.000Z\n' + - 'Not Before: 2025-01-01T00:00:01.000Z\n' + - 'Request ID: req-456\n' + - 'Resources:\n' + - '- https://api.example.com/tos' - ) - }) - - test('uses one blank line between the address and URI when there is no statement', () => { - const { statement: _, ...info } = payload.agentkit.info - const message = createSiweMessage(info, '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', '8453') - - expect(message).toContain('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\n\nURI:') - expect(message.endsWith('\n')).toBe(false) - }) -}) + test('supports the empty body used by GET requests', async () => { + const account = privateKeyToAccount(`0x${'02'.padStart(64, '0')}`) + const signature = await signRequestBody('', { + signMessage: message => account.signMessage({ message }), + }) -test('creates a base64 AgentKit authorization with a valid EIP-191 signature', async () => { - const account = privateKeyToAccount(`0x${'01'.padStart(64, '0')}`) - const proof = await createAgentkitProof(payload, { - address: account.address, - signMessage: message => account.signMessage({ message }), + expect(await verifyMessage({ address: account.address, message: '', signature })).toBe(true) }) - const decoded = JSON.parse(Buffer.from(proof.encoded, 'base64').toString('utf8')) - - expect(decoded).toEqual(proof.authorization) - expect(decoded.address).toBe(account.address) - expect(decoded.chainId).toBe('eip155:8453') - expect(decoded.type).toBe('eip191') - expect( - await verifyMessage({ address: account.address, message: proof.message, signature: decoded.signature }) - ).toBe(true) }) diff --git a/core/package.json b/core/package.json index 0be56fc..221824a 100644 --- a/core/package.json +++ b/core/package.json @@ -22,9 +22,7 @@ } } }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, @@ -32,10 +30,7 @@ "build": "tsup" }, "dependencies": { - "@noble/curves": "^1.9.1", - "@scure/base": "^1.2.6", - "viem": "^2.46.2", - "zod": "^3.24.2" + "viem": "^2.46.2" }, "devDependencies": { "tsup": "^8.5.1", diff --git a/core/src/agent-book.ts b/core/src/agent-book.ts index bf1d101..5ff1a6d 100644 --- a/core/src/agent-book.ts +++ b/core/src/agent-book.ts @@ -1,6 +1,5 @@ -import { toHex, type PublicClient } from 'viem' +import { createPublicClient, http, toHex, type PublicClient } from 'viem' import { worldchain } from 'viem/chains' -import { getPublicClient } from './viem-client' const AGENT_BOOK_ADDRESS: `0x${string}` = '0xA23aB2712eA7BBa896930544C7d6636a96b944dA' @@ -14,42 +13,25 @@ const AGENT_BOOK_ABI = [ }, ] as const -export interface AgentBookOptions { - /** Custom viem PublicClient. Advanced override for testing or custom deployments. */ +interface AgentBookLookupOptions { client?: PublicClient - /** Custom AgentBook contract address on World Chain. Defaults to the canonical deployment. */ - contractAddress?: `0x${string}` - /** Custom World Chain RPC URL. Defaults to the chain's default public RPC. */ - rpcUrl?: string + createClient?: (chainId: number) => PublicClient } -export function createAgentBookVerifier(options: AgentBookOptions = {}) { - return { - /** - * Look up the anonymous human identifier for an agent's wallet address. - * Always resolves against the AgentBook deployment on World Chain, regardless - * of which chain the agent's signature was produced on. - */ - async lookupHuman(address: string): Promise { - const contractAddress = options.contractAddress ?? AGENT_BOOK_ADDRESS - const client = options.client ?? getPublicClient(worldchain.id, options.rpcUrl) - - try { - const humanId = await client.readContract({ - address: contractAddress, - abi: AGENT_BOOK_ABI, - functionName: 'lookupHuman', - args: [address as `0x${string}`], - }) - - if (humanId === 0n) return null - - return toHex(humanId) - } catch { - return null - } - }, - } +export async function lookupNullifierHash( + address: string, + options: AgentBookLookupOptions = {} +): Promise { + const client = + options.client ?? + options.createClient?.(worldchain.id) ?? + createPublicClient({ chain: worldchain, transport: http() }) + const nullifierHash = await client.readContract({ + address: AGENT_BOOK_ADDRESS, + abi: AGENT_BOOK_ABI, + functionName: 'lookupHuman', + args: [address as `0x${string}`], + }) + + return nullifierHash === 0n ? null : toHex(nullifierHash) } - -export type AgentBookVerifier = ReturnType diff --git a/core/src/evm.ts b/core/src/evm.ts deleted file mode 100644 index c6f4b8c..0000000 --- a/core/src/evm.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createSiweMessage } from 'viem/siwe' -import { getPublicClient } from './viem-client' -import type { CompleteAgentkitInfo } from './types' - -export function extractEVMChainId(chainId: string): number { - const match = /^eip155:(\d+)$/.exec(chainId) - if (!match) { - throw new Error(`Invalid EVM chainId format: ${chainId}. Expected eip155:`) - } - return parseInt(match[1], 10) -} - -export function formatSIWEMessage(info: CompleteAgentkitInfo, address: string): string { - return createSiweMessage({ - domain: info.domain, - address: address as `0x${string}`, - statement: info.statement, - uri: info.uri, - version: info.version as '1', - chainId: extractEVMChainId(info.chainId), - nonce: info.nonce, - issuedAt: new Date(info.issuedAt), - expirationTime: info.expirationTime ? new Date(info.expirationTime) : undefined, - notBefore: info.notBefore ? new Date(info.notBefore) : undefined, - requestId: info.requestId, - resources: info.resources, - }) -} - -/** - * Verify an EVM signature using ERC-1271 (smart wallets) with ecrecover fallback (EOA). - * Uses viem's publicClient.verifyMessage which handles both automatically. - */ -export async function verifyEVMSignature( - message: string, - address: string, - signature: string, - chainId: string, - rpcUrl?: string -): Promise { - const numericChainId = extractEVMChainId(chainId) - const client = getPublicClient(numericChainId, rpcUrl) - - return client.verifyMessage({ - address: address as `0x${string}`, - message, - signature: signature as `0x${string}`, - }) -} diff --git a/core/src/index.ts b/core/src/index.ts index bab93f9..3076f0e 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,41 +1 @@ -// Constants -export { AGENTKIT, AgentkitPayloadSchema } from './types' -export { SOLANA_MAINNET, SOLANA_DEVNET, SOLANA_TESTNET } from './solana' - -// Types -export type { - AgentkitExtension, - AgentkitExtensionInfo, - AgentkitExtensionSchema, - AgentkitPayload, - CompleteAgentkitInfo, - SignatureScheme, - SignatureType, - AgentkitValidationResult, - AgentkitValidationOptions, - AgentkitVerifyResult, - SupportedChain, -} from './types' - -// Verification -export { parseAgentkitHeader } from './parse' -export { validateAgentkitMessage } from './validate' -export { resolveAgentkitSignatureRpcUrl, verifyAgentkitSignature } from './verify' -export type { AgentkitSignatureVerificationConfig, AgentkitSignatureVerificationOptions } from './verify' -export { buildAgentkitSchema } from './schema' - -// Chain utilities - EVM -export { formatSIWEMessage, verifyEVMSignature, extractEVMChainId } from './evm' -export { getDefaultPublicRpcUrl } from './viem-client' - -// Chain utilities - Solana -export { - formatSIWSMessage, - verifySolanaSignature, - decodeBase58, - encodeBase58, - extractSolanaChainReference, -} from './solana' - -// AgentBook -export { createAgentBookVerifier, type AgentBookVerifier, type AgentBookOptions } from './agent-book' +export { verify } from './verify' diff --git a/core/src/parse.ts b/core/src/parse.ts deleted file mode 100644 index 8a9aac0..0000000 --- a/core/src/parse.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { base64 } from '@scure/base' -import { AgentkitPayloadSchema, type AgentkitPayload } from './types' - -export function parseAgentkitHeader(header: string): AgentkitPayload { - let jsonStr: string - try { - jsonStr = new TextDecoder().decode(base64.decode(header)) - } catch { - throw new Error('Invalid agentkit header: not valid base64') - } - - let rawPayload: unknown - try { - rawPayload = JSON.parse(jsonStr) - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error('Invalid agentkit header: not valid JSON') - } - throw error - } - - const parsed = AgentkitPayloadSchema.safeParse(rawPayload) - - if (!parsed.success) { - const issues = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ') - throw new Error(`Invalid agentkit header: ${issues}`) - } - - return parsed.data -} diff --git a/core/src/schema.ts b/core/src/schema.ts deleted file mode 100644 index bcec865..0000000 --- a/core/src/schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AgentkitExtensionSchema } from './types' - -const AGENTKIT_SCHEMA: AgentkitExtensionSchema = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'object', - properties: { - domain: { type: 'string' }, - address: { type: 'string' }, - statement: { type: 'string' }, - uri: { type: 'string', format: 'uri' }, - version: { type: 'string' }, - chainId: { type: 'string' }, - type: { type: 'string' }, - nonce: { type: 'string' }, - issuedAt: { type: 'string', format: 'date-time' }, - expirationTime: { type: 'string', format: 'date-time' }, - notBefore: { type: 'string', format: 'date-time' }, - requestId: { type: 'string' }, - resources: { type: 'array', items: { type: 'string', format: 'uri' } }, - signature: { type: 'string' }, - }, - required: ['domain', 'address', 'uri', 'version', 'chainId', 'type', 'nonce', 'issuedAt', 'signature'], -} - -export function buildAgentkitSchema(): AgentkitExtensionSchema { - return AGENTKIT_SCHEMA -} diff --git a/core/src/solana.ts b/core/src/solana.ts deleted file mode 100644 index c29b991..0000000 --- a/core/src/solana.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ed25519 } from '@noble/curves/ed25519' -import { base58 } from '@scure/base' -import type { CompleteAgentkitInfo } from './types' - -export const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' -export const SOLANA_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1' -export const SOLANA_TESTNET = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z' - -export function extractSolanaChainReference(chainId: string): string { - const [, reference] = chainId.split(':') - return reference -} - -export function formatSIWSMessage(info: CompleteAgentkitInfo, address: string): string { - const lines: string[] = [`${info.domain} wants you to sign in with your Solana account:`, address, ''] - - if (info.statement) { - lines.push(info.statement, '') - } - - lines.push( - `URI: ${info.uri}`, - `Version: ${info.version}`, - `Chain ID: ${extractSolanaChainReference(info.chainId)}`, - `Nonce: ${info.nonce}`, - `Issued At: ${info.issuedAt}` - ) - - if (info.expirationTime) { - lines.push(`Expiration Time: ${info.expirationTime}`) - } - if (info.notBefore) { - lines.push(`Not Before: ${info.notBefore}`) - } - if (info.requestId) { - lines.push(`Request ID: ${info.requestId}`) - } - - if (info.resources && info.resources.length > 0) { - lines.push('Resources:') - for (const resource of info.resources) { - lines.push(`- ${resource}`) - } - } - - return lines.join('\n') -} - -export function verifySolanaSignature(message: string, signature: Uint8Array, publicKey: Uint8Array): boolean { - const messageBytes = new TextEncoder().encode(message) - try { - return ed25519.verify(signature, messageBytes, publicKey, { zip215: false }) - } catch { - // @noble/curves throws on malformed inputs (wrong length, non-canonical points); - // tweetnacl returned false. Preserve the boolean contract for callers. - return false - } -} - -export function decodeBase58(encoded: string): Uint8Array { - return base58.decode(encoded) -} - -export function encodeBase58(bytes: Uint8Array): string { - return base58.encode(bytes) -} diff --git a/core/src/types.ts b/core/src/types.ts deleted file mode 100644 index 1c83e6d..0000000 --- a/core/src/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { z } from 'zod' - -export const AGENTKIT = 'agentkit' - -export type SignatureScheme = 'eip191' | 'eip1271' | 'eip6492' | 'siws' - -export type SignatureType = 'eip191' | 'eip1271' | 'ed25519' - -export interface SupportedChain { - chainId: string - type: SignatureType - signatureScheme?: SignatureScheme -} - -export interface AgentkitExtensionInfo { - domain: string - uri: string - statement?: string - version: string - nonce: string - issuedAt: string - expirationTime?: string - notBefore?: string - requestId?: string - resources?: string[] -} - -export interface AgentkitExtensionSchema { - $schema: string - type: 'object' - properties: { - domain: { type: 'string' } - address: { type: 'string' } - statement?: { type: 'string' } - uri: { type: 'string'; format: 'uri' } - version: { type: 'string' } - chainId: { type: 'string' } - type: { type: 'string' } - nonce: { type: 'string' } - issuedAt: { type: 'string'; format: 'date-time' } - expirationTime?: { type: 'string'; format: 'date-time' } - notBefore?: { type: 'string'; format: 'date-time' } - requestId?: { type: 'string' } - resources?: { type: 'array'; items: { type: 'string'; format: 'uri' } } - signature: { type: 'string' } - } - required: string[] -} - -export interface AgentkitExtension { - info: AgentkitExtensionInfo - supportedChains: SupportedChain[] - schema: AgentkitExtensionSchema -} - -export const AgentkitPayloadSchema = z.object({ - domain: z.string(), - address: z.string(), - statement: z.string().optional(), - uri: z.string(), - version: z.string(), - chainId: z.string(), - type: z.enum(['eip191', 'eip1271', 'ed25519']), - nonce: z.string(), - issuedAt: z.string(), - expirationTime: z.string().optional(), - notBefore: z.string().optional(), - requestId: z.string().optional(), - resources: z.array(z.string()).optional(), - signatureScheme: z.enum(['eip191', 'eip1271', 'eip6492', 'siws']).optional(), - signature: z.string(), -}) - -export type AgentkitPayload = z.infer - -export interface AgentkitValidationResult { - valid: boolean - error?: string -} - -export interface AgentkitValidationOptions { - maxAge?: number - checkNonce?: (nonce: string) => boolean | Promise -} - -export interface AgentkitVerifyResult { - valid: boolean - address?: string - error?: string -} - -export type CompleteAgentkitInfo = AgentkitExtensionInfo & { - chainId: string - type: SignatureType - signatureScheme?: SignatureScheme -} diff --git a/core/src/validate.ts b/core/src/validate.ts deleted file mode 100644 index adedbbb..0000000 --- a/core/src/validate.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { AgentkitPayload, AgentkitValidationResult, AgentkitValidationOptions } from './types' - -const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000 - -export async function validateAgentkitMessage( - message: AgentkitPayload, - expectedResourceUri: string, - options: AgentkitValidationOptions = {} -): Promise { - const expectedUrl = new URL(expectedResourceUri) - const maxAge = options.maxAge ?? DEFAULT_MAX_AGE_MS - - if (message.domain !== expectedUrl.hostname) { - return { - valid: false, - error: `Domain mismatch: expected "${expectedUrl.hostname}", got "${message.domain}"`, - } - } - - let messageHost: string - try { - const messageUrl = new URL(message.uri) - messageHost = messageUrl.host - } catch { - return { valid: false, error: `Invalid URI: "${message.uri}"` } - } - - if (messageHost !== expectedUrl.host) { - return { - valid: false, - error: `URI mismatch: expected host "${expectedUrl.host}", got "${messageHost}"`, - } - } - - const issuedAt = new Date(message.issuedAt) - if (isNaN(issuedAt.getTime())) { - return { valid: false, error: 'Invalid issuedAt timestamp' } - } - - const age = Date.now() - issuedAt.getTime() - if (age > maxAge) { - return { - valid: false, - error: `Message too old: ${Math.round(age / 1000)}s exceeds ${maxAge / 1000}s limit`, - } - } - if (age < 0) { - return { valid: false, error: 'issuedAt is in the future' } - } - - if (message.expirationTime) { - const expiration = new Date(message.expirationTime) - if (isNaN(expiration.getTime())) { - return { valid: false, error: 'Invalid expirationTime timestamp' } - } - if (expiration < new Date()) { - return { valid: false, error: 'Message expired' } - } - } - - if (message.notBefore) { - const notBefore = new Date(message.notBefore) - if (isNaN(notBefore.getTime())) { - return { valid: false, error: 'Invalid notBefore timestamp' } - } - if (new Date() < notBefore) { - return { valid: false, error: 'Message not yet valid (notBefore is in the future)' } - } - } - - if (options.checkNonce) { - const nonceValid = await options.checkNonce(message.nonce) - if (!nonceValid) { - return { valid: false, error: 'Nonce validation failed (possible replay attack)' } - } - } - - return { valid: true } -} diff --git a/core/src/verify.ts b/core/src/verify.ts index 9dc29ca..82184ed 100644 --- a/core/src/verify.ts +++ b/core/src/verify.ts @@ -1,137 +1,53 @@ -import { formatSIWEMessage, verifyEVMSignature } from './evm' -import { formatSIWSMessage, verifySolanaSignature, decodeBase58 } from './solana' -import type { AgentkitPayload, AgentkitVerifyResult } from './types' +import { isHex, recoverMessageAddress, type Hex } from 'viem' +import { lookupNullifierHash } from './agent-book' -export interface AgentkitSignatureVerificationOptions { - /** Fallback custom RPC URL for EVM signature verification. */ - rpcUrl?: string - /** Custom RPC URLs keyed by CAIP-2 chain ID, e.g. { 'eip155:8453': 'https://base.example' }. */ - rpcUrls?: Record -} - -export type AgentkitSignatureVerificationConfig = string | AgentkitSignatureVerificationOptions +const AGENTKIT_HEADER = 'X-AgentKit' -export function resolveAgentkitSignatureRpcUrl( - chainId: string, - options?: AgentkitSignatureVerificationConfig -): string | undefined { - if (typeof options === 'string') return options - return options?.rpcUrls?.[chainId] ?? options?.rpcUrl +type VerifyRequestDependencies = { + recoverAddress?: (body: Uint8Array, signature: Hex) => Promise + lookupNullifierHash?: (address: string) => Promise } -export async function verifyAgentkitSignature( - payload: AgentkitPayload, - options?: AgentkitSignatureVerificationConfig -): Promise { - try { - if (payload.chainId.startsWith('eip155:')) { - return verifyEVMPayload(payload, options) - } - - if (payload.chainId.startsWith('solana:')) { - return verifySolanaPayload(payload) - } - - return { - valid: false, - error: `Unsupported chain namespace: ${payload.chainId}. Supported: eip155:* (EVM), solana:* (Solana)`, - } - } catch (error) { - return { - valid: false, - error: error instanceof Error ? error.message : 'Verification failed', - } - } +export async function verify(request: Request): Promise { + return verifyRequest(request) } -async function verifyEVMPayload( - payload: AgentkitPayload, - options?: AgentkitSignatureVerificationConfig -): Promise { - const message = formatSIWEMessage( - { - domain: payload.domain, - uri: payload.uri, - statement: payload.statement, - version: payload.version, - chainId: payload.chainId, - type: payload.type, - nonce: payload.nonce, - issuedAt: payload.issuedAt, - expirationTime: payload.expirationTime, - notBefore: payload.notBefore, - requestId: payload.requestId, - resources: payload.resources, - }, - payload.address - ) +export async function verifyRequest(request: Request, dependencies: VerifyRequestDependencies = {}): Promise { + const signature = request.headers.get(AGENTKIT_HEADER)?.trim() + if (!signature) { + throw verificationError('Missing X-AgentKit header', 'MISSING_HEADER') + } + if (!isHex(signature) || !/^0x[0-9a-fA-F]{130}$/.test(signature)) { + throw verificationError('Invalid X-AgentKit signature', 'INVALID_SIGNATURE') + } + let body: Uint8Array try { - const rpcUrl = resolveAgentkitSignatureRpcUrl(payload.chainId, options) - const valid = await verifyEVMSignature(message, payload.address, payload.signature, payload.chainId, rpcUrl) - - if (!valid) { - return { - valid: false, - error: `Signature verification failed. The signature does not match the reconstructed SIWE message. Ensure your agent signs exactly this message using EIP-191 (EOA) or ERC-1271 (smart wallet):\n\n${message}`, - } - } - - return { valid: true, address: payload.address } - } catch (error) { - const reason = error instanceof Error ? error.message : 'Unknown error' - return { - valid: false, - error: `Signature verification error: ${reason}. The SIWE message the server reconstructed from your payload:\n\n${message}`, - } + body = new Uint8Array(await request.clone().arrayBuffer()) + } catch { + throw verificationError('Unable to read request body', 'INVALID_REQUEST_BODY') } -} -function verifySolanaPayload(payload: AgentkitPayload): AgentkitVerifyResult { - const message = formatSIWSMessage( - { - domain: payload.domain, - uri: payload.uri, - statement: payload.statement, - version: payload.version, - chainId: payload.chainId, - type: payload.type, - nonce: payload.nonce, - issuedAt: payload.issuedAt, - expirationTime: payload.expirationTime, - notBefore: payload.notBefore, - requestId: payload.requestId, - resources: payload.resources, - }, - payload.address - ) - - let signature: Uint8Array - let publicKey: Uint8Array + const recoverAddress = + dependencies.recoverAddress ?? + ((message: Uint8Array, value: Hex) => recoverMessageAddress({ message: { raw: message }, signature: value })) + let address: string try { - signature = decodeBase58(payload.signature) - publicKey = decodeBase58(payload.address) - } catch (error) { - return { - valid: false, - error: `Invalid Base58 encoding: ${error instanceof Error ? error.message : 'decode failed'}`, - } - } - - if (signature.length !== 64) { - return { valid: false, error: `Invalid signature length: expected 64 bytes, got ${signature.length}` } + address = await recoverAddress(body, signature) + } catch { + throw verificationError('Invalid X-AgentKit signature', 'INVALID_SIGNATURE') } - if (publicKey.length !== 32) { - return { valid: false, error: `Invalid public key length: expected 32 bytes, got ${publicKey.length}` } + const lookup = dependencies.lookupNullifierHash ?? (signer => lookupNullifierHash(signer)) + const nullifierHash = await lookup(address) + if (!nullifierHash) { + throw verificationError('Agent is not registered in AgentBook', 'AGENT_NOT_REGISTERED', address) } - const valid = verifySolanaSignature(message, signature, publicKey) - - if (!valid) { - return { valid: false, error: 'Solana signature verification failed' } - } + return nullifierHash +} - return { valid: true, address: payload.address } +export function verificationError(message: string, code: string, address?: string): Error { + return Object.assign(new Error(message), { code, ...(address ? { address } : {}) }) } diff --git a/core/src/viem-client.ts b/core/src/viem-client.ts deleted file mode 100644 index 0a1374e..0000000 --- a/core/src/viem-client.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as chains from 'viem/chains' -import { createPublicClient, extractChain, http, type PublicClient } from 'viem' - -const allChains = Object.values(chains) -const clientCache = new Map() - -// Shared Alchemy free-tier API key. This is intentionally NOT a secret: the -// endpoints below are rate-limited and treated as public RPC. Callers running -// production traffic should pass their own RPC URL via getPublicClient's rpcUrl. -const ALCHEMY_FREE_TIER_KEY = 'k0eQqlkOQBUAUuM8qcfGh' - -// Arc mainnet (chain id 5042) is not live yet. Its public RPC is wired up ahead -// of launch so relying parties don't need a config update when it ships. viem -// has no chain definition for it yet, hence the hardcoded id. -const ARC_MAINNET_ID = 5042 - -const defaultPublicRpcUrls = new Map([ - [chains.worldchain.id, chains.worldchain.rpcUrls.default.http[0]], - [chains.base.id, chains.base.rpcUrls.default.http[0]], - [chains.tempo.id, `https://tempo-mainnet.g.alchemy.com/v2/${ALCHEMY_FREE_TIER_KEY}`], - [chains.arcTestnet.id, `https://arc-testnet.g.alchemy.com/v2/${ALCHEMY_FREE_TIER_KEY}`], - [ARC_MAINNET_ID, 'http://rpc.arc.io/'], -]) - -export function getDefaultPublicRpcUrl(numericChainId: number): string | undefined { - return defaultPublicRpcUrls.get(numericChainId) -} - -export function getPublicClient(numericChainId: number, rpcUrl?: string): PublicClient { - const effectiveRpcUrl = rpcUrl ?? getDefaultPublicRpcUrl(numericChainId) - const cacheKey = `${numericChainId}:${effectiveRpcUrl ?? ''}` - let cached = clientCache.get(cacheKey) - if (cached) return cached - - let chain: chains.Chain - if (effectiveRpcUrl) { - chain = { id: numericChainId } as chains.Chain - } else { - chain = extractChain({ chains: allChains, id: numericChainId as (typeof allChains)[number]['id'] }) - } - - cached = createPublicClient({ chain, transport: http(effectiveRpcUrl) }) as PublicClient - clientCache.set(cacheKey, cached) - return cached -} diff --git a/core/tests/agent-book.test.ts b/core/tests/agent-book.test.ts index fe263fb..15795b6 100644 --- a/core/tests/agent-book.test.ts +++ b/core/tests/agent-book.test.ts @@ -1,49 +1,45 @@ -import type { PublicClient } from 'viem' import { describe, expect, it } from 'bun:test' -import { createAgentBookVerifier } from '../src/agent-book' +import type { PublicClient } from 'viem' +import { lookupNullifierHash } from '../src/agent-book' -function createMockClient(result: bigint, calls: Array<{ address: string }>): PublicClient { +function createMockClient(result: bigint | Error, calls: Array<{ address: string }>): PublicClient { return { readContract: async (args: { address: string }) => { calls.push({ address: args.address }) + if (result instanceof Error) throw result return result }, } as unknown as PublicClient } -describe('createAgentBookVerifier', () => { - it('looks up addresses against the World Chain AgentBook', async () => { +describe('lookupNullifierHash', () => { + it('uses the canonical AgentBook deployment and returns the nullifier hash', async () => { const calls: Array<{ address: string }> = [] - const verifier = createAgentBookVerifier({ - client: createMockClient(1n, calls), + const chainIds: number[] = [] + const nullifierHash = await lookupNullifierHash('0x1234567890abcdef1234567890abcdef12345678', { + createClient(chainId) { + chainIds.push(chainId) + return createMockClient(1n, calls) + }, }) - const humanId = await verifier.lookupHuman('0x1234567890abcdef1234567890abcdef12345678') - - expect(humanId).toBe('0x1') + expect(nullifierHash).toBe('0x1') + expect(chainIds).toEqual([480]) expect(calls).toEqual([{ address: '0xA23aB2712eA7BBa896930544C7d6636a96b944dA' }]) }) - it('returns null for unregistered addresses', async () => { - const calls: Array<{ address: string }> = [] - const verifier = createAgentBookVerifier({ - client: createMockClient(0n, calls), + it('returns null for an unregistered address', async () => { + const nullifierHash = await lookupNullifierHash('0x1234567890abcdef1234567890abcdef12345678', { + client: createMockClient(0n, []), }) - - const humanId = await verifier.lookupHuman('0x1234567890abcdef1234567890abcdef12345678') - - expect(humanId).toBeNull() + expect(nullifierHash).toBeNull() }) - it('honors custom contract deployments', async () => { - const calls: Array<{ address: string }> = [] - const verifier = createAgentBookVerifier({ - client: createMockClient(1n, calls), - contractAddress: '0x9999999999999999999999999999999999999999', - }) - - await verifier.lookupHuman('0x1234567890abcdef1234567890abcdef12345678') - - expect(calls).toEqual([{ address: '0x9999999999999999999999999999999999999999' }]) + it('propagates World Chain RPC failures', async () => { + await expect( + lookupNullifierHash('0x1234567890abcdef1234567890abcdef12345678', { + client: createMockClient(new Error('RPC failed'), []), + }) + ).rejects.toThrow('RPC failed') }) }) diff --git a/core/tests/exports.test.ts b/core/tests/exports.test.ts new file mode 100644 index 0000000..0878989 --- /dev/null +++ b/core/tests/exports.test.ts @@ -0,0 +1,9 @@ +import * as core from '../src' +import { describe, expect, it } from 'bun:test' + +describe('@worldcoin/agentkit-core exports', () => { + it('exports only the request verifier', () => { + expect(Object.keys(core)).toEqual(['verify']) + expect(typeof core.verify).toBe('function') + }) +}) diff --git a/core/tests/solana.test.ts b/core/tests/solana.test.ts deleted file mode 100644 index 665d8fb..0000000 --- a/core/tests/solana.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { verifySolanaSignature } from '../src/solana' - -function fromHex(hex: string): Uint8Array { - const out = new Uint8Array(hex.length / 2) - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) - } - return out -} - -// Fixture generated with tweetnacl@1.0.3 from a deterministic seed. -// Guards against regressions if the underlying ed25519 implementation changes. -const FIXTURE = { - message: - 'example.com wants you to sign in with your Solana account:\nABC\n\nURI: https://example.com\nVersion: 1\nChain ID: mainnet\nNonce: abcd1234\nIssued At: 2026-05-13T00:00:00.000Z', - publicKey: fromHex('6b80f36fa38d2942de85ff15bff2c62704c9fc9a4c1174a2dd5b8e1cd91f4326'), - signature: fromHex( - '3cbd22d2a13d291ce5a52c379f9fc2f6de624fccdaf6de7e3b0d4a7168b06c111f6c6d82e81590c13503c374c966c448c8ef374613c822ba983f61d9dc463a01' - ), -} - -describe('verifySolanaSignature', () => { - it('accepts a valid signature produced by tweetnacl', () => { - expect(verifySolanaSignature(FIXTURE.message, FIXTURE.signature, FIXTURE.publicKey)).toBe(true) - }) - - it('rejects a tampered signature', () => { - const tampered = new Uint8Array(FIXTURE.signature) - tampered[0] ^= 0x01 - expect(verifySolanaSignature(FIXTURE.message, tampered, FIXTURE.publicKey)).toBe(false) - }) - - it('rejects a tampered message', () => { - expect(verifySolanaSignature(FIXTURE.message + ' ', FIXTURE.signature, FIXTURE.publicKey)).toBe(false) - }) - - it('rejects a signature checked against the wrong public key', () => { - const wrongKey = new Uint8Array(FIXTURE.publicKey) - wrongKey[0] ^= 0x01 - expect(verifySolanaSignature(FIXTURE.message, FIXTURE.signature, wrongKey)).toBe(false) - }) - - it('returns false for a malformed signature instead of throwing', () => { - const wrongLength = new Uint8Array(10) - expect(verifySolanaSignature(FIXTURE.message, wrongLength, FIXTURE.publicKey)).toBe(false) - - const allZero = new Uint8Array(64) - expect(verifySolanaSignature(FIXTURE.message, allZero, FIXTURE.publicKey)).toBe(false) - - const allOnes = new Uint8Array(64).fill(0xff) - expect(verifySolanaSignature(FIXTURE.message, allOnes, FIXTURE.publicKey)).toBe(false) - }) -}) diff --git a/core/tests/validate.test.ts b/core/tests/validate.test.ts deleted file mode 100644 index 1587978..0000000 --- a/core/tests/validate.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import type { AgentkitPayload } from '../src/types' -import { validateAgentkitMessage } from '../src/validate' - -function buildPayload(overrides: Partial = {}): AgentkitPayload { - return { - domain: 'x402-worldchain.vercel.app', - address: '0x1234567890abcdef1234567890abcdef12345678', - uri: 'https://x402-worldchain.vercel.app/generate', - version: '1', - chainId: 'eip155:480', - type: 'eip191', - nonce: 'nonce', - issuedAt: new Date().toISOString(), - signature: '0xsignature', - ...overrides, - } -} - -describe('validateAgentkitMessage', () => { - it('accepts https agentkit URIs when the server sees an internal http URL behind a proxy', async () => { - const result = await validateAgentkitMessage( - buildPayload(), - 'http://x402-worldchain.vercel.app/generate' - ) - - expect(result).toEqual({ valid: true }) - }) - - it('rejects mismatched hosts', async () => { - const result = await validateAgentkitMessage( - buildPayload({ uri: 'https://evil.example/generate' }), - 'http://x402-worldchain.vercel.app/generate' - ) - - expect(result.valid).toBe(false) - expect(result.error).toBe('URI mismatch: expected host "x402-worldchain.vercel.app", got "evil.example"') - }) - - it('rejects mismatched ports', async () => { - const result = await validateAgentkitMessage( - buildPayload({ uri: 'https://x402-worldchain.vercel.app:444/generate' }), - 'http://x402-worldchain.vercel.app/generate' - ) - - expect(result.valid).toBe(false) - expect(result.error).toBe('URI mismatch: expected host "x402-worldchain.vercel.app", got "x402-worldchain.vercel.app:444"') - }) -}) diff --git a/core/tests/verify.test.ts b/core/tests/verify.test.ts index 82db202..a1d9e70 100644 --- a/core/tests/verify.test.ts +++ b/core/tests/verify.test.ts @@ -1,34 +1,85 @@ import { describe, expect, it } from 'bun:test' -import { resolveAgentkitSignatureRpcUrl } from '../src/verify' +import { isAddressEqual } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { verifyRequest } from '../src/verify' -describe('resolveAgentkitSignatureRpcUrl', () => { - it('keeps the legacy single rpcUrl form as a fallback', () => { - expect(resolveAgentkitSignatureRpcUrl('eip155:8453', 'https://fallback.example')).toBe('https://fallback.example') - expect(resolveAgentkitSignatureRpcUrl('eip155:8453', { rpcUrl: 'https://fallback.example' })).toBe( - 'https://fallback.example' - ) +const encoder = new TextEncoder() + +async function signedRequest(body: string) { + const account = privateKeyToAccount(generatePrivateKey()) + const signature = await account.signMessage({ message: { raw: encoder.encode(body) } }) + const request = new Request('https://api.example.com/data', { + method: 'POST', + headers: { 'X-AgentKit': signature }, + body, }) + return { account, request, signature } +} - it('selects the custom RPC URL from the signed payload chain ID', () => { - expect( - resolveAgentkitSignatureRpcUrl('eip155:8453', { - rpcUrl: 'https://world-chain.example', - rpcUrls: { - 'eip155:480': 'https://world-chain.example', - 'eip155:8453': 'https://base.example', - }, +describe('verify', () => { + it('recovers the signer from the exact request body and returns its nullifier hash', async () => { + const body = JSON.stringify({ hello: 'world' }) + const { account, request } = await signedRequest(body) + const addresses: string[] = [] + + const nullifierHash = await verifyRequest(request, { + async lookupNullifierHash(address) { + addresses.push(address) + return isAddressEqual(address as `0x${string}`, account.address) ? '0x1234' : null + }, + }) + + expect(nullifierHash).toBe('0x1234') + expect(addresses).toHaveLength(1) + expect(isAddressEqual(addresses[0] as `0x${string}`, account.address)).toBe(true) + expect(await request.text()).toBe(body) + }) + + it('throws when the X-AgentKit header is missing', async () => { + const request = new Request('https://api.example.com/data', { method: 'POST', body: 'hello' }) + await expect(verifyRequest(request)).rejects.toThrow('Missing X-AgentKit header') + }) + + it('throws when the header is not a valid signature', async () => { + const request = new Request('https://api.example.com/data', { + method: 'POST', + headers: { 'X-AgentKit': 'not-a-signature' }, + body: 'hello', + }) + await expect(verifyRequest(request)).rejects.toThrow('Invalid X-AgentKit signature') + }) + + it('rejects a signature copied onto a different body', async () => { + const { account, signature } = await signedRequest('original') + const request = new Request('https://api.example.com/data', { + method: 'POST', + headers: { 'X-AgentKit': signature }, + body: 'tampered', + }) + + await expect( + verifyRequest(request, { + lookupNullifierHash: async address => + isAddressEqual(address as `0x${string}`, account.address) ? '0x1234' : null, }) - ).toBe('https://base.example') + ).rejects.toThrow('Agent is not registered in AgentBook') + }) + + it('throws when the recovered signer is not registered', async () => { + const { request } = await signedRequest('hello') + await expect(verifyRequest(request, { lookupNullifierHash: async () => null })).rejects.toThrow( + 'Agent is not registered in AgentBook' + ) }) - it('falls back when no per-chain RPC URL is configured for the signed chain', () => { - expect( - resolveAgentkitSignatureRpcUrl('eip155:10', { - rpcUrl: 'https://fallback.example', - rpcUrls: { - 'eip155:480': 'https://world-chain.example', + it('propagates AgentBook RPC failures', async () => { + const { request } = await signedRequest('hello') + await expect( + verifyRequest(request, { + lookupNullifierHash: async () => { + throw new Error('World Chain unavailable') }, }) - ).toBe('https://fallback.example') + ).rejects.toThrow('World Chain unavailable') }) }) diff --git a/core/tests/viem-client.test.ts b/core/tests/viem-client.test.ts deleted file mode 100644 index 27ec5f7..0000000 --- a/core/tests/viem-client.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { getDefaultPublicRpcUrl } from '../src/viem-client' - -describe('getDefaultPublicRpcUrl', () => { - it('provides built-in public RPCs for common SCA verification chains', () => { - expect(getDefaultPublicRpcUrl(480)).toBe('https://worldchain-mainnet.g.alchemy.com/public') - expect(getDefaultPublicRpcUrl(4217)).toBe('https://tempo-mainnet.g.alchemy.com/v2/k0eQqlkOQBUAUuM8qcfGh') - expect(getDefaultPublicRpcUrl(8453)).toBe('https://mainnet.base.org') - expect(getDefaultPublicRpcUrl(5_042_002)).toBe('https://arc-testnet.g.alchemy.com/v2/k0eQqlkOQBUAUuM8qcfGh') - expect(getDefaultPublicRpcUrl(5042)).toBe('http://rpc.arc.io/') - }) - - it('leaves unsupported chains override-only', () => { - expect(getDefaultPublicRpcUrl(1)).toBeUndefined() - expect(getDefaultPublicRpcUrl(42_431)).toBeUndefined() - }) -}) diff --git a/core/tsconfig.json b/core/tsconfig.json index 03acab7..5ef1673 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ES2020", - "lib": ["ES2020"], + "lib": ["ES2020", "DOM"], "moduleResolution": "bundler", "declaration": true, "declarationMap": true, diff --git a/skills/agentkit-x402/SKILL.md b/skills/agentkit-x402/SKILL.md index a7fd1d1..f1d2c06 100644 --- a/skills/agentkit-x402/SKILL.md +++ b/skills/agentkit-x402/SKILL.md @@ -1,103 +1,93 @@ --- name: agentkit-x402 -description: Use when an HTTP 402 Payment Required response contains an `agentkit` extension. Use the AgentKit CLI to prove the local agent is registered, attach the returned base64 authorization as the `agentkit` header, interpret free, free-trial, and discount modes, and fall back to x402 payment only when AgentKit access is unavailable. +description: Use when an HTTP 402 Payment Required response contains an `agentkit` extension. Sign the request body with the AgentKit CLI, retry with the hexadecimal signature in `X-AgentKit`, interpret free, free-trial, and discount modes, and fall back to x402 payment only when AgentKit access is unavailable. --- # Authenticate to x402 with AgentKit ## Always try AgentKit before paying -When an HTTP request returns `402 Payment Required`, inspect the response for an `agentkit` extension before authorizing payment. Attempt AgentKit authentication whenever the extension is present. +When an HTTP request returns `402 Payment Required`, inspect the response's `extensions` object. Attempt AgentKit authentication whenever `extensions.agentkit` is present. Fall back to the normal x402 payment flow only when: - the response has no `agentkit` extension; -- `agentkit prove` cannot authenticate this agent; or -- the service rejects the proof or reports that free-trial access is exhausted. +- `agentkit prove` cannot authenticate this agent; +- the service rejects the signature; or +- free-trial or discount access is exhausted. -Do not construct or sign SIWE messages manually. Do not read or request a private key. The AgentKit CLI owns identity loading, registration checks, challenge formatting, chain selection, signing, and authorization encoding. +The lowercase `agentkit` name is only the x402 extension key. Never send it as the authentication header. The request header is `X-AgentKit`. + +Do not read or request a private key. Do not construct signatures manually. The AgentKit CLI loads the managed identity, confirms that it is registered, and signs the body. ## Authenticate the request -### 1. Extract the complete extension - -Pass a JSON object with the top-level `agentkit` key to the CLI. If the 402 body nests the extension under an `extensions` object, wrap the extension value as `{ "agentkit": extensionValue }`. - -Example payload: - -```json -{ - "agentkit": { - "info": { - "domain": "api.example.com", - "uri": "https://api.example.com/data", - "version": "1", - "nonce": "abc123", - "issuedAt": "2025-01-01T00:00:00.000Z", - "statement": "Verify your agent is backed by a real human" - }, - "supportedChains": [ - { "chainId": "eip155:8453", "type": "eip191" }, - { "chainId": "eip155:8453", "type": "eip1271" } - ], - "schema": {} - } -} +### 1. Prepare the body that will be retried + +The signature covers the request body, so the body passed to `agentkit prove` and the body sent on the retry must be identical UTF-8 text. + +- For a request with no body, use the empty string. +- For JSON, parse it and serialize it once as compact JSON. Use that exact compact JSON for both signing and the retry. Do not pretty-print or reorder it afterward. +- The portable x402 hooks flow supports bodyless and JSON requests. Use plain text only when the service explicitly documents exact-body AgentKit support outside the standard x402 hooks. +- Do not use the CLI flow for arbitrary text, binary, multipart, or form-encoded bodies when the service's body handling is unknown. Use an AgentKit-aware framework integration or continue with the normal x402 payment flow. + +Example compact JSON body: + +```text +{"query":"weather","city":"Lisbon"} ``` -Preserve every `info` field exactly. Do not change the nonce, timestamps, URI, statement, resources, or supported chains. +### 2. Ask the CLI to sign that exact body -Before signing, confirm that `info.domain` and `info.uri` describe the service and request you intended to access. Treat a mismatch as an invalid or suspicious challenge. +Pass the body as the single argument: -### 2. Ask the CLI to prove the agent +```bash +agentkit prove '' +``` -Pass the serialized JSON payload as one argument: +For a bodyless request: ```bash -agentkit prove '' +agentkit prove '' ``` -If the CLI is not installed globally, use: +If the CLI is not installed globally: ```bash -npx @worldcoin/agentkit-cli prove '' +npx @worldcoin/agentkit-cli prove '' ``` The command: -- loads the existing identity from the AgentKit XDG key file without creating a key; -- verifies that the derived address is registered in AgentBook; -- selects the first supported `eip155:*` chain with type `eip191`; -- constructs and signs the required SIWE message; and -- returns a `signature` field containing the complete base64 AgentKit authorization value. - -Use the returned `signature` value directly as the HTTP header value. It is already an encoded JSON authorization containing the challenge information, public address, CAIP-2 chain ID, `eip191` type, and EIP-191 signature. +- loads the existing AgentKit identity without creating a key; +- checks that its address is registered in AgentBook; and +- returns a `signature` field containing a hexadecimal EIP-191 signature. -Do not decode, edit, or re-encode it. Do not confuse it with the inner hexadecimal EIP-191 signature. +Use the returned `signature` directly. It is already the complete `X-AgentKit` header value. Do not encode, decode, wrap, or edit it. ### 3. Retry the original request -Repeat the original request with the same method, URI, body, and non-AgentKit headers, adding: +Repeat the request with the same method, URL, prepared body, and other headers, adding: ```text -agentkit: +X-AgentKit: ``` -Send the authorization only to the original challenge URI and domain. Treat it as an ephemeral credential: do not persist it, print it unnecessarily, or reuse it for another request or challenge. +The retried body must be byte-for-byte identical to the body given to `prove`. In particular, if JSON was compacted before signing, send that compact form on the retry. -If the service grants access, return the resource without paying. If it responds with another 402, interpret the access mode and error before deciding whether to pay. +If the service grants access, return the resource without paying. If it responds with another 402, interpret the access mode before deciding whether to pay. ## Handle access modes -Read the extension's `mode` when present: +Read `extensions.agentkit.mode` when present: -| Mode | Behavior | -|---|---| -| `free` | Retry with only the `agentkit` header. Human-backed agents receive free access. | -| `free-trial` | Retry with only the `agentkit` header until the service reports that the per-human trial is exhausted. Then use the normal payment flow. | -| `discount` | Send both the `agentkit` header and the x402 payment header, paying the discounted amount specified by the service. | +| Mode | Behavior | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `free` | Retry with `X-AgentKit` and no payment. | +| `free-trial` | Retry with `X-AgentKit` and no payment until the service reports that the per-human allowance is exhausted. Then use the normal payment flow. | +| `discount` | Keep `X-AgentKit` on the request and use the normal x402 payment flow with the discounted amount advertised by the service. | -If no mode is present, try the `agentkit` header without payment first. +If no mode is present, try `X-AgentKit` without payment first. ## Recover from errors @@ -109,11 +99,11 @@ No local AgentKit identity exists. Run: agentkit register ``` -Registration creates the local identity, checks AgentBook, and starts World ID verification only when needed. Human action may be required. After registration completes, fetch a new 402 challenge and run `prove` again. +Registration creates the local identity, checks AgentBook, and starts World ID verification only when needed. Human action may be required. After registration completes, retry `prove` with the same prepared request body. ### `AGENT_NOT_REGISTERED` -The existing identity has not been registered. Run `agentkit register`, complete World ID verification, then fetch a fresh challenge and retry. +The existing identity has not been registered. Run `agentkit register`, complete World ID verification, then run `prove` again. ### `IDENTITY_LOAD_FAILED` @@ -123,16 +113,10 @@ The local key is inaccessible or invalid. Report the error. Do not replace, rege The CLI could not check AgentBook. Retry when connectivity is available. If authentication remains unavailable and the service requires payment, continue with the normal x402 payment flow. -### `INVALID_AGENTKIT_PAYLOAD` - -Confirm that the complete top-level `{ "agentkit": ... }` wrapper was passed unchanged. The managed CLI identity is an EOA and requires at least one supported `eip155:*`/`eip191` entry; an `eip1271`-only challenge cannot be used by this CLI. - -Fetch a fresh 402 challenge if the nonce, timestamps, or payload may be stale. Do not attempt to recreate the SIWE message manually. - ### `SIGNING_FAILED` -Report the signing failure and retry once with a fresh challenge. Never ask the user to paste the private key. +Report the signing failure and retry once. Never ask the user to paste the private key. -### Server rejects the authorization +### Server rejects the signature -Fetch a new 402 response and retry once with its new AgentKit payload. Challenges are short-lived and must not be cached or reused. If the fresh proof is also rejected and the service still requires payment, continue with the normal x402 payment flow. +First verify that the retry used `X-AgentKit`, not `agentkit`, and that its body exactly matches the body passed to `prove`. Recreate the signature after any body change. If a second correctly signed retry is rejected and the service still requires payment, continue with the normal x402 payment flow. diff --git a/skills/integrate-agentkit-x402/SKILL.md b/skills/integrate-agentkit-x402/SKILL.md new file mode 100644 index 0000000..4608261 --- /dev/null +++ b/skills/integrate-agentkit-x402/SKILL.md @@ -0,0 +1,81 @@ +--- +name: integrate-agentkit-x402 +description: "Use this skill when integrating @worldcoin/agentkit into an x402 server or facilitator flow: choose free/free-trial/discount mode, wire payments on any supported EVM chain, handle ExactEvmScheme money parsing, or finish an integration end-to-end." +--- + +# Integrate AgentKit with x402 + +Use this skill for end-to-end server-side integration work with `@worldcoin/agentkit`. + +## Start by clarifying the integration + +If the developer has not already answered these, ask before choosing an implementation: + +1. Which access mode do they want: `free`, `free-trial`, or `discount`? +2. Which payment network should the protected route use? +3. Do they control the facilitator path, or are they using a hosted facilitator? +4. Do they also need agent registration, or only request-time verification? + +## Default recommendation + +For most production integrations: + +- Put paid routes on whichever network the developer's facilitator already supports (commonly World Chain `eip155:480` or Base `eip155:8453`). +- Leave AgentBook lookup alone — `createAgentkitHooks()` resolves against the canonical World Chain deployment automatically. The developer does not need to create a verifier or think about which chain the registry lives on. +- Start with `free-trial` unless the developer explicitly wants `free` or `discount`. +- Only choose `discount` when you can wire `hooks.verifyFailureHook` into the facilitator flow you control. + +## Key pieces + +- x402 resource server: the protected HTTP route and 402 retry flow +- facilitator: verifies and settles payment payloads; required for `discount` +- AgentKit extension: advertises AgentKit support and verifies the request body's `X-AgentKit` signature +- AgentBook: on-chain registry on World Chain that maps the agent wallet to an anonymous human ID. Lookup is always against World Chain regardless of the payment chain — the caller side is chain-agnostic. +- storage: per-human usage tracking for `free-trial` and `discount` +- registration path: separate from request-time verification; use `npx @worldcoin/agentkit-cli --llms` if the developer also needs registration help + +## Workflow + +1. Read [`../../x402/DOCS.md`](../../x402/DOCS.md) first. It should be the primary integration playbook. +2. Confirm exported APIs in [`../../core/src/index.ts`](../../core/src/index.ts) and [`../../x402/src/index.ts`](../../x402/src/index.ts) before adding imports. +3. Prefer the hooks-based path: + - `declareAgentkitExtension` + - `agentkitResourceServerExtension` + - `createAgentkitHooks` +4. If the payment network is World Chain (`eip155:480`), add a custom `ExactEvmScheme().registerMoneyParser(...)` for World Chain USDC. Do not assume the server scheme has a working default stablecoin for World Chain. +5. Do not construct or inject an AgentBook verifier, chain selector, RPC selector, or contract override. The hooks call Core's fixed World Chain verifier. +6. If the mode is `free-trial` or `discount`, add persistent `AgentKitStorage`. `InMemoryAgentKitStorage` is only for demos. +7. If the mode is `discount`, wire `hooks.verifyFailureHook` into the facilitator. Without it, discounted underpayments will fail verification. +8. Verify the whole path end-to-end: + - 402 response includes the `agentkit` extension + - registered agent gets the intended behavior + - unregistered agent falls back to normal payment + - the client signs the normalized body and retries with `X-AgentKit` + - usage storage behaves as expected + +## Ground rules + +- Prefer the hooks-based integration unless the user explicitly needs the low-level flow. +- Use the portable hooks path for bodyless and JSON requests. For other bodies, verify the original Web `Request` in framework-level middleware before x402 parses it. +- Use [`../../x402/DOCS.md`](../../x402/DOCS.md) as the primary reference for examples and mode behavior. +- Do not introduce any "pin AgentBook to chain X" language — lookup is always against the canonical World Chain deployment. The payment chain and the AgentBook lookup chain are decoupled on purpose, and callers should never have to think about the lookup chain. +- Do not add an `agentBook`, RPC, chain, or contract override. The hooks own the canonical World Chain lookup. +- Do not document World Chain with bare `new ExactEvmScheme()` only. Include the World Chain money parser. +- Do not choose `discount` unless the facilitator hook can actually be registered. +- Confirm exports in [`../../core/src/index.ts`](../../core/src/index.ts) and [`../../x402/src/index.ts`](../../x402/src/index.ts) before adding or documenting imports. + +## Constants to keep handy + +- World Chain payment network: `eip155:480` +- World Chain AgentBook contract: `0xA23aB2712eA7BBa896930544C7d6636a96b944dA` +- World Chain USDC: `0x79A02482A880bCE3F13e09Da970dC34db4CD24d1` + +## Reference files + +- Integration docs: [`../../x402/DOCS.md`](../../x402/DOCS.md) +- Core public exports: [`../../core/src/index.ts`](../../core/src/index.ts) +- x402 public exports: [`../../x402/src/index.ts`](../../x402/src/index.ts) +- Hooks: [`../../x402/src/hooks.ts`](../../x402/src/hooks.ts) +- AgentBook lookup: [`../../core/src/agent-book.ts`](../../core/src/agent-book.ts) +- Request verification: [`../../core/src/verify.ts`](../../core/src/verify.ts) +- x402 body normalization and header constants: [`../../x402/src/protocol.ts`](../../x402/src/protocol.ts) diff --git a/skills/integrate-agentkit/SKILL.md b/skills/integrate-agentkit/SKILL.md index b76e842..e6e62a9 100644 --- a/skills/integrate-agentkit/SKILL.md +++ b/skills/integrate-agentkit/SKILL.md @@ -1,78 +1,105 @@ --- name: integrate-agentkit -description: Use this skill when integrating @worldcoin/agentkit into an x402 server or facilitator flow: choose free/free-trial/discount mode, wire payments on any supported EVM chain, handle ExactEvmScheme money parsing, or finish an integration end-to-end. +description: Protect one HTTP endpoint with @worldcoin/agentkit-core. Use this skill when an application must validate X-AgentKit body signatures, identify a registered human, or add AgentKit authentication to an API route. --- # Integrate AgentKit -Use this skill for end-to-end server-side integration work with `@worldcoin/agentkit`. - -## Start by clarifying the integration - -If the developer has not already answered these, ask before choosing an implementation: - -1. Which access mode do they want: `free`, `free-trial`, or `discount`? -2. Which payment network should the protected route use? -3. Do they control the facilitator path, or are they using a hosted facilitator? -4. Do they also need agent registration, or only request-time verification? - -## Default recommendation - -For most production integrations: - -- Put paid routes on whichever network the developer's facilitator already supports (commonly World Chain `eip155:480` or Base `eip155:8453`). -- Leave AgentBook lookup alone — `createAgentBookVerifier()` always resolves against the canonical World Chain deployment. The developer does not need to think about which chain the registry lives on. -- Start with `free-trial` unless the developer explicitly wants `free` or `discount`. -- Only choose `discount` when you can wire `hooks.verifyFailureHook` into the facilitator flow you control. - -## Key pieces - -- x402 resource server: the protected HTTP route and 402 challenge flow -- facilitator: verifies and settles payment payloads; required for `discount` -- AgentKit extension: adds the CAIP-122 challenge and verifies the signed `agentkit` header -- AgentBook: on-chain registry on World Chain that maps the agent wallet to an anonymous human ID. Lookup is always against World Chain regardless of the payment chain — the caller side is chain-agnostic. -- storage: per-human usage tracking for `free-trial` and `discount` -- registration path: separate from request-time verification; use `npx @worldcoin/agentkit-cli --llms` if the developer also needs registration help - -## Workflow - -1. Read [`../../x402/DOCS.md`](../../x402/DOCS.md) first. It should be the primary integration playbook. -2. Confirm exported APIs in [`../../core/src/index.ts`](../../core/src/index.ts) and [`../../x402/src/index.ts`](../../x402/src/index.ts) before adding imports. -3. Prefer the hooks-based path: - - `declareAgentkitExtension` - - `agentkitResourceServerExtension` - - `createAgentkitHooks` - - `createAgentBookVerifier` -4. If the payment network is World Chain (`eip155:480`), add a custom `ExactEvmScheme().registerMoneyParser(...)` for World Chain USDC. Do not assume the server scheme has a working default stablecoin for World Chain. -5. Call `createAgentBookVerifier()` with no arguments in the common case. Pass `rpcUrl` or `contractAddress` only for custom World Chain endpoints or non-canonical deployments. -6. If the mode is `free-trial` or `discount`, add persistent `AgentKitStorage`. `InMemoryAgentKitStorage` is only for demos. -7. If the mode is `discount`, wire `hooks.verifyFailureHook` into the facilitator. Without it, discounted underpayments will fail verification. -8. Verify the whole path end-to-end: - - 402 response includes the `agentkit` extension - - registered agent gets the intended behavior - - unregistered agent falls back to normal payment - - replay protection and storage behavior work as expected - -## Ground rules - -- Prefer the hooks-based integration unless the user explicitly needs the low-level flow. -- Use [`../../x402/DOCS.md`](../../x402/DOCS.md) as the primary reference for examples and mode behavior. -- Do not introduce any "pin AgentBook to chain X" language — lookup is always against the canonical World Chain deployment. The payment chain and the AgentBook lookup chain are decoupled on purpose, and callers should never have to think about the lookup chain. -- Do not document World Chain with bare `new ExactEvmScheme()` only. Include the World Chain money parser. -- Do not choose `discount` unless the facilitator hook can actually be registered. -- Confirm exports in [`../../core/src/index.ts`](../../core/src/index.ts) and [`../../x402/src/index.ts`](../../x402/src/index.ts) before adding or documenting imports. - -## Constants to keep handy - -- World Chain payment network: `eip155:480` -- World Chain AgentBook contract: `0xA23aB2712eA7BBa896930544C7d6636a96b944dA` -- World Chain USDC: `0x79A02482A880bCE3F13e09Da970dC34db4CD24d1` - -## Reference files - -- Integration docs: [`../../x402/DOCS.md`](../../x402/DOCS.md) -- Core public exports: [`../../core/src/index.ts`](../../core/src/index.ts) -- x402 public exports: [`../../x402/src/index.ts`](../../x402/src/index.ts) -- Hooks: [`../../x402/src/hooks.ts`](../../x402/src/hooks.ts) -- AgentBook verifier: [`../../core/src/agent-book.ts`](../../core/src/agent-book.ts) -- Header parsing and verification: [`../../core/src/parse.ts`](../../core/src/parse.ts), [`../../core/src/validate.ts`](../../core/src/validate.ts), [`../../core/src/verify.ts`](../../core/src/verify.ts) +Protect one HTTP endpoint with AgentKit. Use `@worldcoin/agentkit-core`. + +## Select the endpoint + +1. Use the endpoint that the user specifies. +2. If the user does not specify an endpoint, inspect the application routes. +3. Select one endpoint that handles the protected action or data. +4. Tell the user which endpoint you selected. + +Do not protect all endpoints unless the user requests this change. + +Prefer an endpoint that receives a Web `Request`. AgentKit must read the original body bytes. + +## Add verification + +Install the Core package if the application does not have it: + +```bash +npm install @worldcoin/agentkit-core +``` + +Import `verify` from the package. Call `verify(request)` before you read or change the request body. + +```typescript +import { verify } from '@worldcoin/agentkit-core' + +const agentkitError = { + error: 'AGENTKIT_VALIDATION_FAILED', + message: 'AgentKit validation failed. Read your AgentKit skill for instructions. Then retry this request.', +} + +export async function POST(request: Request) { + let humanId: string + + try { + humanId = await verify(request) + } catch { + return Response.json(agentkitError, { status: 401 }) + } + + const body = await request.json() + return createReport(body, { humanId }) +} +``` + +Replace `createReport` with the current endpoint logic. + +Use the returned nullifier hash as an internal human ID when the access policy needs it. Do not return the nullifier unless the API contract requires it. Do not expose the signer address or private key. + +`verify` clones the request. The endpoint can read the body after successful verification. + +## Return one safe error + +Return status `401` when AgentKit validation fails. Return this message: + +```text +AgentKit validation failed. Read your AgentKit skill for instructions. Then retry this request. +``` + +Use a stable machine code such as `AGENTKIT_VALIDATION_FAILED`. + +Do not return the internal verification error. The internal error can contain an address or a network detail. If the application has a server logger, record the internal error there. + +## Preserve the signed body + +Pass the original request to `verify`. Do not parse, format, or rebuild the body first. + +If the framework does not use Web `Request`, capture the exact body before a body parser changes it. Build one Web `Request` with those exact bytes and the original `X-AgentKit` header. + +Do not use a parsed JSON object as a replacement for the original body bytes. + +## Keep the change local + +- Change only the selected endpoint and its direct tests. +- Keep the endpoint's current success response unless the user requests a change. +- Keep all other authentication checks. +- Decide if AgentKit replaces or supplements the existing authentication. +- State that decision in the handoff. + +## Verify the result + +Test these cases: + +1. A request without `X-AgentKit` returns status `401` and the required message. +2. A malformed signature returns the same safe error. +3. An unregistered signer returns the same safe error. +4. A registered signer can use the endpoint. +5. A changed body invalidates the signature. +6. The endpoint can read the body after `verify` succeeds. +7. An endpoint outside the selected route stays unchanged. + +Run the normal formatter, type checker, and relevant tests for the application. + +## Confirm the package contract + +Before implementation, confirm that [`../../core/src/index.ts`](../../core/src/index.ts) exports `verify`. Read [`../../core/src/verify.ts`](../../core/src/verify.ts) if the framework needs an adapter. + +Do not add a chain option, contract option, or AgentBook client. Core always checks the canonical AgentBook on World Chain. diff --git a/x402/DOCS.md b/x402/DOCS.md index 85c2c45..7b13de2 100644 --- a/x402/DOCS.md +++ b/x402/DOCS.md @@ -1,157 +1,112 @@ -# AgentKit Extension +# AgentKit x402 Extension -Verify that an agent is backed by a real, World ID-verified human. +Add proof-of-personhood access policies to x402 resources. A registered agent signs the request body, the server verifies the `X-AgentKit` signature through the canonical AgentBook on World Chain, and the access policy is applied per human. ## Install -Install the library from npm: - ```bash npm install @worldcoin/agentkit ``` -or: - -```bash -bun add @worldcoin/agentkit -``` - -If you also need the registration CLI, install: +For local agent registration: ```bash -npm install -g @worldcoin/agentkit-cli +npx @worldcoin/agentkit-cli register ``` -or run it with: - -```bash -npx @worldcoin/agentkit-cli register -``` +## Access modes -For the end-user registration flow, see [`../cli/REGISTRATION.md`](../cli/REGISTRATION.md). +| Mode | Behavior | +| ------------ | ------------------------------------------------------------------------------------------------- | +| `free` | Registered agents bypass payment. | +| `free-trial` | The first N requests per human and endpoint bypass payment. | +| `discount` | Registered agents may pay a configured percentage less, optionally for only the first N requests. | -## Overview +`free-trial` and `discount` require an `AgentKitStorage` implementation. Usage is keyed by the AgentBook nullifier, so multiple registered agents belonging to one human share the same allowance. -Services that deal with automated traffic increasingly need to distinguish between "random bot" and "bot acting on behalf of a human". AgentKit solves this by combining the wallet every x402 agent already has with World ID's proof-of-personhood and an on-chain agent registry (the AgentBook). +## Request flow -- Agents register in the AgentBook smart contract using a World ID proof, tying their wallet address to an anonymous human identifier -- When accessing a protected resource, agents sign a CAIP-122 challenge with their wallet -- The server verifies the signature, looks up the agent's human identifier in the AgentBook, and applies the configured access policy -- Usage limits are tracked per human, not per agent, allowing for multiple agents to share a single human-backed identity +1. The client calls the protected resource normally. +2. The server returns `402 Payment Required` with `extensions.agentkit`. +3. The client normalizes and signs the request body, then retries with the hexadecimal signature in `X-AgentKit`. +4. The server calls Core's `verify(request)`, which recovers the signer and resolves its human nullifier from AgentBook on World Chain. +5. The hooks grant access, consume a trial use, or prepare a discounted payment according to the configured mode. -This is a **Server ↔ Client** extension. The Facilitator is not involved in identity verification itself, but `discount` mode still requires wiring `verifyFailureHook` into the payment flow. +The `agentkit` string is the lowercase x402 extension key. The HTTP request header is always `X-AgentKit`. -## Access Modes +## Client -AgentKit supports three configurable modes that control what happens when a human-backed agent is identified: - -| Mode | Behavior | -| ------------ | ---------------------------------------------------------------------------------------------------------- | -| `free` | Human-backed agents always bypass payment. | -| `free-trial` | Human-backed agents bypass payment the first N times (default: 1). After that, normal payment is required. | -| `discount` | Human-backed agents get a N% discount (optionally, only for the first N times). | - -Usage counters are tracked per **human** per **endpoint** — so two agents backed by the same human share the same counter. - -## How It Works - -1. Client requests a protected resource -2. Server responds with `402 Payment Required`, including the `agentkit` extension with a CAIP-122 challenge (nonce, domain, supported chains, mode) -3. Client signs the challenge with their wallet and sends it via the `agentkit` HTTP header -4. Server validates the signature, recovers the wallet address, and looks up the human identifier in the AgentBook -5. If the agent is registered and the access mode allows it, access is granted or a discount is applied. Otherwise, the standard payment flow continues. - -## Agent Client Usage - -If you are building an agent that calls paid x402 APIs, create an AgentKit client and use `agentkit.fetch` for those calls. The client retries AgentKit-enabled 402 responses with a signed `agentkit` header before your normal x402 payment fallback runs. +`createAgentkitClient` wraps `fetch`. It tries AgentKit once when a 402 response advertises the extension, then returns the retry response to the caller. It does not create or settle x402 payments. ```typescript import { createAgentkitClient } from '@worldcoin/agentkit' const agentkit = createAgentkitClient({ signer: { - address: agentWallet.address, - chainId: 'eip155:8453', - type: 'eip191', - signMessage: message => agentWallet.signMessage(message), + signMessage: message => agentWallet.signMessage({ message }), }, }) -const response = await agentkit.fetch('https://api.example.com/data') +const response = await agentkit.fetch('https://api.example.com/data', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: 'weather', city: 'Lisbon' }), +}) ``` -The client does not create payments. If AgentKit is unavailable, fails, or is exhausted, it returns the original 402 response so your existing x402 client can pay normally. +For JSON, the wrapper parses and serializes the body once, signs that compact representation, and transmits the same representation on the retry. Bodyless requests sign the empty string. -### Framework Examples +The built-in x402 adapters expose parsed JSON rather than raw request bytes. The portable hooks path therefore supports bodyless and JSON requests. For text, form, multipart, binary, or any route that must authenticate the original wire representation, call `verify(request)` in framework-level middleware while the original Web `Request` is available. -You do not need separate framework packages for V1. Use the same helper where your framework makes HTTP calls: +### Custom clients -```typescript -// Vercel AI SDK / OpenAI Agents SDK tool body -const response = await agentkit.fetch(url, requestInit) -``` +`createHeader(body)` returns the hexadecimal value to place in `X-AgentKit`: ```typescript -// LangChain or LangGraph tool -const callPaidApi = tool(async ({ url }) => agentkit.fetch(url), { - name: 'call_paid_api', - description: 'Call paid APIs; AgentKit verification is attempted before x402 payment.', +const body = { query: 'weather', city: 'Lisbon' } +const signature = await agentkit.createHeader(body) + +const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-AgentKit': signature, + }, + body: JSON.stringify(body), }) ``` -```typescript -// Coinbase AgentKit custom action -const action = async ({ url }) => agentkit.fetch(url) -``` - -For Hermes, Codex, Claude Code, and other local agents, either call code that uses `createAgentkitClient` or install the `agentkit-x402` skill so the agent knows to attempt AgentKit before payment when it cannot change the HTTP client. - -## Server Usage +When using `createHeader` directly, the sent body must match `normalizeAgentkitBody(body)` exactly. -AgentKit is published as `@worldcoin/agentkit` and is intended to be consumed as a normal npm package in your server application. +## Server hooks -### Hooks (Recommended) - -The hooks-based approach handles challenge generation, signature verification, and AgentBook lookups automatically. +The hooks integrate with `x402HTTPResourceServer` and, for discounts, the facilitator client. ```typescript import { Hono } from 'hono' import { serve } from '@hono/node-server' +import { HTTPFacilitatorClient } from '@x402/core/http' import { ExactEvmScheme } from '@x402/evm/exact/server' -import { HTTPFacilitatorClient, RouteConfig } from '@x402/core/http' -import { paymentMiddlewareFromHTTPServer, x402ResourceServer, x402HTTPResourceServer } from '@x402/hono' +import { paymentMiddlewareFromHTTPServer, x402HTTPResourceServer, x402ResourceServer } from '@x402/hono' import { - declareAgentkitExtension, + InMemoryAgentKitStorage, agentkitResourceServerExtension, createAgentkitHooks, - createAgentBookVerifier, - InMemoryAgentKitStorage, + declareAgentkitExtension, } from '@worldcoin/agentkit' -const NETWORK = 'eip155:8453' // Base -const payTo = '0xYourAddress' - -const agentBook = createAgentBookVerifier() -const storage = new InMemoryAgentKitStorage() +const NETWORK = 'eip155:8453' +const facilitator = new HTTPFacilitatorClient({ url: 'https://x402.org/facilitator' }) +const resourceServer = new x402ResourceServer(facilitator) + .register(NETWORK, new ExactEvmScheme()) + .registerExtension(agentkitResourceServerExtension) const hooks = createAgentkitHooks({ - storage, - agentBook, mode: { type: 'free-trial', uses: 3 }, + storage: new InMemoryAgentKitStorage(), }) -const facilitatorClient = new HTTPFacilitatorClient({ - url: 'https://x402.org/facilitator', -}) - -const resourceServer = new x402ResourceServer(facilitatorClient) - .register(NETWORK, new ExactEvmScheme()) - .registerExtension(agentkitResourceServerExtension) - -// Register the verify failure hook on the facilitator (required for discount mode) -if (hooks.verifyFailureHook) { - facilitatorClient.onVerifyFailure(hooks.verifyFailureHook) -} +if (hooks.verifyFailureHook) facilitator.onVerifyFailure(hooks.verifyFailureHook) const routes = { 'GET /data': { @@ -160,351 +115,156 @@ const routes = { scheme: 'exact', price: '$0.01', network: NETWORK, - payTo, + payTo: '0xYourAddress', }, ], extensions: declareAgentkitExtension({ - statement: 'Verify your agent is backed by a real human', mode: { type: 'free-trial', uses: 3 }, }), }, } const httpServer = new x402HTTPResourceServer(resourceServer, routes).onProtectedRequest(hooks.requestHook) - const app = new Hono() -app.use(paymentMiddlewareFromHTTPServer(httpServer)) -app.get('/data', c => { - return c.json({ message: 'Protected content' }) -}) +app.use(paymentMiddlewareFromHTTPServer(httpServer)) +app.get('/data', c => c.json({ message: 'Protected content' })) serve({ fetch: app.fetch, port: 4021 }) ``` -If your paid route runs on World Chain (`eip155:480`), add a custom `registerMoneyParser(...)` for World Chain USDC on `ExactEvmScheme`. AgentBook lookup always resolves against the canonical World Chain deployment — your paid route can run on any EVM chain, but the registry check happens on World Chain. - -### Mode Examples +The payment network can be any network supported by the configured x402 scheme and facilitator. AgentBook lookup is independent of it and always uses the canonical World Chain deployment. -In **Free access** mode, human-backed agents never pay: +### Free access ```typescript -const hooks = createAgentkitHooks({ - agentBook, - mode: { type: 'free' }, -}) +const hooks = createAgentkitHooks({ mode: { type: 'free' } }) ``` -No storage is needed for this mode. +No storage is required. -In **Free trial** mode, the first N uses are free per human per endpoint: +### Free trial ```typescript const hooks = createAgentkitHooks({ - agentBook, mode: { type: 'free-trial', uses: 5 }, storage: new InMemoryAgentKitStorage(), }) ``` -If Alice has two agents and uses 3 of her 5 free uses with Agent A, Agent B gets 2 remaining. +Use persistent, atomic storage in production. `InMemoryAgentKitStorage` is intended for examples and single-process development. -In **Discount** mode, human-backed agents get N% off the first N times: +### Discount ```typescript const hooks = createAgentkitHooks({ - agentBook, mode: { type: 'discount', percent: 50, uses: 10 }, storage: new InMemoryAgentKitStorage(), }) -// IMPORTANT: register the verify failure hook on the facilitator for discount mode -facilitatorClient.onVerifyFailure(hooks.verifyFailureHook!) -``` - -The client pays the discounted price. Payment verification fails (amount too low), but the `onVerifyFailure` hook on the facilitator recovers it by confirming the agent is human-backed with remaining discount uses, then adjusting the required amount so settlement re-verification passes. - -### Smart Wallet Support (EIP-1271 / EIP-6492) - -Signature verification automatically handles both smart contract wallets (ERC-1271) and EOA wallets (ecrecover). Smart wallets like Safe, Coinbase Smart Wallet, and CDP wallets work out of the box with no additional configuration. A public client is created internally from the chain ID to make the on-chain `isValidSignature` call when needed. - -ERC-1271 verification uses the `chainId` in the signed AgentKit payload. This is separate from AgentBook lookup, which still resolves against the canonical World Chain registry. Built-in public RPCs are used by default for Base, World Chain, Tempo, and Arc, so most integrations do not need RPC configuration for those signing chains. - -To accept smart-account signatures from contracts deployed on chains other than World Chain, advertise those signing chains in `declareAgentkit({ network: ... })`. If you need private or higher-quota RPC endpoints, override them per chain: - -```typescript -const hooks = createAgentkitHooks({ - agentBook, - mode: { type: 'free' }, - rpcUrls: { - 'eip155:480': 'https://your-world-chain-rpc.example', - 'eip155:8453': 'https://your-base-rpc.example', - }, -}) +facilitator.onVerifyFailure(hooks.verifyFailureHook!) ``` -`rpcUrl` is still supported as a fallback RPC for EVM signature verification, but `rpcUrls` is preferred when multiple signing chains are accepted. - -### Custom AgentBook Configuration - -`createAgentBookVerifier()` always resolves against the canonical AgentBook deployment on World Chain (`eip155:480`). You do not need to pass a chain ID — the registry lives on one chain and lookup happens there regardless of which chain the agent signed on or which chain your paid route runs on. The caller side stays fully chain‑agnostic. +The payment must come from the same address that signed the AgentKit body. Discount mode requires control of the facilitator hook path; it cannot be implemented only at the resource server. -```typescript -// Default — queries the canonical AgentBook on World Chain -const agentBook = createAgentBookVerifier() - -// Use a custom World Chain RPC endpoint -const agentBook = createAgentBookVerifier({ - rpcUrl: 'https://your-world-chain-rpc.example', -}) - -// Point at a custom contract (e.g. staging/testnet), still on World Chain RPC -const agentBook = createAgentBookVerifier({ - contractAddress: '0xYourCustomContract', -}) - -// Advanced: inject a fully custom viem client (useful for tests or non-standard setups) -import { worldchain } from 'viem/chains' -import { createPublicClient, http } from 'viem' +## Direct framework verification -const agentBook = createAgentBookVerifier({ - client: createPublicClient({ chain: worldchain, transport: http() }), -}) -``` - -### Manual Usage (Advanced) - -For custom flows, use the low-level functions directly: +When the framework exposes the original Fetch `Request`, use Core directly for exact-byte verification: ```typescript -import { - declareAgentkitExtension, - parseAgentkitHeader, - validateAgentkitMessage, - verifyAgentkitSignature, - createAgentBookVerifier, - AGENTKIT, -} from '@worldcoin/agentkit' - -// Include in 402 response -const extensions = declareAgentkitExtension({ - domain: 'api.example.com', - resourceUri: 'https://api.example.com/data', - network: 'eip155:8453', - statement: 'Verify your agent is backed by a real human', -}) +import { verify } from '@worldcoin/agentkit-core' -const agentBook = createAgentBookVerifier() +export async function POST(request: Request) { + const humanId = await verify(request) + const body = await request.json() -// Process incoming authentication -async function handleRequest(request: Request) { - const header = request.headers.get('agentkit') - if (!header) return - - const payload = parseAgentkitHeader(header) - - const validation = await validateAgentkitMessage(payload, 'https://api.example.com/data') - if (!validation.valid) { - return { error: validation.error } - } - - const verification = await verifyAgentkitSignature(payload) - if (!verification.valid) { - return { error: verification.error } - } - - // Look up the human behind this agent (always resolves against World Chain AgentBook) - const humanId = await agentBook.lookupHuman(verification.address!) - if (!humanId) { - return { error: 'Agent is not registered in the AgentBook' } - } - - // humanId is the anonymous human identifier - // Apply your own access policy based on this + return Response.json({ humanId, body }) } ``` -## EVM Network Support +`verify` clones the request, so the handler can still read the original body. This is the preferred path for non-JSON bodies and for applications that cannot accept JSON normalization. -Servers can accept authentication on any EVM network expressed as a CAIP-2 chain ID: - -```typescript -const routes = { - 'GET /data': { - accepts: [ - { - scheme: 'exact', - price: '$0.01', - network: 'eip155:8453', // Base - payTo: '0xYourEVMAddress', - }, - ], - extensions: declareAgentkitExtension({ - network: 'eip155:8453', - statement: 'Verify your agent is backed by a real human', - }), - }, -} -``` - -## Supported Chains - -- **Chain ID format:** `eip155:*` (e.g., `eip155:8453` for Base) -- **Signature type:** `eip191` -- **Signature schemes:** `eip191` (EOA, default), `eip1271` (smart contract), `eip6492` (counterfactual) -- **Message format:** EIP-4361 (SIWE) - -## API Reference +## API reference ### `declareAgentkitExtension(options?)` -Configures the extension for 402 responses. Most parameters are auto-derived from request context when using `agentkitResourceServerExtension`. +Creates the `agentkit` extension declaration for a route. -| Parameter | Type | Description | -| ------------------- | -------------------- | --------------------------------------------------------- | -| `domain` | `string` | Server's domain. Auto-derived from request URL. | -| `resourceUri` | `string` | Full resource URI. Auto-derived from request URL. | -| `network` | `string \| string[]` | CAIP-2 network(s). Auto-derived from `accepts[].network`. | -| `statement` | `string` | Human-readable purpose for signing. | -| `version` | `string` | CAIP-122 version (default: `"1"`). | -| `expirationSeconds` | `number` | Challenge TTL in seconds. | -| `mode` | `AgentkitMode` | Access mode (included in 402 response for clients). | +| Option | Type | Description | +| ------ | -------------- | ----------------------------------------- | +| `mode` | `AgentkitMode` | Access mode included in the 402 response. | -### `createAgentkitClient(options)` +### `agentkitResourceServerExtension` -Creates a client that tries AgentKit verification before normal x402 payment. +Register this with `x402ResourceServer.registerExtension(...)`. It adds the public AgentKit extension data to 402 responses and removes the declaration's private options. -| Option | Type | Description | -| --------- | ---------------------------------------- | --------------------------------------------------- | -| `signer` | `AgentkitSigner` | Agent wallet identity and `signMessage` function. | -| `fetch` | `typeof fetch` | Optional base fetch implementation. | -| `onEvent` | `(event: AgentkitFetchEvent) => void` | Optional callback for logging and debugging. | +### `createAgentkitClient(options)` -`agentkit.fetch` has the same shape as `fetch`. It only retries when the first response is a 402 with `extensions.agentkit`. +| Option | Type | Description | +| --------- | --------------------------------------------------- | ----------------------------------------- | +| `signer` | `{ signMessage(message: string): Promise }` | EIP-191 EOA signer. | +| `fetch` | `typeof fetch` | Optional underlying fetch implementation. | +| `onEvent` | `(event: AgentkitFetchEvent) => void` | Optional client event callback. | -**Returns:** +Returns: -| Field | Type | Description | -| -------------- | ----------------------------------------- | --------------------------------------------------------------------------- | -| `fetch` | `typeof fetch` | Fetch-compatible function that retries AgentKit-enabled 402 responses once. | -| `createHeader` | `(extension: AgentkitExtension) => Promise` | Creates the base64 `agentkit` HTTP header for custom HTTP clients. | +| Field | Description | +| -------------------- | --------------------------------------------------------------------------- | +| `fetch` | Fetch-compatible function that retries AgentKit-enabled 402 responses once. | +| `createHeader(body)` | Signs `normalizeAgentkitBody(body)` and returns the `X-AgentKit` value. | ### `createAgentkitHooks(options)` -Creates hooks for `x402HTTPResourceServer` and optionally `x402ResourceServer`. - -| Option | Type | Description | -| ----------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- | -| `agentBook` | `AgentBookVerifier` | AgentBook verifier instance (required). | -| `mode` | `AgentkitMode` | Access mode (default: `{ type: "free" }`). | -| `storage` | `AgentKitStorage` | Storage for usage tracking (required for `free-trial` and `discount`). | -| `rpcUrl` | `string` | Fallback custom RPC URL for EVM signature verification. Uses the signed chain's default public RPC if omitted. | -| `rpcUrls` | `Record` | Custom EVM signature-verification RPC URLs keyed by signed CAIP-2 chain ID, e.g. `{ "eip155:8453": "https://..." }`. | -| `onEvent` | `(event: AgentkitHookEvent) => void` | Callback for logging/debugging. | - -**Returns:** - -| Field | Type | Description | -| ------------------- | --------------------- | -------------------------------------------------------------------------------- | -| `requestHook` | function | Register with `httpServer.onProtectedRequest()`. | -| `verifyFailureHook` | function \| undefined | Register with `facilitator.onVerifyFailure()`. Only present for `discount` mode. | - -### `AgentkitMode` - -| Mode | Fields | Description | -| ------------ | ------------------------------------------------------ | ---------------------------------------------- | -| `free` | `{ type: "free" }` | Always bypass payment for human-backed agents. | -| `free-trial` | `{ type: "free-trial"; uses?: number }` | Bypass payment the first N times (default: 1). | -| `discount` | `{ type: "discount"; percent: number; uses?: number }` | N% discount the first N times. | - -### `createAgentBookVerifier(options?)` - -Creates a verifier that looks up agent wallet addresses in the canonical AgentBook contract on World Chain (`eip155:480`). Lookup always resolves against World Chain — the verifier is chain‑agnostic from the caller's perspective, regardless of which chain the agent's signature was produced on or which chain your paid route runs on. - -| Option | Type | Description | -| ----------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | -| `rpcUrl` | `string` | Custom World Chain RPC URL. Defaults to the chain's default public RPC. Ignored if `client` is provided. | -| `contractAddress` | `` `0x${string}` `` | Custom AgentBook contract address on World Chain. Defaults to the canonical deployment. | -| `client` | `PublicClient` | Advanced override — inject a fully custom viem public client (useful for tests or non-standard deployments). | +| Option | Type | Description | +| --------- | ------------------------------------ | ----------------------------------------- | +| `mode` | `AgentkitMode` | Defaults to `{ type: "free" }`. | +| `storage` | `AgentKitStorage` | Required for `free-trial` and `discount`. | +| `onEvent` | `(event: AgentkitHookEvent) => void` | Optional server event callback. | -Returns an object with `lookupHuman(address: string): Promise`. Returns the anonymous human identifier (hex string) or `null` if the agent is not registered. +Returns `requestHook` and, only for discount mode, `verifyFailureHook`. -### `AgentKitStorage` / `InMemoryAgentKitStorage` +### `AgentKitStorage` -Storage interface for tracking per-human usage counts. - -| Method | Description | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `tryIncrementUsage(endpoint, humanId, limit)` | Atomically increment usage if below `limit`. Returns `true` if incremented, `false` if limit already reached. | -| `hasUsedNonce?(nonce)` | Optional: check for replay attacks. | -| `recordNonce?(nonce)` | Optional: record a used nonce. | - -`InMemoryAgentKitStorage` is the reference in-memory implementation. For production, implement `AgentKitStorage` with a persistent backend (e.g. using a database transaction with row-level locking). - -### `parseAgentkitHeader(header)` - -Parses a base64-encoded `agentkit` header into a structured payload object. Throws if the header is malformed or missing required fields. - -### `validateAgentkitMessage(payload, resourceUri, options?)` - -Validates message fields including domain binding, URI, timestamps, and nonce. - -| Option | Type | Description | -| ------------ | ---------------------------- | -------------------------------------------------- | -| `maxAge` | `number` | Max age for `issuedAt` in ms (default: 5 minutes). | -| `checkNonce` | `(nonce: string) => boolean` | Custom nonce validation function. | - -Returns `{ valid: boolean; error?: string }`. - -### `verifyAgentkitSignature(payload, options?)` - -Verifies the cryptographic signature and recovers the signer address. EVM verification uses ERC-1271 (smart wallets) with ecrecover fallback (EOA) automatically. - -| Option | Type | Description | -| --------- | ------------------------ | ---------------------------------------------------------------------------------------------- | -| `rpcUrl` | `string` | Fallback custom RPC URL for EVM signature verification. Uses the signed chain's default public RPC if omitted. | -| `rpcUrls` | `Record` | Custom EVM signature-verification RPC URLs keyed by signed CAIP-2 chain ID. | +```typescript +interface AgentKitStorage { + tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise +} +``` -Returns `{ valid: boolean; address?: string; error?: string }`. +The check and increment must be atomic. -**Hook events:** +### Body helpers -| Event | Fields | Description | -| -------------------- | -------------------------------- | ------------------------------------------------------ | -| `agent_verified` | `resource`, `address`, `humanId` | Agent is human-backed, access granted. | -| `agent_not_verified` | `resource`, `address` | Valid signature but agent not registered in AgentBook. | -| `validation_failed` | `resource`, `error` | Signature or message validation failed. | -| `discount_applied` | `resource`, `address`, `humanId` | Discount mode: payment recovered at discounted rate. | -| `discount_exhausted` | `resource`, `address`, `humanId` | Discount mode: no more discounted uses remaining. | +- `normalizeAgentkitBody(body)` converts a parsed body to the UTF-8 text used for signing. +- `normalizeAgentkitRequestBody(request)` reads and normalizes a client's Fetch request body. +- `AGENTKIT` is the x402 extension key, `agentkit`. +- `AGENTKIT_HEADER` is the request header name, `X-AgentKit`. -## Security Considerations +## Security considerations -- **Domain binding**: The signed message includes the server's domain, preventing signature reuse across services. -- **Nonce uniqueness**: A fresh nonce is generated per request to prevent replay attacks. -- **Temporal bounds**: `issuedAt` must be recent (default: 5 minutes) and `expirationTime` must be in the future. -- **Chain-specific verification**: Signatures are verified using chain-appropriate methods, preventing cross-chain reuse. -- **Smart wallet support**: EVM verification automatically supports both smart contract wallets (ERC-1271) and EOA wallets via RPC calls to the chain. -- **On-chain verification**: AgentBook lookups happen at request time, so revoked registrations take effect immediately. -- **Per-human tracking**: Usage limits are tracked by anonymous human identifier, not by wallet address. Multiple agents controlled by one person share a single counter. +- The signature authenticates only the normalized request body. It does not automatically bind the method, URL, host, audience, timestamp, or nonce. Put any required context in the signed body and validate it in the application. +- Core currently uses recoverable EIP-191 EOA signatures. Smart-contract and counterfactual-wallet signatures are not supported by this format. +- AgentBook is queried on World Chain for every verification, so registration state is not selected by the x402 payment network. +- JSON normalization is part of the x402 hooks contract. A custom client must sign and send the same normalized representation. +- Trial and discount storage must be atomic and persistent in production. ## Troubleshooting ### Signature verification fails -- Verify the client is signing with the correct wallet -- Check the signature scheme matches (EIP-191 for EOA, EIP-1271 for smart wallets) -- If using a custom `rpcUrl`, ensure it points to the correct chain -- Confirm the chain ID is consistent between client and server +- Confirm the header is `X-AgentKit`, not `agentkit`. +- Confirm the header value is the raw hexadecimal signature, not base64 or JSON. +- Confirm the retried body is the same normalized body that was signed. +- For the hooks path, use an empty or JSON request body. -### Message validation fails +### AgentBook lookup fails -- Check that `issuedAt` is recent (within 5 minutes by default) -- Verify `expirationTime` is in the future -- Ensure the `domain` matches the server's hostname -- Confirm the `uri` starts with the server's origin +- Confirm the signing identity completed `agentkit register`. +- Confirm the service can reach World Chain. +- Confirm the identity is registered in the canonical AgentBook deployment. -### AgentBook lookup returns null +### AgentKit retry still returns 402 -- Verify the agent wallet has been registered in the AgentBook with a valid World ID proof -- Ensure the World Chain RPC endpoint is reachable (or your custom `rpcUrl` if one was provided) -- If you overrode `contractAddress`, confirm the deployment you're pointing at is the one holding your registration +- Check whether the free-trial or discount allowance is exhausted. +- Check the hook event callback for `agent_not_verified` or `validation_failed`. +- If AgentKit is unavailable, continue with the normal x402 payment flow. diff --git a/x402/package.json b/x402/package.json index da1fdb9..940a832 100644 --- a/x402/package.json +++ b/x402/package.json @@ -33,7 +33,8 @@ }, "dependencies": { "@worldcoin/agentkit-core": "^0.2.1", - "@x402/core": "^2.4.0" + "@x402/core": "^2.4.0", + "viem": "^2.46.2" }, "devDependencies": { "@types/node": "^25.5.0", diff --git a/x402/src/client.ts b/x402/src/client.ts index 55912f9..92dc70e 100644 --- a/x402/src/client.ts +++ b/x402/src/client.ts @@ -1,24 +1,13 @@ -import { - AGENTKIT, - formatSIWEMessage, - type AgentkitExtension, - type AgentkitPayload, - type CompleteAgentkitInfo, -} from '@worldcoin/agentkit-core' import type { PaymentRequired } from '@x402/core/types' - -export type AgentkitSignerType = 'eip191' | 'eip1271' +import { AGENTKIT, AGENTKIT_HEADER, normalizeAgentkitBody, normalizeAgentkitRequestBody } from './protocol' export type AgentkitSigner = { - address: string - chainId: string - type: AgentkitSignerType signMessage(message: string): Promise } export type AgentkitFetchEvent = | { type: 'agentkit_detected'; url: string } - | { type: 'agentkit_signed'; url: string; chainId: string; signatureType: string } + | { type: 'agentkit_signed'; url: string } | { type: 'agentkit_skipped'; url: string; reason: string } | { type: 'agentkit_retry_completed'; url: string; status: number } @@ -30,54 +19,34 @@ export interface CreateAgentkitClientOptions { export interface AgentkitClient { fetch: typeof fetch - createHeader(extension: AgentkitExtension): Promise + createHeader(body: unknown): Promise } export function createAgentkitClient(options: CreateAgentkitClientOptions): AgentkitClient { const fetchFn = options.fetch ?? globalThis.fetch - const createHeader = async (extension: AgentkitExtension): Promise => { - const supported = selectSupportedChain(extension, options.signer) - if (!supported) { - throw new Error(`Signer ${options.signer.chainId}/${options.signer.type} is not supported by this resource`) - } - - const completeInfo: CompleteAgentkitInfo = { - ...extension.info, - chainId: supported.chainId, - type: supported.type, - signatureScheme: supported.signatureScheme, - } - - const message = formatSIWEMessage(completeInfo, options.signer.address) - const signature = await options.signer.signMessage(message) - const payload: AgentkitPayload = { - ...extension.info, - address: options.signer.address, - chainId: supported.chainId, - type: supported.type, - ...(supported.signatureScheme ? { signatureScheme: supported.signatureScheme } : {}), - signature, - } - - return encodeBase64(JSON.stringify(payload)) - } + const createHeader = async (body: unknown): Promise => + options.signer.signMessage(normalizeAgentkitBody(body)) - const agentkitFetch = (async (input: Parameters[0], init?: Parameters[1]) => { + const agentkitFetch = (async ( + input: Parameters[0], + init?: Parameters[1] + ) => { const request = new Request(input, init) const response = await fetchFn(request.clone()) if (response.status !== 402) return response const paymentRequired = await parsePaymentRequired(response) - const extension = paymentRequired?.extensions?.[AGENTKIT] - if (!isAgentkitExtension(extension)) return response + if (!isAgentkitExtension(paymentRequired?.extensions?.[AGENTKIT])) return response const url = request.url options.onEvent?.({ type: 'agentkit_detected', url }) let header: string + let body: string try { - header = await createHeader(extension) + body = await normalizeAgentkitRequestBody(request) + header = await createHeader(body) } catch (err) { options.onEvent?.({ type: 'agentkit_skipped', @@ -87,17 +56,13 @@ export function createAgentkitClient(options: CreateAgentkitClientOptions): Agen return response } - options.onEvent?.({ - type: 'agentkit_signed', - url, - chainId: options.signer.chainId, - signatureType: options.signer.type, - }) + options.onEvent?.({ type: 'agentkit_signed', url }) const headers = new Headers(request.headers) - headers.set(AGENTKIT, header) + headers.set(AGENTKIT_HEADER, header) + headers.delete('content-length') - const retryResponse = await fetchFn(new Request(request, { headers })) + const retryResponse = await fetchFn(createRetryRequest(request, headers, body)) options.onEvent?.({ type: 'agentkit_retry_completed', url, status: retryResponse.status }) return retryResponse @@ -109,10 +74,6 @@ export function createAgentkitClient(options: CreateAgentkitClientOptions): Agen } } -function selectSupportedChain(extension: AgentkitExtension, signer: AgentkitSigner) { - return extension.supportedChains.find(chain => chain.chainId === signer.chainId && chain.type === signer.type) -} - async function parsePaymentRequired(response: Response): Promise { try { return (await response.clone().json()) as PaymentRequired @@ -121,29 +82,14 @@ async function parsePaymentRequired(response: Response): Promise { - const info: Partial & { version: string } = { - version: options.version ?? '1', - } - - if (options.domain) { - info.domain = options.domain - } - if (options.resourceUri) { - info.uri = options.resourceUri - info.resources = [options.resourceUri] - } - if (options.statement) { - info.statement = options.statement - } - - let supportedChains: SupportedChain[] = [] - if (options.network) { - const networks = Array.isArray(options.network) ? options.network : [options.network] - supportedChains = networks.flatMap(network => - getSignatureTypes(network).map(type => ({ - chainId: network, - type, - })) - ) - } - +export function declareAgentkitExtension( + options: DeclareAgentkitOptions = {} +): Record { const declaration: AgentkitDeclaration = { - info: info as AgentkitExtensionInfo, - supportedChains, - schema: buildAgentkitSchema(), + ...(options.mode ? { mode: options.mode } : {}), _options: options, } diff --git a/x402/src/hooks.ts b/x402/src/hooks.ts index 57d98f5..fa67e12 100644 --- a/x402/src/hooks.ts +++ b/x402/src/hooks.ts @@ -1,12 +1,8 @@ import type { AgentkitMode } from './types' import type { AgentKitStorage } from './storage' -import type { AgentBookVerifier, AgentkitSignatureVerificationOptions } from '@worldcoin/agentkit-core' -import { - AGENTKIT, - parseAgentkitHeader, - verifyAgentkitSignature, - validateAgentkitMessage, -} from '@worldcoin/agentkit-core' +import { verify } from '@worldcoin/agentkit-core' +import { recoverMessageAddress, type Hex } from 'viem' +import { AGENTKIT_HEADER, normalizeAgentkitBody, normalizeAgentkitJsonBody } from './protocol' export type AgentkitHookEvent = | { type: 'agent_verified'; resource: string; address: string; humanId: string } @@ -16,18 +12,27 @@ export type AgentkitHookEvent = | { type: 'discount_exhausted'; resource: string; address: string; humanId: string } export interface CreateAgentkitHooksOptions { - agentBook: AgentBookVerifier mode?: AgentkitMode storage?: AgentKitStorage - /** Fallback custom RPC URL for EVM signature verification. Uses the signed chain's default public RPC if omitted. */ - rpcUrl?: string - /** Custom EVM signature-verification RPC URLs keyed by CAIP-2 chain ID. */ - rpcUrls?: Record onEvent?: (event: AgentkitHookEvent) => void } export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { - const { agentBook, onEvent } = options + return createAgentkitHooksInternal(options) +} + +type VerifyFunction = (request: Request) => Promise +type RecoverAddressFunction = (body: Uint8Array, signature: Hex) => Promise + +export function createAgentkitHooksInternal( + options: CreateAgentkitHooksOptions, + dependencies: { verify?: VerifyFunction; recoverAddress?: RecoverAddressFunction } = {} +) { + const { onEvent } = options + const verifyRequest = dependencies.verify ?? verify + const recoverAddress = + dependencies.recoverAddress ?? + ((body: Uint8Array, signature: Hex) => recoverMessageAddress({ message: { raw: body }, signature })) const mode: AgentkitMode = options.mode ?? { type: 'free' } const storage = options.storage @@ -53,48 +58,30 @@ export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { const pendingDiscounts = new Map() const requestHook = async (context: { - adapter: { getHeader(name: string): string | undefined; getUrl(): string } + adapter: { getHeader(name: string): string | undefined; getUrl(): string; getBody?(): unknown } path: string }): Promise => { - const header = context.adapter.getHeader(AGENTKIT) || context.adapter.getHeader(AGENTKIT.toLowerCase()) + const header = context.adapter.getHeader(AGENTKIT_HEADER) if (!header) return try { - const payload = parseAgentkitHeader(header) - const resourceUri = context.adapter.getUrl() - - const checkNonce = storage?.hasUsedNonce - ? async (nonce: string) => !(await storage.hasUsedNonce!(nonce)) - : undefined - - const validation = await validateAgentkitMessage(payload, resourceUri, { checkNonce }) - if (!validation.valid) { - onEvent?.({ type: 'validation_failed', resource: context.path, error: validation.error }) - return - } - - const verificationOptions: AgentkitSignatureVerificationOptions = { - rpcUrl: options.rpcUrl, - rpcUrls: options.rpcUrls, - } - const verification = await verifyAgentkitSignature(payload, verificationOptions) - if (!verification.valid || !verification.address) { - onEvent?.({ type: 'validation_failed', resource: context.path, error: verification.error }) - return - } - - if (storage?.recordNonce) { - await storage.recordNonce(payload.nonce) - } - - const humanId = await agentBook.lookupHuman(verification.address) - if (!humanId) { - onEvent?.({ type: 'agent_not_verified', resource: context.path, address: verification.address }) - return - } + const parsedBody = await context.adapter.getBody?.() + const contentType = context.adapter.getHeader('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + const body = + contentType === 'application/json' || contentType?.endsWith('+json') + ? normalizeAgentkitJsonBody(parsedBody) + : normalizeAgentkitBody(parsedBody) + const bodyBytes = new TextEncoder().encode(body) + const verificationRequest = new Request(context.adapter.getUrl(), { + method: 'POST', + headers: { [AGENTKIT_HEADER]: header }, + body, + }) + const humanId = await verifyRequest(verificationRequest) + const address = await recoverAddress(bodyBytes, header as Hex) if (mode.type === 'free') { - onEvent?.({ type: 'agent_verified', resource: context.path, address: verification.address, humanId }) + onEvent?.({ type: 'agent_verified', resource: context.path, address, humanId }) return { grantAccess: true } } @@ -104,7 +91,7 @@ export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { onEvent?.({ type: 'agent_verified', resource: context.path, - address: verification.address, + address, humanId, }) return { grantAccess: true } @@ -119,15 +106,20 @@ export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { for (const [key, entry] of pendingDiscounts) { if (now - entry.createdAt > PENDING_TTL_MS) pendingDiscounts.delete(key) } - pendingDiscounts.set(`${context.path}:${verification.address}`, { + pendingDiscounts.set(`${context.path}:${address}`, { humanId, - address: verification.address, + address, createdAt: now, }) // Don't grant access — agent is expected to pay (at a discount) return } } catch (err) { + const failure = err as { code?: unknown; address?: unknown } + if (failure?.code === 'AGENT_NOT_REGISTERED' && typeof failure.address === 'string') { + onEvent?.({ type: 'agent_not_verified', resource: context.path, address: failure.address }) + return + } onEvent?.({ type: 'validation_failed', resource: context.path, @@ -196,7 +188,11 @@ function extractPayer(payload: Record): string | null { } } -const UNDERPAYMENT_REASONS = ['invalid_exact_evm_payload_authorization_value', 'permit2_insufficient_amount', 'insufficient_funds'] +const UNDERPAYMENT_REASONS = [ + 'invalid_exact_evm_payload_authorization_value', + 'permit2_insufficient_amount', + 'insufficient_funds', +] function isUnderpaymentError(error: Error): boolean { const reason = error.message.split(':')[0] diff --git a/x402/src/index.ts b/x402/src/index.ts index 9e44e22..3a401ef 100644 --- a/x402/src/index.ts +++ b/x402/src/index.ts @@ -1,5 +1,6 @@ -// Re-export everything from core -export * from '@worldcoin/agentkit-core' +// x402 protocol +export { AGENTKIT, AGENTKIT_HEADER, normalizeAgentkitBody, normalizeAgentkitRequestBody } from './protocol' +export type { AgentkitExtension } from './protocol' // x402-specific types export type { AgentkitMode, DeclareAgentkitOptions } from './types' @@ -10,13 +11,7 @@ export { agentkitResourceServerExtension } from './server' // x402 client integration export { createAgentkitClient } from './client' -export type { - AgentkitClient, - AgentkitFetchEvent, - AgentkitSigner, - AgentkitSignerType, - CreateAgentkitClientOptions, -} from './client' +export type { AgentkitClient, AgentkitFetchEvent, AgentkitSigner, CreateAgentkitClientOptions } from './client' // Storage export { InMemoryAgentKitStorage, type AgentKitStorage } from './storage' diff --git a/x402/src/protocol.ts b/x402/src/protocol.ts new file mode 100644 index 0000000..a18ab85 --- /dev/null +++ b/x402/src/protocol.ts @@ -0,0 +1,52 @@ +import type { AgentkitMode } from './types' + +export const AGENTKIT = 'agentkit' +export const AGENTKIT_HEADER = 'X-AgentKit' + +export interface AgentkitExtension { + mode?: AgentkitMode +} + +/** + * Convert the parsed body exposed by x402 HTTP adapters into the UTF-8 text + * signed by AgentKit clients. JSON bodies are serialized without insignificant + * whitespace; text bodies are preserved. + */ +export function normalizeAgentkitBody(body: unknown): string { + if (body === undefined) return '' + if (typeof body === 'string') return body + if (body instanceof ArrayBuffer) return new TextDecoder('utf-8', { fatal: true }).decode(body) + if (ArrayBuffer.isView(body)) { + return new TextDecoder('utf-8', { fatal: true }).decode( + new Uint8Array(body.buffer, body.byteOffset, body.byteLength) + ) + } + + const serialized = JSON.stringify(body) + if (serialized === undefined) throw new Error('AgentKit cannot serialize this request body') + return serialized +} + +export function normalizeAgentkitJsonBody(body: unknown): string { + const serialized = JSON.stringify(body) + if (serialized === undefined) throw new Error('AgentKit cannot serialize this JSON request body') + return serialized +} + +export async function normalizeAgentkitRequestBody(request: Request): Promise { + if (request.body === null) return '' + + const text = await request.clone().text() + if (text === '') return '' + + const contentType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (contentType === 'application/json' || contentType?.endsWith('+json')) { + try { + return normalizeAgentkitJsonBody(JSON.parse(text)) + } catch { + throw new Error('AgentKit cannot sign an invalid JSON request body') + } + } + + return text +} diff --git a/x402/src/server.ts b/x402/src/server.ts index 962e1e1..1cfd36b 100644 --- a/x402/src/server.ts +++ b/x402/src/server.ts @@ -1,73 +1,20 @@ -import { AGENTKIT, buildAgentkitSchema } from '@worldcoin/agentkit-core' -import { randomBytes } from 'crypto' -import { getSignatureTypes, type AgentkitDeclaration } from './declare' -import type { ResourceServerExtension, PaymentRequiredContext } from '@x402/core/types' -import type { - AgentkitExtension, - AgentkitExtensionInfo, - SupportedChain, -} from '@worldcoin/agentkit-core' +import { AGENTKIT } from './protocol' +import type { AgentkitExtension } from './protocol' +import type { AgentkitDeclaration } from './declare' import type { DeclareAgentkitOptions } from './types' +import type { ResourceServerExtension, PaymentRequiredContext } from '@x402/core/types' export const agentkitResourceServerExtension: ResourceServerExtension = { key: AGENTKIT, - enrichPaymentRequiredResponse: async (declaration: unknown, context: PaymentRequiredContext): Promise => { + enrichPaymentRequiredResponse: async ( + declaration: unknown, + _context: PaymentRequiredContext + ): Promise => { const decl = declaration as AgentkitDeclaration const opts: DeclareAgentkitOptions = decl._options ?? {} - const resourceUri = opts.resourceUri ?? context.resourceInfo.url - - let domain = opts.domain - if (!domain && resourceUri) { - try { - domain = new URL(resourceUri).hostname - } catch { - // leave domain undefined - } - } - - let networks: string[] - if (opts.network) { - networks = Array.isArray(opts.network) ? opts.network : [opts.network] - } else { - networks = [...new Set(context.requirements.map(r => r.network))] - } - - const nonce = randomBytes(16).toString('hex') - const issuedAt = new Date().toISOString() - - const expirationSeconds = opts.expirationSeconds - const expirationTime = - expirationSeconds !== undefined ? new Date(Date.now() + expirationSeconds * 1000).toISOString() : undefined - - const info: AgentkitExtensionInfo = { - domain: domain ?? '', - uri: resourceUri, - version: opts.version ?? '1', - nonce, - issuedAt, - resources: [resourceUri], - } - - if (expirationTime) { - info.expirationTime = expirationTime - } - if (opts.statement) { - info.statement = opts.statement - } - - const supportedChains: SupportedChain[] = networks.flatMap(network => - getSignatureTypes(network).map(type => ({ - chainId: network, - type, - })) - ) - return { - info, - supportedChains, - schema: buildAgentkitSchema(), ...(opts.mode ? { mode: opts.mode } : {}), } }, diff --git a/x402/src/storage.ts b/x402/src/storage.ts index 2cd83a9..779cf1b 100644 --- a/x402/src/storage.ts +++ b/x402/src/storage.ts @@ -7,14 +7,10 @@ export interface AgentKitStorage { * (e.g. a database transaction with row-level locking) to prevent TOCTOU race conditions. */ tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise - - hasUsedNonce?(nonce: string): Promise - recordNonce?(nonce: string): Promise } export class InMemoryAgentKitStorage implements AgentKitStorage { private usage = new Map() - private nonces = new Set() async tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise { const key = `${endpoint}:${humanId}` @@ -23,12 +19,4 @@ export class InMemoryAgentKitStorage implements AgentKitStorage { this.usage.set(key, count + 1) return true } - - async hasUsedNonce(nonce: string): Promise { - return this.nonces.has(nonce) - } - - async recordNonce(nonce: string): Promise { - this.nonces.add(nonce) - } } diff --git a/x402/src/types.ts b/x402/src/types.ts index 2f864ff..e38db2c 100644 --- a/x402/src/types.ts +++ b/x402/src/types.ts @@ -4,11 +4,5 @@ export type AgentkitMode = | { type: 'discount'; percent: number; uses?: number } export interface DeclareAgentkitOptions { - domain?: string - resourceUri?: string - statement?: string - version?: string - network?: string | string[] - expirationSeconds?: number mode?: AgentkitMode } diff --git a/x402/tests/client-e2e.test.ts b/x402/tests/client-e2e.test.ts index ca89170..4ba4a8e 100644 --- a/x402/tests/client-e2e.test.ts +++ b/x402/tests/client-e2e.test.ts @@ -1,29 +1,14 @@ import { describe, expect, it } from 'bun:test' -import { AGENTKIT, buildAgentkitSchema } from '@worldcoin/agentkit-core' +import { verifyRequest } from '../../core/src/verify' +import { createAgentkitHooksInternal } from '../src/hooks' +import { AGENTKIT, AGENTKIT_HEADER } from '../src/protocol' +import { createAgentkitClient, type AgentKitStorage } from '../src' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' -import { createAgentkitClient, createAgentkitHooks, type AgentKitStorage } from '../src' -import type { AgentkitExtension } from '@worldcoin/agentkit-core' const CHAIN_ID = 'eip155:8453' const PROTECTED_URL = 'https://agentkit.example/protected' -function createExtension(url: string): AgentkitExtension { - return { - info: { - domain: new URL(url).hostname, - uri: url, - version: '1', - nonce: 'nonce1234', - issuedAt: new Date().toISOString(), - statement: 'Verify your agent is backed by a real human', - resources: [url], - }, - supportedChains: [{ chainId: CHAIN_ID, type: 'eip191' }], - schema: buildAgentkitSchema(), - } -} - -function paymentRequired(extension: AgentkitExtension) { +function paymentRequired() { return { x402Version: 2, resource: { @@ -42,23 +27,27 @@ function paymentRequired(extension: AgentkitExtension) { extra: {}, }, ], - extensions: { [AGENTKIT]: extension }, + extensions: { [AGENTKIT]: { mode: { type: 'free-trial', uses: 3 } } }, } } -function createAdapter(request: Request) { +function createAdapter(request: Request, body: unknown) { return { getHeader(name: string) { + if (name.toLowerCase() === 'content-type') return request.headers.get('content-type') ?? undefined return request.headers.get(name) ?? undefined }, getUrl() { return request.url }, + getBody() { + return body + }, } } describe('AgentKit client/server E2E', () => { - it('uses the client SDK to satisfy an AgentKit-enabled 402 before payment fallback', async () => { + it('signs the request body and satisfies an AgentKit-enabled 402 before payment', async () => { const account = privateKeyToAccount(generatePrivateKey()) const clientEvents: Array> = [] const serverEvents: Array> = [] @@ -72,43 +61,46 @@ describe('AgentKit client/server E2E', () => { return true }, } - const hooks = createAgentkitHooks({ - agentBook: { - async lookupHuman(address) { - lookups.push(address) - return address.toLowerCase() === account.address.toLowerCase() ? 'human-1' : null - }, + const hooks = createAgentkitHooksInternal( + { + mode: { type: 'free-trial', uses: 3 }, + storage, + onEvent: event => serverEvents.push(event as Record), }, - mode: { type: 'free-trial', uses: 3 }, - storage, - onEvent: event => serverEvents.push(event as Record), - }) + { + verify: request => + verifyRequest(request, { + async lookupNullifierHash(address) { + lookups.push(address) + return address.toLowerCase() === account.address.toLowerCase() ? 'human-1' : null + }, + }), + } + ) const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { requestCount += 1 const request = new Request(input, init) const path = new URL(request.url).pathname - const grant = await hooks.requestHook({ adapter: createAdapter(request), path }) - - if (grant?.grantAccess) { - return Response.json({ ok: true }) - } + const text = await request.clone().text() + const body = text === '' ? undefined : JSON.parse(text) + const grant = await hooks.requestHook({ adapter: createAdapter(request, body), path }) - return Response.json(paymentRequired(createExtension(request.url)), { status: 402 }) + if (grant?.grantAccess) return Response.json({ ok: true }) + return Response.json(paymentRequired(), { status: 402 }) } const agentkit = createAgentkitClient({ - signer: { - address: account.address, - chainId: CHAIN_ID, - type: 'eip191', - signMessage: message => account.signMessage({ message }), - }, + signer: { signMessage: message => account.signMessage({ message }) }, fetch, onEvent: event => clientEvents.push(event), }) - const response = await agentkit.fetch(PROTECTED_URL) + const response = await agentkit.fetch(PROTECTED_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{\n "hello": "world"\n}', + }) const body = await response.json() expect(response.status).toBe(200) @@ -130,4 +122,9 @@ describe('AgentKit client/server E2E', () => { }, ]) }) + + it('does not treat the lowercase extension key as the request header name', () => { + expect(AGENTKIT).toBe('agentkit') + expect(AGENTKIT_HEADER).toBe('X-AgentKit') + }) }) diff --git a/x402/tests/client.test.ts b/x402/tests/client.test.ts index fbc73e4..6d3c541 100644 --- a/x402/tests/client.test.ts +++ b/x402/tests/client.test.ts @@ -1,18 +1,13 @@ import { describe, expect, it } from 'bun:test' +import { AGENTKIT_HEADER, normalizeAgentkitBody } from '../src/protocol' import { createAgentkitClient, type AgentkitSigner } from '../src/client' -import { buildAgentkitSchema, formatSIWEMessage } from '@worldcoin/agentkit-core' -import type { AgentkitExtension, AgentkitPayload } from '@worldcoin/agentkit-core' const CHAIN_ID = 'eip155:8453' -const SIGNATURE = '0x1234' -const ADDRESS = '0x1234567890AbcdEF1234567890aBcdef12345678' +const SIGNATURE = `0x${'12'.repeat(65)}` -function createEVMSigner(): AgentkitSigner & { messages: string[] } { +function createSigner(): AgentkitSigner & { messages: string[] } { const messages: string[] = [] return { - address: ADDRESS, - chainId: CHAIN_ID, - type: 'eip191', messages, async signMessage(message: string) { messages.push(message) @@ -21,22 +16,7 @@ function createEVMSigner(): AgentkitSigner & { messages: string[] } { } } -function createExtension(): AgentkitExtension { - return { - info: { - domain: 'agentkit.example', - uri: 'https://agentkit.example/protected', - version: '1', - nonce: 'nonce1234', - issuedAt: new Date().toISOString(), - resources: ['https://agentkit.example/protected'], - }, - supportedChains: [{ chainId: CHAIN_ID, type: 'eip191' }], - schema: buildAgentkitSchema(), - } -} - -function paymentRequired(extension?: AgentkitExtension) { +function paymentRequired(agentkit = true) { return { x402Version: 2, resource: { @@ -55,108 +35,115 @@ function paymentRequired(extension?: AgentkitExtension) { extra: {}, }, ], - ...(extension ? { extensions: { agentkit: extension } } : {}), + ...(agentkit ? { extensions: { agentkit: { mode: { type: 'free' } } } } : {}), } } describe('createAgentkitClient', () => { - it('creates a base64 AgentKit header with a signed EVM payload', async () => { - const signer = createEVMSigner() - const extension = createExtension() + it('creates an X-AgentKit value by signing the normalized body', async () => { + const signer = createSigner() const agentkit = createAgentkitClient({ signer }) + const body = { hello: 'world', unicode: '你好' } - const header = await agentkit.createHeader(extension) - const payload = JSON.parse(Buffer.from(header, 'base64').toString('utf8')) as AgentkitPayload - const message = formatSIWEMessage(payload, signer.address) - - expect(payload.address).toBe(signer.address) - expect(payload.chainId).toBe(CHAIN_ID) - expect(payload.type).toBe('eip191') - expect(payload.nonce).toBe(extension.info.nonce) - expect(payload.signature).toBe(SIGNATURE) - expect(signer.messages).toEqual([message]) + await expect(agentkit.createHeader(body)).resolves.toBe(SIGNATURE) + expect(signer.messages).toEqual([normalizeAgentkitBody(body)]) }) - it('encodes Unicode payloads in browser btoa environments', async () => { - const originalBtoa = globalThis.btoa - globalThis.btoa = (value: string) => Buffer.from(value, 'binary').toString('base64') - - try { - const signer = createEVMSigner() - const extension = createExtension() - const agentkit = createAgentkitClient({ signer }) - extension.info.statement = 'Verify this human-backed agent: 你好' + it('preserves the JSON representation of primitive string bodies', async () => { + const signer = createSigner() + const agentkit = createAgentkitClient({ + signer, + fetch: async request => { + const req = request instanceof Request ? request : new Request(request) + return req.headers.has(AGENTKIT_HEADER) + ? new Response('ok') + : Response.json(paymentRequired(), { status: 402 }) + }, + }) - const header = await agentkit.createHeader(extension) - const payload = JSON.parse(Buffer.from(header, 'base64').toString('utf8')) as AgentkitPayload + await agentkit.fetch('https://agentkit.example/protected', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '"hello"', + }) - expect(payload.statement).toBe(extension.info.statement) - } finally { - globalThis.btoa = originalBtoa - } + expect(signer.messages).toEqual(['"hello"']) }) - it('retries 402 responses with an AgentKit header when the extension is present', async () => { - const signer = createEVMSigner() + it('normalizes JSON once and retries with the exact body that was signed', async () => { + const signer = createSigner() const events: Array> = [] - const seenHeaders: string[] = [] + const retries: Array<{ header: string | null; body: string }> = [] const agentkit = createAgentkitClient({ signer, onEvent: event => events.push(event), fetch: async request => { const req = request instanceof Request ? request : new Request(request) - const header = req.headers.get('agentkit') + const header = req.headers.get(AGENTKIT_HEADER) if (header) { - seenHeaders.push(header) - return new Response(JSON.stringify({ ok: true }), { status: 200 }) + retries.push({ header, body: await req.text() }) + return Response.json({ ok: true }) } - return new Response(JSON.stringify(paymentRequired(createExtension())), { status: 402 }) + return Response.json(paymentRequired(), { status: 402 }) }, }) - const response = await agentkit.fetch('https://agentkit.example/protected') + const response = await agentkit.fetch('https://agentkit.example/protected', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{\n "hello": "world",\n "count": 2\n}', + }) expect(response.status).toBe(200) - expect(seenHeaders).toHaveLength(1) - expect(events.map(event => event.type)).toEqual(['agentkit_detected', 'agentkit_signed', 'agentkit_retry_completed']) + expect(signer.messages).toEqual(['{"hello":"world","count":2}']) + expect(retries).toEqual([{ header: SIGNATURE, body: '{"hello":"world","count":2}' }]) + expect(events.map(event => event.type)).toEqual([ + 'agentkit_detected', + 'agentkit_signed', + 'agentkit_retry_completed', + ]) }) - it('returns successful non-402 responses unchanged', async () => { - const signer = createEVMSigner() - const original = new Response('ok', { status: 200 }) + it('signs an empty body for bodyless requests', async () => { + const signer = createSigner() const agentkit = createAgentkitClient({ signer, - fetch: async () => original, + fetch: async request => { + const req = request instanceof Request ? request : new Request(request) + return req.headers.has(AGENTKIT_HEADER) + ? new Response('ok') + : Response.json(paymentRequired(), { status: 402 }) + }, }) + await expect(agentkit.fetch('https://agentkit.example/protected')).resolves.toHaveProperty('status', 200) + expect(signer.messages).toEqual(['']) + }) + + it('returns successful non-402 responses unchanged', async () => { + const original = new Response('ok', { status: 200 }) + const agentkit = createAgentkitClient({ signer: createSigner(), fetch: async () => original }) + await expect(agentkit.fetch('https://agentkit.example/open')).resolves.toBe(original) }) it('returns 402 responses without AgentKit unchanged', async () => { - const signer = createEVMSigner() - const original = new Response(JSON.stringify(paymentRequired()), { status: 402 }) - const agentkit = createAgentkitClient({ - signer, - fetch: async () => original, - }) + const original = Response.json(paymentRequired(false), { status: 402 }) + const agentkit = createAgentkitClient({ signer: createSigner(), fetch: async () => original }) await expect(agentkit.fetch('https://agentkit.example/protected')).resolves.toBe(original) }) - it('returns the original 402 and emits a skip event when the signer is unsupported', async () => { - const signer: AgentkitSigner = { - address: '0x1234567890abcdef1234567890abcdef12345678', - chainId: 'eip155:1', - type: 'eip191', - async signMessage() { - throw new Error('should not sign') - }, - } + it('returns the original 402 and emits a skip event when signing fails', async () => { const events: Array> = [] - const original = new Response(JSON.stringify(paymentRequired(createExtension())), { status: 402 }) + const original = Response.json(paymentRequired(), { status: 402 }) const agentkit = createAgentkitClient({ - signer, + signer: { + async signMessage() { + throw new Error('signer unavailable') + }, + }, onEvent: event => events.push(event), fetch: async () => original, }) diff --git a/x402/tests/hooks.test.ts b/x402/tests/hooks.test.ts index 38092a7..41e47ce 100644 --- a/x402/tests/hooks.test.ts +++ b/x402/tests/hooks.test.ts @@ -1,52 +1,60 @@ +import type { Hex } from 'viem' import { describe, expect, it } from 'bun:test' -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' -import { createAgentkitHooks } from '../src/hooks' -import type { AgentkitPayload } from '@worldcoin/agentkit-core' +import { AGENTKIT_HEADER } from '../src/protocol' import type { AgentKitStorage } from '../src/storage' -import { formatSIWEMessage } from '@worldcoin/agentkit-core' +import { createAgentkitHooksInternal } from '../src/hooks' -const CHAIN_ID = 'eip155:8453' +const ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' +const SIGNATURE = `0x${'12'.repeat(65)}` +const URL = 'https://agentkit.example/protected' -async function createSignedRequest(url = 'https://agentkit.example/protected') { - const account = privateKeyToAccount(generatePrivateKey()) - const address = account.address - const unsignedPayload = { - domain: new URL(url).hostname, - address, - uri: url, - version: '1', - chainId: CHAIN_ID, - type: 'eip191', - nonce: 'nonce1234', - issuedAt: new Date().toISOString(), - } satisfies Omit - - const message = formatSIWEMessage(unsignedPayload, address) - const signature = await account.signMessage({ message }) - const payload: AgentkitPayload = { ...unsignedPayload, signature } - - return { - address, - header: Buffer.from(JSON.stringify(payload)).toString('base64'), - path: new URL(url).pathname, - url, - } -} - -function createAdapter(url: string, header: string) { +function createAdapter(body: unknown = { hello: 'world' }, header = SIGNATURE, contentType?: string) { return { getHeader(name: string) { - return name.toLowerCase() === 'agentkit' ? header : undefined + if (name.toLowerCase() === AGENTKIT_HEADER.toLowerCase()) return header + if (name.toLowerCase() === 'content-type') return contentType + return undefined }, getUrl() { - return url + return URL + }, + getBody() { + return body }, } } +const dependencies = { + verify: async () => 'human-1', + recoverAddress: async (_body: Uint8Array, _signature: Hex) => ADDRESS, +} + describe('createAgentkitHooks', () => { - it('uses tryIncrementUsage to grant free-trial access', async () => { - const request = await createSignedRequest() + it('passes the normalized adapter body and X-AgentKit header to core verify', async () => { + const requests: Request[] = [] + const hooks = createAgentkitHooksInternal( + {}, + { + ...dependencies, + verify: async request => { + requests.push(request) + return 'human-1' + }, + } + ) + + await expect( + hooks.requestHook({ + adapter: createAdapter({ hello: 'world' }, SIGNATURE, 'application/json'), + path: '/protected', + }) + ).resolves.toEqual({ grantAccess: true }) + expect(requests).toHaveLength(1) + expect(requests[0]!.headers.get(AGENTKIT_HEADER)).toBe(SIGNATURE) + expect(await requests[0]!.text()).toBe('{"hello":"world"}') + }) + + it('uses the nullifier hash to grant free-trial access', async () => { const usageCalls: Array<{ endpoint: string; humanId: string; limit: number }> = [] const events: Array> = [] const storage: AgentKitStorage = { @@ -56,32 +64,30 @@ describe('createAgentkitHooks', () => { }, } - const hooks = createAgentkitHooks({ - agentBook: { lookupHuman: async () => 'human-1' }, - mode: { type: 'free-trial', uses: 3 }, - storage, - onEvent: event => events.push(event as Record), - }) + const hooks = createAgentkitHooksInternal( + { + mode: { type: 'free-trial', uses: 3 }, + storage, + onEvent: event => events.push(event as Record), + }, + dependencies + ) - const result = await hooks.requestHook({ - adapter: createAdapter(request.url, request.header), - path: request.path, - }) + const result = await hooks.requestHook({ adapter: createAdapter(), path: '/protected' }) expect(result).toEqual({ grantAccess: true }) - expect(usageCalls).toEqual([{ endpoint: request.path, humanId: 'human-1', limit: 3 }]) + expect(usageCalls).toEqual([{ endpoint: '/protected', humanId: 'human-1', limit: 3 }]) expect(events).toEqual([ { type: 'agent_verified', - resource: request.path, - address: request.address, + resource: '/protected', + address: ADDRESS, humanId: 'human-1', }, ]) }) - it('uses tryIncrementUsage to recover discounted underpayments', async () => { - const request = await createSignedRequest() + it('uses the nullifier hash to recover discounted underpayments', async () => { const usageCalls: Array<{ endpoint: string; humanId: string; limit: number }> = [] const events: Array> = [] const storage: AgentKitStorage = { @@ -91,46 +97,56 @@ describe('createAgentkitHooks', () => { }, } - const hooks = createAgentkitHooks({ - agentBook: { lookupHuman: async () => 'human-1' }, - mode: { type: 'discount', percent: 50, uses: 2 }, - storage, - onEvent: event => events.push(event as Record), - }) + const hooks = createAgentkitHooksInternal( + { + mode: { type: 'discount', percent: 50, uses: 2 }, + storage, + onEvent: event => events.push(event as Record), + }, + dependencies + ) - const requestResult = await hooks.requestHook({ - adapter: createAdapter(request.url, request.header), - path: request.path, - }) + const requestResult = await hooks.requestHook({ adapter: createAdapter(), path: '/protected' }) const requirements = { amount: '100' } const verifyResult = await hooks.verifyFailureHook?.({ paymentPayload: { - resource: { url: request.url }, - payload: { - authorization: { - from: request.address, - value: '50', - }, - }, + resource: { url: URL }, + payload: { authorization: { from: ADDRESS, value: '50' } }, }, requirements, error: new Error('invalid_exact_evm_payload_authorization_value: discounted payment'), }) expect(requestResult).toBeUndefined() - expect(verifyResult).toEqual({ - recovered: true, - result: { isValid: true, payer: request.address }, - }) + expect(verifyResult).toEqual({ recovered: true, result: { isValid: true, payer: ADDRESS } }) expect(requirements.amount).toBe('50') - expect(usageCalls).toEqual([{ endpoint: request.path, humanId: 'human-1', limit: 2 }]) + expect(usageCalls).toEqual([{ endpoint: '/protected', humanId: 'human-1', limit: 2 }]) expect(events).toEqual([ { type: 'discount_applied', - resource: request.path, - address: request.address, + resource: '/protected', + address: ADDRESS, humanId: 'human-1', }, ]) }) + + it('reports an unregistered signer separately from a malformed signature', async () => { + const events: Array> = [] + const hooks = createAgentkitHooksInternal( + { onEvent: event => events.push(event as Record) }, + { + ...dependencies, + verify: async () => { + throw Object.assign(new Error('Agent is not registered in AgentBook'), { + code: 'AGENT_NOT_REGISTERED', + address: ADDRESS, + }) + }, + } + ) + + await hooks.requestHook({ adapter: createAdapter(), path: '/protected' }) + expect(events).toEqual([{ type: 'agent_not_verified', resource: '/protected', address: ADDRESS }]) + }) })