Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/silver-scas-verify.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 27 additions & 3 deletions core/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentkitVerifyResult> {
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<string, string>
}

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<AgentkitVerifyResult> {
try {
if (payload.chainId.startsWith('eip155:')) {
return verifyEVMPayload(payload, rpcUrl)
return verifyEVMPayload(payload, options)
}

if (payload.chainId.startsWith('solana:')) {
Expand All @@ -24,7 +44,10 @@ export async function verifyAgentkitSignature(payload: AgentkitPayload, rpcUrl?:
}
}

async function verifyEVMPayload(payload: AgentkitPayload, rpcUrl?: string): Promise<AgentkitVerifyResult> {
async function verifyEVMPayload(
payload: AgentkitPayload,
options?: AgentkitSignatureVerificationConfig
): Promise<AgentkitVerifyResult> {
const message = formatSIWEMessage(
{
domain: payload.domain,
Expand All @@ -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) {
Expand Down
29 changes: 26 additions & 3 deletions core/src/viem-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,42 @@ import { createPublicClient, extractChain, http, type PublicClient } from 'viem'
const allChains = Object.values(chains)
const clientCache = new Map<string, PublicClient>()

// 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<number, string>([
[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
}
34 changes: 34 additions & 0 deletions core/tests/verify.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
17 changes: 17 additions & 0 deletions core/tests/viem-client.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
1 change: 1 addition & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
]
21 changes: 15 additions & 6 deletions x402/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, string>` | 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:**
Expand Down Expand Up @@ -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<string, string>` | Custom EVM signature-verification RPC URLs keyed by signed CAIP-2 chain ID. |

Returns `{ valid: boolean; address?: string; error?: string }`.

Expand Down
19 changes: 15 additions & 4 deletions x402/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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<string, string>
onEvent?: (event: AgentkitHookEvent) => void
}

Expand Down Expand Up @@ -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
Expand Down