List Ink pools at one block hash: EIP-1898 eth_call and Multicall3

Read an Ink factory's pool list pinned to one canonical block hash: eth_chainId, the latest block hash, allPoolsLength, then Multicall3 aggregate3, with an x-api-key header and a Node.js viem example.

Direct answer

To discover pools on Ink Mainnet at a single block, use the authenticated endpoint https://api.blockvectra.com/v1/ink_mainnet with your key in the x-api-key header. Read the latest block once, then send every later eth_call with that block's hash as an EIP-1898 selector, {"blockHash": "0x…", "requireCanonical": true}. The keyless …/v1/ink_mainnet/public route rejects this hash-pinned eth_call with -32011 and reason state_window (see When a hash-pinned read is rejected), so this task needs a key.

Keep the key in the BLOCKVECTRA_API_KEY environment variable on your machine. Do not put it in a URL, a chat message, source code or a log line. To get one, log in to the console, or follow the programmatic sign-up guide from an agent or script. This page does not create accounts or keys.

  • First step: check that eth_chainId returns 0xdef1 (57073).
  • Done when: the script prints one block hash, the factory's pool count at that hash and exactly that many pool addresses.

The four requests

  1. eth_chainId must equal 57073. Stop on anything else.
  2. eth_getBlockByNumber with latest returns the head. Keep its hash; do not use the block number from now on.
  3. eth_call to the factory 0x7660BF2aDefC687C22906112ca323d60A00358cF with allPoolsLength() (selector 0xefde4e64) at {blockHash, requireCanonical: true} returns the pool count.
  4. One eth_call to Multicall3 0xcA11bde05977b3631167028862bE2a173976CA11 at the same hash selector runs aggregate3 over allPools(0) … allPools(count − 1) (selector 0x41d1de97). Build the list from the count you just read; do not hard-code it.

Example: Node.js and viem

This example needs Node.js 20 or later and viem 2.x. Install viem yourself in your own project with npm install viem; nothing else is required. Save the script as ink-pools.mjs:

export BLOCKVECTRA_API_KEY='replace-with-your-key'
node ink-pools.mjs
import { decodeAbiParameters, decodeFunctionResult, encodeFunctionData, parseAbi, zeroAddress } from "viem";

const ENDPOINT = "https://api.blockvectra.com/v1/ink_mainnet";
const CHAIN_ID = 57073n;
const FACTORY = "0x7660BF2aDefC687C22906112ca323d60A00358cF";
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
const MAX_POOLS = 150n; // example budget for this script, not a product limit
const TIMEOUT_MS = 15_000;
const GAP_MS = 1_100;

const apiKey = process.env.BLOCKVECTRA_API_KEY;
if (!apiKey || apiKey === "replace-with-your-key") throw new Error("Set BLOCKVECTRA_API_KEY in your local environment");

const factoryAbi = parseAbi([
  "function allPoolsLength() view returns (uint256)",
  "function allPools(uint256) view returns (address)",
]);
const multicallAbi = parseAbi([
  "function aggregate3((address target, bool allowFailure, bytes callData)[] calls) payable returns ((bool success, bytes returnData)[] returnData)",
]);

// One request at a time, no retry, no redirect, no fallback to another endpoint.
let lastSentAt = 0;
async function rpc(method, params) {
  const wait = lastSentAt + GAP_MS - Date.now();
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
  lastSentAt = Date.now();
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "content-type": "application/json", "x-api-key": apiKey },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
    redirect: "error",
    signal: AbortSignal.timeout(TIMEOUT_MS),
  });
  if (response.status !== 200) throw new Error(`${method}: HTTP ${response.status}`);
  const body = await response.json();
  if (body?.jsonrpc !== "2.0" || body.id !== 1) throw new Error(`${method}: invalid JSON-RPC response`);
  if (body.error) {
    const reason = String(body.error.data?.reason ?? "").slice(0, 64);
    throw new Error(`${method}: JSON-RPC error ${body.error.code} ${reason}`.trim());
  }
  return body.result;
}

function requireData(value, bytes) {
  const valid = typeof value === "string" && /^0x(?:[0-9a-f]{2})*$/i.test(value)
    && (bytes === undefined || value.length === 2 + bytes * 2);
  if (!valid) throw new Error("Invalid eth_call result");
  return value;
}

// 1. Chain id
const chainIdHex = await rpc("eth_chainId", []);
if (typeof chainIdHex !== "string" || !/^0x(?:0|[1-9a-f][0-9a-f]*)$/i.test(chainIdHex)) throw new Error("Invalid chain id");
if (BigInt(chainIdHex) !== CHAIN_ID) throw new Error(`Expected chain ${CHAIN_ID}, got ${BigInt(chainIdHex)}`);

// 2. Read the head once and keep only its hash
const head = await rpc("eth_getBlockByNumber", ["latest", false]);
const blockHash = head?.hash;
if (typeof blockHash !== "string" || !/^0x[0-9a-f]{64}$/i.test(blockHash)) throw new Error("Invalid block hash");
const at = { blockHash, requireCanonical: true };

