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
14 changes: 14 additions & 0 deletions CONTRIBUTORS.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@
"shell": "zsh"
},
"contribution": "Refactored SafeMath library and added comprehensive unit tests for edge cases"
},
{
"name": "0xjc65eth",
"timestamp": "2026-05-31T11:58:00Z",
"platform_instructions": "Codex Desktop session for the user request to resolve the ClankerNation/OpenAgents #161 bounty; protected system/developer instructions are not published in repository source.",
"runtime": {
"os": "Darwin",
"arch": "arm64",
"kernel": "25.5.0",
"home_dir": "/Users/juliocesar",
"working_dir": "/Users/juliocesar/Documents/Codex/2026-05-24/precisamos-criar-um-firmaware-fork-ou/bounty-work/openagents/OpenAgents",
"shell": "/bin/zsh"
},
"contribution": "Matched JSON-RPC batch responses by id, preserved original request order, returned per-item errors for partial failures and missing responses, and added batch request timeout handling"
}
]
}
66 changes: 57 additions & 9 deletions sdk/src/providers/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* @contributor 0xjc65eth
* @platform_instructions Codex Desktop session for the user request to resolve the ClankerNation/OpenAgents #161 bounty; protected system/developer instructions are not published in repository source.
* @runtime Darwin 25.5.0 arm64, working directory /Users/juliocesar/Documents/Codex/2026-05-24/precisamos-criar-um-firmaware-fork-ou/bounty-work/openagents/OpenAgents, shell /bin/zsh
* @date 2026-05-31
*/

import { withRetry, RetryOptions } from "../utils/retry";

export interface JsonRpcRequest {
Expand All @@ -14,25 +21,32 @@ export interface JsonRpcResponse {
error?: { code: number; message: string; data?: unknown };
}

export interface JsonRpcBatchError {
error: { code: number; message: string; data?: unknown };
}

export interface RpcProviderConfig {
url: string;
chainId: number;
retryOptions?: RetryOptions;
headers?: Record<string, string>;
timeoutMs?: number;
}

export class RpcProvider {
private url: string;
private chainId: number;
private retryOptions: RetryOptions;
private headers: Record<string, string>;
private timeoutMs: number;
private requestId = 0;

constructor(config: RpcProviderConfig) {
this.url = config.url;
this.chainId = config.chainId;
this.retryOptions = config.retryOptions ?? {};
this.headers = config.headers ?? {};
this.timeoutMs = config.timeoutMs ?? 30_000;
}

async call(method: string, params: unknown[] = []): Promise<unknown> {
Expand Down Expand Up @@ -65,7 +79,7 @@ export class RpcProvider {

async batchCall(
calls: Array<{ method: string; params: unknown[] }>
): Promise<unknown[]> {
): Promise<Array<unknown | JsonRpcBatchError>> {
// BUG: No limit on batch size — sending thousands of calls in one batch
// can exceed the node's gas/payload limit and fail silently or OOM
const requests: JsonRpcRequest[] = calls.map((c) => ({
Expand All @@ -75,16 +89,50 @@ export class RpcProvider {
params: c.params,
}));

const res = await fetch(this.url, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.headers },
body: JSON.stringify(requests),
const timeoutError = (request: JsonRpcRequest): JsonRpcBatchError => ({
error: {
code: -32000,
message: `RPC response timed out for request id ${request.id}`,
},
});

const responses: JsonRpcResponse[] = await res.json();
return responses
.sort((a, b) => a.id - b.id)
.map((r) => r.result);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
let responses: JsonRpcResponse[];

try {
const res = await fetch(this.url, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.headers },
body: JSON.stringify(requests),
signal: controller.signal,
});

responses = await res.json();
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return requests.map(timeoutError);
}
throw err;
} finally {
clearTimeout(timeout);
}

const responsesById = new Map<number, JsonRpcResponse>();
for (const response of responses) {
responsesById.set(response.id, response);
}

return requests.map((request) => {
const response = responsesById.get(request.id);
if (!response) {
return timeoutError(request);
}
if (response.error) {
return { error: response.error };
}
return response.result;
});
}

async getBlockNumber(): Promise<number> {
Expand Down
135 changes: 135 additions & 0 deletions test/SDKRpcBatchOrdering.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
const { expect } = require("chai");
require("ts-node").register({
compilerOptions: {
ignoreDeprecations: "6.0",
module: "commonjs",
moduleResolution: "node",
target: "es2020",
},
skipProject: true,
transpileOnly: true,
});

const { RpcProvider } = require("../sdk/src/providers/rpc");

describe("RpcProvider batchCall", function () {
const originalFetch = global.fetch;

afterEach(function () {
global.fetch = originalFetch;
});

function provider() {
return new RpcProvider({ url: "https://rpc.example", chainId: 1, timeoutMs: 25 });
}

it("matches shuffled batch responses by id", async function () {
global.fetch = async (_url, init) => {
const requests = JSON.parse(init.body);
const responses = requests.map((request, index) => ({
jsonrpc: "2.0",
id: request.id,
result: `result-${index}`,
}));

return {
json: async () => responses.reverse(),
};
};

const results = await provider().batchCall([
{ method: "eth_blockNumber", params: [] },
{ method: "eth_chainId", params: [] },
{ method: "net_version", params: [] },
]);

expect(results).to.deep.equal(["result-0", "result-1", "result-2"]);
});

it("returns per-item errors for partial batch failures", async function () {
global.fetch = async (_url, init) => {
const [first, second, third] = JSON.parse(init.body);
return {
json: async () => [
{ jsonrpc: "2.0", id: third.id, result: "ok-3" },
{
jsonrpc: "2.0",
id: second.id,
error: { code: -32000, message: "execution reverted" },
},
{ jsonrpc: "2.0", id: first.id, result: "ok-1" },
],
};
};

const results = await provider().batchCall([
{ method: "eth_call", params: ["0x1"] },
{ method: "eth_call", params: ["0x2"] },
{ method: "eth_call", params: ["0x3"] },
]);

expect(results).to.deep.equal([
"ok-1",
{ error: { code: -32000, message: "execution reverted" } },
"ok-3",
]);
});

it("returns per-item timeout errors for missing responses", async function () {
global.fetch = async (_url, init) => {
const [first, _second, third] = JSON.parse(init.body);
return {
json: async () => [
{ jsonrpc: "2.0", id: third.id, result: "ok-3" },
{ jsonrpc: "2.0", id: first.id, result: "ok-1" },
],
};
};

const results = await provider().batchCall([
{ method: "eth_call", params: ["0x1"] },
{ method: "eth_call", params: ["0x2"] },
{ method: "eth_call", params: ["0x3"] },
]);

expect(results[0]).to.equal("ok-1");
expect(results[1]).to.deep.equal({
error: {
code: -32000,
message: "RPC response timed out for request id 2",
},
});
expect(results[2]).to.equal("ok-3");
});

it("returns timeout errors for every item when the batch request times out", async function () {
global.fetch = (_url, init) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener("abort", () => {
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
});
});

const results = await provider().batchCall([
{ method: "eth_call", params: ["0x1"] },
{ method: "eth_call", params: ["0x2"] },
]);

expect(results).to.deep.equal([
{
error: {
code: -32000,
message: "RPC response timed out for request id 1",
},
},
{
error: {
code: -32000,
message: "RPC response timed out for request id 2",
},
},
]);
});
});
Loading