Connect to Astar zkEVM RPC and Verify Your Chain Before You Index
A practical guide to hitting the Astar zkEVM JSON-RPC endpoint from Node.js, confirming chain ID and block height, handling failures, and understanding why RPC access is not the same as indexing.
You want to talk to Astar zkEVM from a script and confirm you are actually on the right chain before you build anything on top of it. The fastest answer: send a JSON-RPC eth_chainId call to an Astar zkEVM RPC endpoint, compare the returned hex chain ID to the documented value, then call eth_blockNumber to confirm the node is producing blocks. This guide shows a runnable Node.js 18+ example, the expected output, and how to fail loudly instead of silently querying the wrong network. It also draws the line between RPC access (reading live chain state) and indexing (querying historical, structured data) so you pick the right tool for the job.
Step 1: Verify the chain ID before anything else
Every JSON-RPC interaction starts with knowing which chain you are on. Astar zkEVM exposes a standard Ethereum-compatible JSON-RPC interface, so eth_chainId returns a hex-encoded chain ID. If your script assumes a chain and never checks, a misconfigured endpoint can send you querying a different network entirely, and every downstream result becomes quietly wrong.
You need an RPC endpoint URL from your provider. OnFinality lists Astar network details on its Astar network page, and its RPC Assistant helps you assemble the correct endpoint and request shape for the network you are targeting. Do not hardcode a URL you found in a random gist; take it from a source you trust and keep it in an environment variable.
eth_chainIdis the cheapest possible sanity check and should run first in any script.- Store the endpoint in an env var so you can swap networks without editing code.
- Compare against the chain ID documented for Astar zkEVM, not a value you guessed.
// verify-chain.mjs — Node.js 18+ (global fetch)
const RPC_URL = process.env.ASTAR_ZKEVM_RPC_URL;
const EXPECTED_CHAIN_ID = process.env.EXPECTED_CHAIN_ID; // hex string, e.g. "0x" + id
if (!RPC_URL) throw new Error("Set ASTAR_ZKEVM_RPC_URL");
if (!EXPECTED_CHAIN_ID) throw new Error("Set EXPECTED_CHAIN_ID");
async function rpc(method, params = []) {
const res = await fetch(RPC_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
if (!res.ok) throw new Error(`HTTP ${res.status} from RPC`);
const json = await res.json();
if (json.error) throw new Error(`RPC error: ${JSON.stringify(json.error)}`);
return json.result;
}
const chainId = await rpc("eth_chainId");
console.log("chainId:", chainId);
if (chainId.toLowerCase() !== EXPECTED_CHAIN_ID.toLowerCase()) {
throw new Error(`Wrong chain: got ${chainId}, expected ${EXPECTED_CHAIN_ID}`);
}
console.log("Chain verified.");Run it with ASTAR_ZKEVM_RPC_URL=... EXPECTED_CHAIN_ID=0x... node verify-chain.mjs. A passing run prints the chain ID and Chain verified.; a mismatch throws before you do any real work.
Take the expected chain ID from Astar's own documentation rather than copying a value from a tutorial, since chain IDs are network-specific and a stale value will make a correct endpoint look broken.
Step 2: Confirm the node is live with a block number
A correct chain ID does not prove the node is synced or serving fresh state. eth_blockNumber returns the latest block height as a hex string. Calling it twice a few seconds apart tells you whether the chain head is advancing, which is a cheap liveness signal you can build into a health check.
This is a live measurement, so do not trust any number quoted in an article, including this one. Measure it yourself: log the block number, wait, log it again, and compare. If it does not move over a reasonable window, treat the endpoint as unhealthy and fail over rather than retrying forever.
eth_blockNumberreturns hex; convert withparseInt(hex, 16)for display.- Two samples a few seconds apart are a better liveness check than one.
- Never hardcode an expected block height; it changes every block.
// block-health.mjs — Node.js 18+
const RPC_URL = process.env.ASTAR_ZKEVM_RPC_URL;
if (!RPC_URL) throw new Error("Set ASTAR_ZKEVM_RPC_URL");
async function rpc(method, params = []) {
const res = await fetch(RPC_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
if (!res.ok) throw new Error(`HTTP ${res.status} from RPC`);
const json = await res.json();
if (json.error) throw new Error(`RPC error: ${JSON.stringify(json.error)}`);
return json.result;
}
const first = parseInt(await rpc("eth_blockNumber"), 16);
console.log("block:", first);
await new Promise((r) => setTimeout(r, 5000));
const second = parseInt(await rpc("eth_blockNumber"), 16);
console.log("block:", second);
if (second <= first) {
throw new Error(`Head not advancing (${first} -> ${second}); endpoint may be stale`);
}
console.log(`Head advanced by ${second - first} block(s).`);Expected result looks like block: 1234567, then a slightly larger number, then Head advanced by N block(s). The exact heights are irrelevant; the movement is the signal.
If the head does not advance, check your endpoint configuration and provider status before assuming the chain is down. A single endpoint failing is not evidence about the network.
Step 3: Handle failures like a production client
JSON-RPC failures come in several shapes: transport errors (connection refused, DNS, timeouts), HTTP errors (401 for a bad key, 429 for rate limiting), and protocol errors returned in the error field with HTTP 200. A robust client distinguishes these so you can retry the right things and alert on the rest.
Retrying a 401 or a chain-ID mismatch is pointless; retrying a transient timeout or 429 with backoff is reasonable. Keep the classification explicit so future-you does not debug a silent loop.
- Treat HTTP 200 with an
errorobject as a failure, not a success. - Retry transient transport errors and 429 with exponential backoff and a cap.
- Do not retry auth failures or chain-ID mismatches; surface them immediately.
- Log the method name and endpoint host (not the full URL with keys) on failure.
// resilient-rpc.mjs — Node.js 18+
const RPC_URL = process.env.ASTAR_ZKEVM_RPC_URL;
if (!RPC_URL) throw new Error("Set ASTAR_ZKEVM_RPC_URL");
class RpcError extends Error {
constructor(message, { retryable }) {
super(message);
this.retryable = retryable;
}
}
async function rpcOnce(method, params) {
let res;
try {
res = await fetch(RPC_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
throw new RpcError(`transport: ${err.message}`, { retryable: true });
}
if (res.status === 429 || res.status >= 500) {
throw new RpcError(`HTTP ${res.status}`, { retryable: true });
}
if (!res.ok) {
throw new RpcError(`HTTP ${res.status}`, { retryable: false });
}
const json = await res.json();
if (json.error) {
throw new RpcError(`rpc: ${JSON.stringify(json.error)}`, { retryable: false });
}
return json.result;
}
async function rpc(method, params = [], attempts = 4) {
let delay = 500;
for (let i = 0; i < attempts; i++) {
try {
return await rpcOnce(method, params);
} catch (err) {
if (!err.retryable || i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 2, 8000);
}
}
}
console.log("chainId:", await rpc("eth_chainId"));
console.log("block:", parseInt(await rpc("eth_blockNumber"), 16));Expected result: the chain ID and a block height print once. On a transient failure you see retries internally; on a permanent failure the script exits with a clear message.
If you need to compare providers or endpoints, measure latency and error rates yourself with a small script like this rather than relying on published numbers. The OnFinality Learn section has background material on RPC concepts if you want to go deeper.
RPC access is not indexing
A JSON-RPC endpoint answers questions about current state and lets you read specific blocks, transactions, and logs. That is not the same as an indexer. If you need to answer questions like "every transfer to this address last month" or "all events emitted by this contract matching a filter," you are describing a query over historical data, and doing that with raw RPC calls means paginating block ranges yourself, handling reorgs, and storing results.
An indexing layer ingests blocks into a structured store you can query with filters and aggregations. SubQuery is built for exactly that pattern on EVM-compatible chains. The practical rule: use RPC for live reads, transaction submission, and small targeted lookups; use an indexer for historical, filtered, or aggregated queries. Mixing them up is the most common source of slow, fragile scripts.
If you are unsure which you need, start with the concrete question you want to answer. "What is the current balance?" is RPC. "What did this address do over time?" is indexing.
- RPC: current state, single blocks, targeted reads, sending transactions.
- Indexing: historical queries, filtered event streams, aggregations across many blocks.
- Raw RPC over long block ranges means you own pagination, reorg handling, and storage.
- Pick the tool from the question, not from habit.
# A single targeted RPC read: fetch one block by number
curl -s "$ASTAR_ZKEVM_RPC_URL" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["latest",false]}' \
| head -c 400Expected result: a JSON object with result.number, result.hash, and other block fields. This is a point read, not a query over history.
For anything that spans many blocks with filters, reach for an indexer instead of looping this call. The RPC Assistant is a good next step for shaping correct RPC requests, and the Astar network page is where to confirm network details before you point a client at it.
Where to go next
You now have a script that verifies the chain, checks liveness, and fails clearly. From here, the useful next moves depend on what you are building. If you are wiring up a dapp, keep the chain-ID check in your startup path so a bad config never reaches users. If you are building analytics or a data API, design your indexing schema before writing more RPC code.
To keep learning without guesswork, browse the OnFinality Learn guides, and use the RPC Assistant when you need to confirm the right method and parameters for a request. Confirm network specifics on the Astar network page rather than trusting a copied endpoint.
- Keep the chain-ID assertion in every service that talks to the chain.
- Measure latency and error rates yourself before making performance claims.
- Separate live RPC reads from historical indexing in your architecture.