Skip to content
Closed
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
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions packages/web3/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down
7 changes: 6 additions & 1 deletion packages/web3/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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({
Expand Down
71 changes: 59 additions & 12 deletions scripts/study/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, Chain> = {
Expand Down Expand Up @@ -67,21 +72,48 @@ 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);
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";
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
})
}));
Expand All @@ -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
Expand All @@ -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)));
}
Expand Down
67 changes: 67 additions & 0 deletions scripts/study/paid.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading