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:
+12
-1
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user