diff --git a/.changeset/silver-scas-verify.md b/.changeset/silver-scas-verify.md new file mode 100644 index 0000000..700f490 --- /dev/null +++ b/.changeset/silver-scas-verify.md @@ -0,0 +1,6 @@ +--- +"@worldcoin/agentkit-core": patch +"@worldcoin/agentkit": patch +--- + +Allow EVM smart-account signature verification to use built-in public RPCs for common signing chains and select custom RPC endpoints from the signed payload chain ID. diff --git a/core/src/index.ts b/core/src/index.ts index 9e34e0f..bab93f9 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -20,11 +20,13 @@ export type { // Verification export { parseAgentkitHeader } from './parse' export { validateAgentkitMessage } from './validate' -export { verifyAgentkitSignature } from './verify' +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 { diff --git a/core/src/verify.ts b/core/src/verify.ts index 945d3ed..9dc29ca 100644 --- a/core/src/verify.ts +++ b/core/src/verify.ts @@ -2,10 +2,30 @@ import { formatSIWEMessage, verifyEVMSignature } from './evm' import { formatSIWSMessage, verifySolanaSignature, decodeBase58 } from './solana' import type { AgentkitPayload, AgentkitVerifyResult } from './types' -export async function verifyAgentkitSignature(payload: AgentkitPayload, rpcUrl?: string): Promise { +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 + +export function resolveAgentkitSignatureRpcUrl( + chainId: string, + options?: AgentkitSignatureVerificationConfig +): string | undefined { + if (typeof options === 'string') return options + return options?.rpcUrls?.[chainId] ?? options?.rpcUrl +} + +export async function verifyAgentkitSignature( + payload: AgentkitPayload, + options?: AgentkitSignatureVerificationConfig +): Promise { try { if (payload.chainId.startsWith('eip155:')) { - return verifyEVMPayload(payload, rpcUrl) + return verifyEVMPayload(payload, options) } if (payload.chainId.startsWith('solana:')) { @@ -24,7 +44,10 @@ export async function verifyAgentkitSignature(payload: AgentkitPayload, rpcUrl?: } } -async function verifyEVMPayload(payload: AgentkitPayload, rpcUrl?: string): Promise { +async function verifyEVMPayload( + payload: AgentkitPayload, + options?: AgentkitSignatureVerificationConfig +): Promise { const message = formatSIWEMessage( { domain: payload.domain, @@ -44,6 +67,7 @@ async function verifyEVMPayload(payload: AgentkitPayload, rpcUrl?: string): Prom ) try { + const rpcUrl = resolveAgentkitSignatureRpcUrl(payload.chainId, options) const valid = await verifyEVMSignature(message, payload.address, payload.signature, payload.chainId, rpcUrl) if (!valid) { diff --git a/core/src/viem-client.ts b/core/src/viem-client.ts index cd9779a..0a1374e 100644 --- a/core/src/viem-client.ts +++ b/core/src/viem-client.ts @@ -4,19 +4,42 @@ 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 cacheKey = `${numericChainId}:${rpcUrl ?? ''}` + const effectiveRpcUrl = rpcUrl ?? getDefaultPublicRpcUrl(numericChainId) + const cacheKey = `${numericChainId}:${effectiveRpcUrl ?? ''}` let cached = clientCache.get(cacheKey) if (cached) return cached let chain: chains.Chain - if (rpcUrl) { + 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(rpcUrl) }) as PublicClient + cached = createPublicClient({ chain, transport: http(effectiveRpcUrl) }) as PublicClient clientCache.set(cacheKey, cached) return cached } diff --git a/core/tests/verify.test.ts b/core/tests/verify.test.ts new file mode 100644 index 0000000..82db202 --- /dev/null +++ b/core/tests/verify.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test' +import { resolveAgentkitSignatureRpcUrl } 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' + ) + }) + + 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', + }, + }) + ).toBe('https://base.example') + }) + + 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', + }, + }) + ).toBe('https://fallback.example') + }) +}) diff --git a/core/tests/viem-client.test.ts b/core/tests/viem-client.test.ts new file mode 100644 index 0000000..27ec5f7 --- /dev/null +++ b/core/tests/viem-client.test.ts @@ -0,0 +1,17 @@ +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/deny.toml b/deny.toml index 18a498d..6d9e8e2 100644 --- a/deny.toml +++ b/deny.toml @@ -37,4 +37,5 @@ ignore = [ "RUSTSEC-2025-0055", # `tracing-subscriber` < 0.3.20, requires bumps on `semaphore-rs` (and Ark deps) (2025-09-09) "RUSTSEC-2025-0141", # Unmaintained `bincode`, feature complete for now (2026-01-15) "RUSTSEC-2025-0057", # Unmaintained `fxhash` (2025-09-05) + "RUSTSEC-2026-0173", # Unmaintained `proc-macro-error2`, build-time dep via alloy-sol-macro (2026-06-16) ] diff --git a/x402/DOCS.md b/x402/DOCS.md index 54b3617..85c2c45 100644 --- a/x402/DOCS.md +++ b/x402/DOCS.md @@ -228,16 +228,23 @@ The client pays the discounted price. Payment verification fails (amount too low 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. -To use a custom RPC endpoint instead of the chain's default public RPC: +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' }, - rpcUrl: 'https://your-rpc-endpoint.com', + rpcUrls: { + 'eip155:480': 'https://your-world-chain-rpc.example', + 'eip155:8453': 'https://your-base-rpc.example', + }, }) ``` +`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. @@ -391,7 +398,8 @@ Creates hooks for `x402HTTPResourceServer` and optionally `x402ResourceServer`. | `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` | Custom RPC URL for EVM signature verification. Uses the chain's default public RPC if omitted. | +| `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:** @@ -452,9 +460,10 @@ Returns `{ valid: boolean; error?: string }`. 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` | Custom RPC URL for EVM signature verification. Uses the chain's default public RPC if omitted. | +| 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. | Returns `{ valid: boolean; address?: string; error?: string }`. diff --git a/x402/src/hooks.ts b/x402/src/hooks.ts index ba11fc0..57d98f5 100644 --- a/x402/src/hooks.ts +++ b/x402/src/hooks.ts @@ -1,7 +1,12 @@ import type { AgentkitMode } from './types' import type { AgentKitStorage } from './storage' -import type { AgentBookVerifier } from '@worldcoin/agentkit-core' -import { AGENTKIT, parseAgentkitHeader, verifyAgentkitSignature, validateAgentkitMessage } from '@worldcoin/agentkit-core' +import type { AgentBookVerifier, AgentkitSignatureVerificationOptions } from '@worldcoin/agentkit-core' +import { + AGENTKIT, + parseAgentkitHeader, + verifyAgentkitSignature, + validateAgentkitMessage, +} from '@worldcoin/agentkit-core' export type AgentkitHookEvent = | { type: 'agent_verified'; resource: string; address: string; humanId: string } @@ -14,8 +19,10 @@ export interface CreateAgentkitHooksOptions { agentBook: AgentBookVerifier mode?: AgentkitMode storage?: AgentKitStorage - /** Custom RPC URL for EVM signature verification. Uses the chain's default public RPC if omitted. */ + /** 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 } @@ -66,7 +73,11 @@ export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { return } - const verification = await verifyAgentkitSignature(payload, options.rpcUrl) + 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