Verify an EVM contract deployment with RPC

Catch wrong-chain deployments in CI by checking the RPC chain ID, transaction receipt, canonical block hash, and runtime bytecode before publishing an address.

Make deployment verification a release gate. Compare the RPC chain ID against the intended environment, require a successful creation receipt, confirm that its block is still canonical, and read the deployed code at that block. Only publish the address after those checks and your finality policy pass.

Make the expected chain part of the release record

Keep the expected chain ID, deployment transaction hash, and expected contract address in your deployment output. Resolve the endpoint separately from CI secrets so that changing the URL cannot silently redefine the expected chain. The same contract address can exist on multiple networks; neither a familiar hostname nor a nonempty eth_getCode response proves that you are on the right one.

Use OnFinality’s Base Sepolia RPC page for a rehearsal and its Base mainnet RPC page for a separate production configuration. Base Sepolia uses chain ID 84532 and Base mainnet uses 8453. The Base RPC setup guide in RPC Assistant lists both environments side by side. Keep private endpoint URLs in server-side environment variables and out of logs.

Check a top-level creation transaction in CI

Run this read-only script with Node.js 18 or later. Save it as verify-deploy.mjs, set RPC_URL, CHAIN_ID, DEPLOY_TX, and EXPECTED_ADDRESS in the job, then run node verify-deploy.mjs. It verifies a direct contract-creation transaction, where the receipt includes contractAddress. Deployments made through a factory require the separate check below.

JavaScript · Node.js
const { RPC_URL, CHAIN_ID, DEPLOY_TX, EXPECTED_ADDRESS } = process.env;
if (!RPC_URL || !CHAIN_ID ||
    !/^0x[0-9a-f]{64}$/i.test(DEPLOY_TX ?? "") ||
    !/^0x[0-9a-f]{40}$/i.test(EXPECTED_ADDRESS ?? "")) {
  throw new Error("Set RPC_URL, CHAIN_ID, DEPLOY_TX, and EXPECTED_ADDRESS");
}

async function rpc(method, params = []) {
  let response;
  try {
    response = 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 {
    throw new Error(method + " request failed or timed out");
  }
  if (!response.ok) throw new Error(method + " HTTP " + response.status);
  const payload = await response.json();
  if (payload.error) throw new Error(method + " RPC error " + payload.error.code);
  return payload.result;
}

const chain = await rpc("eth_chainId");
if (BigInt(chain) !== BigInt(CHAIN_ID)) throw new Error("Wrong chain");
const receipt = await rpc("eth_getTransactionReceipt", [DEPLOY_TX]);
if (!receipt) throw new Error("Receipt not available");
if (receipt.status !== "0x1") throw new Error("Deployment reverted");
if (receipt.contractAddress?.toLowerCase() !== EXPECTED_ADDRESS.toLowerCase()) {
  throw new Error("Unexpected deployment address");
}

async function checkCanonicalBlock() {
  const block = await rpc("eth_getBlockByNumber", [receipt.blockNumber, false]);
  if (block?.hash !== receipt.blockHash) throw new Error("Receipt block changed");
}
await checkCanonicalBlock();
const code = await rpc("eth_getCode", [receipt.contractAddress, receipt.blockNumber]);
if (!/^0x(?:[0-9a-f]{2})+$/i.test(code ?? "")) throw new Error("Missing runtime code");
await checkCanonicalBlock();
console.log("Verified deployment", receipt.contractAddress, "at", receipt.blockNumber);

The success line confirms that the expected chain returned a successful receipt, the recorded address, and nonempty code at the receipt’s block. It does not prove source-code equivalence or finality. To prove the deployed implementation, compare with approved runtime bytes or a verified hash and account for immutables, linked libraries, and compiler metadata.

Interpret each failure before retrying

  • Wrong chain: check environment variables and network selection. Do not copy the returned chain ID into the manifest to make CI pass.
  • Receipt not available: the deployment may still be pending, the endpoint may be lagging, or the transaction may be unknown. Poll for a bounded period and distinguish a missing receipt from a receipt with status 0x0.
  • Receipt block changed: the receipt is no longer anchored to the block you checked. Restart verification from the receipt and reapply the release’s finality policy. Where supported, an EIP-1898 block-hash selector can make the state query more precise; probing before and after a block-number query is a useful fallback, not an atomic read.
  • Missing runtime code: check that you are reading the intended block and contract. A provider error for old state is a capability failure, not evidence that the contract never existed.

Adapt the gate for factories, proxies, and finality

For a factory deployment, use a trusted event or deterministic address calculation, verify the emitting factory and event signature, and then check code at the derived address. A successful outer transaction can contain more than one creation, and receipt.contractAddress does not list internal deployments.

For a proxy, nonempty code at the proxy is only the first check. Verify the implementation address and relevant configuration at the same release boundary. Apply a finality rule suitable for your chain and application before publishing; a receipt at the latest head is not a permanent record.

See OnFinality Learn’s runtime bytecode guide for more detail on reading code. Once the deployment is verified, put the same chain ID and contract address into your EVM event replay configuration.