From ef88a120bd0b40dfd2c75d1325dad1715b5fb948 Mon Sep 17 00:00:00 2001 From: Claw Date: Sun, 6 Sep 2026 14:51:44 -0500 Subject: [PATCH] feat(facilitator): our own x402 facilitator (verify/settle/supported) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage B infra — stops depending on x402.org. The SDK's ExactEvmScheme validates payers WITH code via ERC-6492/1271, so our facilitator accepts an EIP-7702 session-key payer natively: the 'will the public facilitator accept our 7702 payer?' unknown is removed by owning the facilitator. - src/facilitator/facilitator.ts createFacilitatorService(): x402Facilitator + registerExactEvmScheme with a relay signer (viem wallet+public client). verify is gasless (eth_call); settle broadcasts transferWithAuthorization, gas from the relay (funded, no custody). Home for the self-funding fee later. - config.facilitator (active iff FACILITATOR_RELAY_KEY set); network follows x402. - server routes GET /facilitator/supported, POST /facilitator/{verify,settle}; 503 when disabled. Named createFacilitatorService to avoid colliding with the resource-side createFacilitator (x402/verify.ts). - tests/facilitator.test.ts: stands up, advertises exact/eip155:84532, refuses unsupported networks. 53 tests (42 bun + 11 forge). verify/settle are chain-touching → validated on testnet with a funded relay, not in-sandbox. Facilitator off until FACILITATOR_RELAY_KEY is set — production unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MknoCuiQ6a2qV1jcirjFPA --- src/config.ts | 13 ++++++++- src/facilitator/facilitator.ts | 53 ++++++++++++++++++++++++++++++++++ src/server.ts | 32 ++++++++++++++++++++ tests/facilitator.test.ts | 27 +++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/facilitator/facilitator.ts create mode 100644 tests/facilitator.test.ts diff --git a/src/config.ts b/src/config.ts index 4ae21a1..26a744a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,10 @@ export interface AppConfig { * expiry + funded-budget on-chain. Absent → the gateway falls back to the raw demoBuyerKey. * Written by scripts/deploy-session-delegate.mjs into secrets/session-key.env. */ sessionCustody: { delegatedEoa: `0x${string}`; sessionPrivateKey: string } | null; + /** Our own x402 facilitator (Stage B). Active when a relay key is set. The relay pays settlement + * gas only — no custody. Its ExactEvmScheme validates 7702/ERC-1271 payers natively, so we no + * longer depend on x402.org accepting our session-key payer. */ + facilitator: { relayKey: string; rpcUrl: string; network: Network } | null; } const env = (k: string): string => (process.env[k] ?? '').trim(); @@ -89,5 +93,12 @@ export function loadConfig(): AppConfig { const sessionPrivateKey = env('SESSION_PRIVATE_KEY'); const sessionCustody = delegatedEoa && sessionPrivateKey ? { delegatedEoa: delegatedEoa as `0x${string}`, sessionPrivateKey } : null; - return { port, host, logLevel, x402, x402Missing, demoBuyerKey, receiptSignerKey, agentApiKey, publicOrigin, sessionCustody }; + // Our own facilitator activates when a relay key is present. Network follows x402 config (Base + // Sepolia default); RPC from X402_RPC_URL or the public Base Sepolia endpoint. + const facRelayKey = env('FACILITATOR_RELAY_KEY'); + const facilitator = facRelayKey + ? { relayKey: facRelayKey, rpcUrl: env('X402_RPC_URL') || 'https://sepolia.base.org', network: (x402?.network ?? 'eip155:84532') as Network } + : null; + + return { port, host, logLevel, x402, x402Missing, demoBuyerKey, receiptSignerKey, agentApiKey, publicOrigin, sessionCustody, facilitator }; } diff --git a/src/facilitator/facilitator.ts b/src/facilitator/facilitator.ts new file mode 100644 index 0000000..2ee3009 --- /dev/null +++ b/src/facilitator/facilitator.ts @@ -0,0 +1,53 @@ +/** + * Our own x402 facilitator (Stage B infra). Verifies + settles x402 "exact" payments so AgentView + * stops depending on x402.org/facilitator — critically, the SDK's ExactEvmScheme validates payers + * with code via ERC-6492/1271, so an EIP-7702 session-key payer (Stage A) is verified natively. + * That removes the "will the public facilitator accept our 7702 payer?" unknown: we ARE the + * facilitator. + * + * verify() is read-only (eth_call) — no gas. settle() broadcasts transferWithAuthorization and pays + * gas from the relay account, which must be funded. The relay holds no user funds and takes no + * custody; it is a gas-paying relay + the natural home for a transparent protocol fee later. + */ +import { x402Facilitator } from '@x402/core/facilitator'; +import type { Network, PaymentPayload, PaymentRequirements, VerifyResponse, SettleResponse } from '@x402/core/types'; + +export interface FacilitatorConfig { + /** Relay key — pays settlement gas only. Never holds user funds. */ + relayKey: string; + rpcUrl: string; + network: Network; +} + +export interface Facilitator { + relayAddress: `0x${string}`; + supported: () => ReturnType; + verify: (payload: PaymentPayload, requirements: PaymentRequirements) => Promise; + settle: (payload: PaymentPayload, requirements: PaymentRequirements) => Promise; +} + +export async function createFacilitatorService(cfg: FacilitatorConfig): Promise { + const { createWalletClient, http, publicActions } = await import('viem'); + const { privateKeyToAccount } = await import('viem/accounts'); + const { baseSepolia } = await import('viem/chains'); + const { registerExactEvmScheme } = await import('@x402/evm/exact/facilitator'); + const { toFacilitatorEvmSigner } = await import('@x402/evm'); + + if (cfg.network !== 'eip155:84532') throw new Error(`facilitator: unsupported network ${cfg.network} (only Base Sepolia for now)`); + const account = privateKeyToAccount(cfg.relayKey as `0x${string}`); + // A wallet client (for settle broadcasts) extended with public actions (for verify's eth_calls). + const client = createWalletClient({ account, chain: baseSepolia, transport: http(cfg.rpcUrl) }).extend(publicActions); + // toFacilitatorEvmSigner is designed to accept a viem wallet+public client; the cast bridges viem + // version drift in overlapping method signatures (verifyTypedData etc.), which the SDK adapts. + const signer = toFacilitatorEvmSigner(Object.assign(client, { address: account.address }) as unknown as Parameters[0]); + + const fac = new x402Facilitator(); + registerExactEvmScheme(fac, { signer, networks: cfg.network }); + + return { + relayAddress: account.address, + supported: () => fac.getSupported(), + verify: (payload, requirements) => fac.verify(payload, requirements), + settle: (payload, requirements) => fac.settle(payload, requirements), + }; +} diff --git a/src/server.ts b/src/server.ts index 12204e6..d245e7c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,6 +28,7 @@ import type { EIP712SignedReceipt } from '@x402/extensions/offer-receipt'; import { createSpendLedger } from './safety/ledger.ts'; import { enforceAndPay } from './safety/gateway.ts'; import { sessionKeySigner } from './kernel/session-signer.ts'; +import { createFacilitatorService } from './facilitator/facilitator.ts'; import type { SpendPolicy } from './safety/policy.ts'; import { readFileSync } from 'node:fs'; import { timingSafeEqual } from 'node:crypto'; @@ -75,6 +76,11 @@ const spendLedger = createSpendLedger((process.env.SPEND_LEDGER_PATH ?? '').trim // Built once at startup so we don't derive the account per request. const custodySigner = config.sessionCustody ? await sessionKeySigner(config.sessionCustody) : undefined; if (custodySigner) console.log(`[custody] session-key mode: payer=${custodySigner.address} (on-chain 7702 leash)`); + +// Our own x402 facilitator (Stage B). Present when a relay key is configured. verify is gasless; +// settle pays gas from the relay. Native ERC-6492/1271 verification means it accepts 7702 payers. +const facilitator = config.facilitator ? await createFacilitatorService(config.facilitator) : undefined; +if (facilitator) console.log(`[facilitator] enabled: relay=${facilitator.relayAddress} network=${config.facilitator!.network}`); // Payments are possible with EITHER a raw buyer key OR session-key custody. const gatewayDeps = () => ({ ledger: spendLedger, spendingKey: config.demoBuyerKey ?? '', signer: custodySigner }); const spendingReady = config.demoBuyerKey !== null || custodySigner !== undefined; @@ -407,6 +413,32 @@ async function handle(req: Request): Promise { return json({ ok: true, agentId, budgetAtomic, perPaymentAtomic: '10000', attempts }); } + // Our x402 facilitator (Stage B). Standard verify/settle/supported surface — an x402 resource + // server can point X402_FACILITATOR_URL here. Accepts 7702/ERC-1271 payers natively. + if (path === '/facilitator/supported' && req.method === 'GET') { + if (!facilitator) return json({ error: 'facilitator_disabled' }, 503); + return json(facilitator.supported()); + } + if ((path === '/facilitator/verify' || path === '/facilitator/settle') && req.method === 'POST') { + if (!facilitator) return json({ error: 'facilitator_disabled' }, 503); + let b: { paymentPayload?: unknown; paymentRequirements?: unknown }; + try { + b = (await req.json()) as typeof b; + } catch { + return json({ error: 'invalid_json' }, 400); + } + if (!b.paymentPayload || !b.paymentRequirements) return json({ error: 'paymentPayload_and_paymentRequirements_required' }, 400); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fn = path.endsWith('verify') ? facilitator.verify : facilitator.settle; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await fn(b.paymentPayload as any, b.paymentRequirements as any); + return json(result as Record); + } catch (err) { + return json({ error: 'facilitator_error', detail: err instanceof Error ? err.message.slice(0, 200) : 'unknown' }, 502); + } + } + return json({ error: 'not_found' }, 404); } diff --git a/tests/facilitator.test.ts b/tests/facilitator.test.ts new file mode 100644 index 0000000..fd61e52 --- /dev/null +++ b/tests/facilitator.test.ts @@ -0,0 +1,27 @@ +/** + * Facilitator construction + capability advertisement (no chain needed — getSupported reads the + * registered schemes, and the RPC transport is lazy). Proves our facilitator stands up and + * advertises the exact/Base-Sepolia scheme. Full verify/settle are chain-touching → validated on + * testnet with a funded relay, not here (this sandbox blocks the loopback a local chain would need). + */ +import { test, expect } from 'bun:test'; +import { privateKeyToAccount } from 'viem/accounts'; +import { createFacilitatorService } from '../src/facilitator/facilitator.ts'; + +const RELAY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +test('facilitator stands up and reports its relay address', async () => { + const f = await createFacilitatorService({ relayKey: RELAY, rpcUrl: 'https://sepolia.base.org', network: 'eip155:84532' }); + expect(f.relayAddress).toBe(privateKeyToAccount(RELAY).address); +}); + +test('facilitator advertises the exact scheme on Base Sepolia', async () => { + const f = await createFacilitatorService({ relayKey: RELAY, rpcUrl: 'https://sepolia.base.org', network: 'eip155:84532' }); + const s = f.supported(); + expect(s.kinds.some((k) => k.scheme === 'exact' && k.network === 'eip155:84532')).toBe(true); +}); + +test('facilitator refuses an unsupported network (fail closed, not silent)', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(createFacilitatorService({ relayKey: RELAY, rpcUrl: 'https://x', network: 'eip155:1' as any })).rejects.toThrow(); +});