diff --git a/docker-compose.yml b/docker-compose.yml index 100d9fb..abb1f19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,6 +96,10 @@ services: HEIGHTPROOF_ENDPOINT: http://api:8080 DATABASE_URL: postgres://heightproof:heightproof@postgres:5432/heightproof STUDY_STATE: /data/state.json + # Optional: x402-paid RPC providers (USDC on Base). Off unless a funded key is set. + STUDY_X402_PAYER_KEY: ${STUDY_X402_PAYER_KEY:-} + STUDY_PAID_DAILY_BUDGET_USD: ${STUDY_PAID_DAILY_BUDGET_USD:-8} + STUDY_PAID_EVERY_TICKS: ${STUDY_PAID_EVERY_TICKS:-80} volumes: - heightproof-study:/data depends_on: diff --git a/packages/web3/src/index.test.ts b/packages/web3/src/index.test.ts index 5a1de82..363aabf 100644 --- a/packages/web3/src/index.test.ts +++ b/packages/web3/src/index.test.ts @@ -149,6 +149,25 @@ describe("result capture", () => { vi.unstubAllGlobals(); }); + it("calls the rpc url through fetchImpl when one is given", async () => { + captured.length = 0; + const globalFetch = vi.fn(); + vi.stubGlobal("fetch", globalFetch); + const payingFetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ jsonrpc: "2.0", result: "0x10" }) + })) as unknown as typeof fetch; + + const client = createHeightproofRpcClient({ ...baseOptions, rpcUrl: "https://paid.example/v1/eth", fetchImpl: payingFetch }); + + expect(await client.request({ method: "eth_blockNumber", params: [] })).toBe("0x10"); + expect(payingFetch).toHaveBeenCalledWith("https://paid.example/v1/eth", expect.objectContaining({ method: "POST" })); + expect(globalFetch).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + it("never lets the rpc url reach the captured event", async () => { captured.length = 0; const fetchMock = vi.fn(async () => ({ diff --git a/packages/web3/src/index.ts b/packages/web3/src/index.ts index 64a474b..ea4ed81 100644 --- a/packages/web3/src/index.ts +++ b/packages/web3/src/index.ts @@ -47,6 +47,11 @@ export type HeightproofWeb3Options = { * requests against the endpoints you name. */ verify?: VerificationOptions; + /** + * Fetch used for calls to `rpcUrl`. Pass one that pays for priced endpoints, such as a fetch + * wrapped with x402. Defaults to the global fetch. + */ + fetchImpl?: typeof fetch; }; /** @@ -87,7 +92,7 @@ export function createHeightproofRpcClient(options: HeightproofWeb3Options) { return { async request(args: JsonRpcRequest) { const outcome = await recorder.record(args, async () => { - const response = await fetch(options.rpcUrl!, { + const response = await (options.fetchImpl ?? fetch)(options.rpcUrl!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ diff --git a/scripts/study/collect.ts b/scripts/study/collect.ts index a502800..eb02af6 100644 --- a/scripts/study/collect.ts +++ b/scripts/study/collect.ts @@ -9,11 +9,16 @@ * * The first run signs up a dedicated workspace and keeps its API key in STUDY_STATE, so restarts * keep writing to the same project. + * + * Paid endpoints (STUDY_PAID_ENDPOINTS, x402 per call) run only when STUDY_X402_PAYER_KEY is set, + * on every STUDY_PAID_EVERY_TICKS-th tick, within STUDY_PAID_DAILY_BUDGET_USD. They are never + * used to verify other endpoints, so verification never spends money on their behalf. */ import { randomBytes } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { createHeightproofRpcClient } from "@heightproof/web3"; +import { createPaidBudget } from "./paid.js"; type Chain = { chainId: number; @@ -27,7 +32,7 @@ type Chain = { /** How far behind the head a log range ends, about 10 s, so a provider a block behind is not a mismatch. */ logLag: number; }; -type Endpoint = { name: string; url: string; chain: string }; +type Endpoint = { name: string; url: string; chain: string; paid: boolean }; type StudyState = { projectId: string; apiKey: string; startedAt: string }; const CHAINS: Record = { @@ -67,6 +72,12 @@ const DEFAULT_ENDPOINTS = "arbitrum/drpc=https://arbitrum.drpc.org," + "arbitrum/blastapi=https://arbitrum-one.public.blastapi.io"; +// RPC sold per call to AI agents over x402. Pocket Network quoted $0.005 a call on 1 Nov 2026. +const DEFAULT_PAID_ENDPOINTS = + "pocket=https://agent.pocket.network/v1/eth," + + "base/pocket=https://agent.pocket.network/v1/base," + + "arbitrum/pocket=https://agent.pocket.network/v1/arb-one"; + const api = process.env.HEIGHTPROOF_ENDPOINT ?? "http://localhost:8080"; const statePath = process.env.STUDY_STATE ?? ".study/state.json"; const intervalMs = Number(process.env.STUDY_INTERVAL_MS ?? 15_000); @@ -74,14 +85,35 @@ const sampleRate = Number(process.env.STUDY_VERIFY_SAMPLE_RATE ?? 0.2); const callTimeoutMs = Number(process.env.STUDY_CALL_TIMEOUT_MS ?? 10_000); // Log queries return the most data by far, so they run on every Nth tick only. const logsEveryTicks = Number(process.env.STUDY_LOGS_EVERY_TICKS ?? 4); -const endpoints: Endpoint[] = (process.env.STUDY_ENDPOINTS ?? DEFAULT_ENDPOINTS) - .split(",") - .map((entry) => { - const [label, ...url] = entry.trim().split("="); - const chain = label.includes("/") ? label.slice(0, label.indexOf("/")) : "ethereum"; - if (!CHAINS[chain]) throw new Error(`unknown chain "${chain}" in STUDY_ENDPOINTS`); - return { name: label, url: url.join("="), chain }; - }); +const payerKey = process.env.STUDY_X402_PAYER_KEY as `0x${string}` | undefined; +const paidEveryTicks = Number(process.env.STUDY_PAID_EVERY_TICKS ?? 80); +const paidPriceUsd = Number(process.env.STUDY_PAID_PRICE_USD ?? 0.005); +const paidDailyBudgetUsd = Number(process.env.STUDY_PAID_DAILY_BUDGET_USD ?? 8); + +function parseEndpoints(list: string, paid: boolean): Endpoint[] { + return list + .split(",") + .filter((entry) => entry.trim()) + .map((entry) => { + const [label, ...url] = entry.trim().split("="); + const chain = label.includes("/") ? label.slice(0, label.indexOf("/")) : "ethereum"; + if (!CHAINS[chain]) throw new Error(`unknown chain "${chain}" in study endpoints`); + return { name: label, url: url.join("="), chain, paid }; + }); +} + +const endpoints: Endpoint[] = [ + ...parseEndpoints(process.env.STUDY_ENDPOINTS ?? DEFAULT_ENDPOINTS, false), + ...(payerKey ? parseEndpoints(process.env.STUDY_PAID_ENDPOINTS ?? DEFAULT_PAID_ENDPOINTS, true) : []) +]; +const budget = payerKey + ? createPaidBudget({ + payerKey, + paidUrls: endpoints.filter((e) => e.paid).map((e) => e.url), + priceUsd: paidPriceUsd, + dailyBudgetUsd: paidDailyBudgetUsd + }) + : undefined; // Deterministic reads whose answers every honest provider must agree on at a given height. const WALLET = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; @@ -120,6 +152,11 @@ const state = await loadOrCreateState(); console.log( `study project ${state.projectId} (since ${state.startedAt}); ${endpoints.length} endpoints every ${intervalMs}ms, verify sample ${sampleRate}` ); +console.log( + budget + ? `paid endpoints on: every ${paidEveryTicks} ticks, $${paidPriceUsd}/call, budget $${paidDailyBudgetUsd}/day` + : "paid endpoints off (set STUDY_X402_PAYER_KEY to a Base key holding USDC)" +); const clients = endpoints.map((endpoint) => ({ endpoint, @@ -128,17 +165,20 @@ const clients = endpoints.map((endpoint) => ({ apiKey: state.apiKey, endpoint: api, rpcUrl: endpoint.url, + fetchImpl: endpoint.paid ? budget!.fetch : undefined, provider: endpoint.name, chainId: CHAINS[endpoint.chain].chainId, serviceName: "rpc-study", environment: "study", verify: { endpoints: endpoints - .filter((other) => other !== endpoint && other.chain === endpoint.chain) + .filter((other) => other !== endpoint && other.chain === endpoint.chain && !other.paid) .map((other) => ({ rpcUrl: other.url, provider: other.name })), sampleRate, maxCallsPerMinute: 20, - timeoutMs: callTimeoutMs + timeoutMs: callTimeoutMs, + // A paid endpoint is also asked for the head when verifying, so that call must pay too. + fetchImpl: endpoint.paid ? budget!.fetch : undefined } }) })); @@ -150,8 +190,14 @@ process.on("SIGINT", () => (stopping = true)); const totals = new Map(endpoints.map((endpoint) => [endpoint.name, { ok: 0, failed: 0 }])); for (let tick = 1; !stopping; tick++) { const started = Date.now(); + // A multiple of logsEveryTicks, so paid endpoints get the log query too. + const paidTick = tick % paidEveryTicks === 0; await Promise.all( clients.map(async ({ endpoint, client }) => { + // Skip, rather than fail, paid calls outside their ticks or past the budget, so they do + // not show up as provider failures in the report. + if (endpoint.paid && (!paidTick || budget!.exhausted())) return; + const head = await withTimeout( client.request({ method: "eth_blockNumber", params: [] }), callTimeoutMs @@ -173,7 +219,8 @@ for (let tick = 1; !stopping; tick++) { if (tick % 20 === 0) { const summary = [...totals].map(([name, t]) => `${name} ${t.ok}/${t.ok + t.failed}`).join(" "); - console.log(`${new Date().toISOString()} tick ${tick} ok/total: ${summary}`); + const spend = budget ? ` x402 spent today $${budget.spentTodayUsd().toFixed(3)}` : ""; + console.log(`${new Date().toISOString()} tick ${tick} ok/total: ${summary}${spend}`); } await sleep(Math.max(0, intervalMs - (Date.now() - started))); } diff --git a/scripts/study/paid.ts b/scripts/study/paid.ts new file mode 100644 index 0000000..b85f959 --- /dev/null +++ b/scripts/study/paid.ts @@ -0,0 +1,67 @@ +/** + * Paying for priced RPC endpoints in the study: providers that sell JSON-RPC per call over x402 + * (USDC on Base). Spending is capped twice: x402 refuses any single payment above + * MAX_PER_PAYMENT, and this wrapper stops paying for the rest of the UTC day once the daily + * budget is spent. + */ +import { privateKeyToAccount } from "viem/accounts"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; + +const BASE_MAINNET = "eip155:8453"; +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +/** $0.01 in USDC base units: no single study call should cost more. */ +const MAX_PER_PAYMENT = "10000"; + +export type PaidBudget = { + fetch: typeof fetch; + /** True once today's budget cannot cover another call. */ + exhausted(): boolean; + spentTodayUsd(): number; +}; + +export function createPaidBudget(options: { + payerKey: `0x${string}`; + paidUrls: string[]; + priceUsd: number; + dailyBudgetUsd: number; +}): PaidBudget { + const payments = new x402Client().setSpendControls({ + allowedAssets: [{ network: BASE_MAINNET, asset: BASE_USDC, maxAmountPerPayment: MAX_PER_PAYMENT }] + }); + registerExactEvmScheme(payments, { signer: privateKeyToAccount(options.payerKey) }); + const paying = wrapFetchWithPayment(fetch, payments); + const paidUrls = new Set(options.paidUrls); + + let day = utcDay(); + let spent = 0; + const rollOver = () => { + if (utcDay() !== day) { + day = utcDay(); + spent = 0; + } + }; + const exhausted = () => { + rollOver(); + return spent + options.priceUsd > options.dailyBudgetUsd; + }; + + const budgetFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (!paidUrls.has(url)) return fetch(input, init); + if (exhausted()) throw new Error("x402 daily budget reached"); + + const response = await paying(input, init); + // A settled payment comes back with a payment-response header; a successful answer + // implies one too. Rejected payments (unfunded wallet, bad signature) cost nothing. + const settled = response.headers.has("payment-response") || response.headers.has("x-payment-response"); + if (settled || response.ok) spent += options.priceUsd; + return response; + }) as typeof fetch; + + return { fetch: budgetFetch, exhausted, spentTodayUsd: () => (rollOver(), spent) }; +} + +function utcDay() { + return new Date().toISOString().slice(0, 10); +}