// 3. Pool count at that hash
const countData = await rpc("eth_call", [
  { to: FACTORY, data: encodeFunctionData({ abi: factoryAbi, functionName: "allPoolsLength" }) },
  at,
]);
const count = decodeFunctionResult({ abi: factoryAbi, functionName: "allPoolsLength", data: requireData(countData, 32) });
if (count < 1n || count > MAX_POOLS) throw new Error(`allPoolsLength ${count} is outside 1..${MAX_POOLS}`);
const size = Number(count);

// 4. allPools(i) for every i below the count, in one aggregate3 at the same hash
const calls = Array.from({ length: size }, (_, i) => ({
  target: FACTORY,
  allowFailure: true,
  callData: encodeFunctionData({ abi: factoryAbi, functionName: "allPools", args: [BigInt(i)] }),
}));
const listData = await rpc("eth_call", [
  { to: MULTICALL3, data: encodeFunctionData({ abi: multicallAbi, functionName: "aggregate3", args: [calls] }) },
  at,
]);
const results = decodeFunctionResult({ abi: multicallAbi, functionName: "aggregate3", data: requireData(listData) });
if (results.length !== size) throw new Error(`aggregate3 returned ${results.length} results for ${size} calls`);

const pools = results.map(({ success, returnData }, i) => {
  if (!success) throw new Error(`allPools(${i}) failed inside Multicall3`);
  const word = requireData(returnData, 32);
  if (!/^0x0{24}[0-9a-f]{40}$/i.test(word)) throw new Error(`allPools(${i}) returned non-zero address padding`);
  const [pool] = decodeAbiParameters([{ type: "address" }], word);
  if (pool === zeroAddress) throw new Error(`allPools(${i}) returned the zero address`);
  return pool;
});
if (new Set(pools.map((pool) => pool.toLowerCase())).size !== pools.length) throw new Error("Duplicate pool address");

console.log(JSON.stringify({ blockHash, count: size, pools }, null, 2));

The script stops with a non-zero exit code and no partial output when any of these happens:

  • an HTTP status other than 200, a JSON-RPC error, a 15 second timeout or a redirect;
  • a chain id other than 57073, a malformed block hash or a result that is not valid hex or valid ABI data;
  • a count outside 1 to 150. The 150 cap is only a bounded budget for this example; it is not a product limit, and we have not tested a 150-call batch here;
  • an inner allPools(i) call that fails, or a zero or duplicate pool address.

It makes four calls, about 1.1 seconds apart, never retries, never broadcasts a transaction, never pages and never substitutes latest or a block number for the hash. It prints only the hash, the count and the pool addresses, never headers or the key.

Sample run

Our QA ran these four requests against ink_mainnet with a key on 2026-11-03 at 06:40 UTC. Every response was HTTP 200 with no retry:

ItemObserved
Chain id0xdef1 (57073)
Block60189593, hash 0xc3b4349618611b7dfd979140a70087920cb8416eb1c8eff82cb46332494b7617
allPoolsLength() at that hash17
aggregate3 at that hash17 results, all successful, non-zero and unique

This is one QA run, not a current value: the head moves and the factory's count can grow, so read both again when you run it. It does not show a full Pharos run, a 150-call batch, pool balances or TVL, sustained use or adoption by any team. The saved requests and responses are public.

When a hash-pinned read is rejected

On 2026-11-03 the keyless route returned the head, then rejected an eth_call with {blockHash, requireCanonical: true} using JSON-RPC -32011 and error.data.reason: state_window. That error is not retryable (see state_window and historical state windows). The authenticated route and the keyless route are different entry points; check public.methods and the state window in GET /v1/chains rather than assuming one behaves like the other.

If a hash-pinned call is rejected, because the block is no longer canonical or the serving node cannot serve it:

  • Stop. Do not repeat the same hash, and do not swap in latest or a block number to get an answer. The count and the pool list would then come from different blocks.
  • Start a new run from step 2. Its output is a new snapshot with its own hash.

Two reads accepted at the same hash do not prove the block can never be reorganized later, and they do not validate the rest of your application. Treat the output as a snapshot at that hash. For the method itself, see eth_call; for Ink Mainnet parameters and access options, see the Ink chain page.

Independent public-RPC observations

If you also want an outside view of public Ink RPC endpoints, OpenChainBench publishes one at openchainbench.com/benchmarks/ink-rpc, with the same data as JSON at openchainbench.com/api/stat/ink-rpc. It measures a keyless eth_getBlockByNumber("latest", false) every 60 seconds from three regions. That is a different workload from this guide's keyed, hash-pinned eth_call and Multicall3 requests, and it says nothing about whether pool discovery succeeds, so run the example with your own key before relying on it.

Next steps

Last updated:

On this page