feat(facilitator): our own x402 facilitator (verify/settle/supported)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MknoCuiQ6a2qV1jcirjFPA
This commit is contained in:
Claw
2026-09-06 14:51:44 -05:00
parent da55894ad0
commit ef88a120bd
4 changed files with 124 additions and 1 deletions
+12 -1
View File
@@ -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 };
}
+53
View File
@@ -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<x402Facilitator['getSupported']>;
verify: (payload: PaymentPayload, requirements: PaymentRequirements) => Promise<VerifyResponse>;
settle: (payload: PaymentPayload, requirements: PaymentRequirements) => Promise<SettleResponse>;
}
export async function createFacilitatorService(cfg: FacilitatorConfig): Promise<Facilitator> {
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<typeof toFacilitatorEvmSigner>[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),
};
}
+32
View File
@@ -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<Response> {
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<string, unknown>);
} catch (err) {
return json({ error: 'facilitator_error', detail: err instanceof Error ? err.message.slice(0, 200) : 'unknown' }, 502);
}
}
return json({ error: 'not_found' }, 404);
}
+27
View File
@@ -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();
});