Replay EVM events without duplicate side effects
Build an idempotent EVM event consumer with a block-hash cursor, transactional outbox, and a replay drill for retries and chain reorganizations.
Treat RPC log delivery as repeatable. Store each observed log by chain, block hash, and log index; write the event and its checkpoint together; and give each outbound action a stable idempotency key. Only send irreversible actions after your chosen finality boundary. This lets you recover an RPC window without sending the same webhook, credit, or alert twice.
Use separate keys for the log and its effect
An RPC log is an observation tied to a particular block. A downstream action is a decision your application makes from that observation. Keep their identities separate. On Base or Ethereum, use the tuple (chainId, blockHash, logIndex) for the observation. Normalize hex quantities before creating the key; store the full hash and the block number for ancestry checks.
Using only a transaction hash loses events when a single transaction emits more than one log. Using only a height and index merges two different blocks at the same height. A transaction can also be included again after a reorganization, so the fact that you have seen its hash does not mean that its old block is still canonical.
Choose the action key based on what the receiver should consider the same operation. A finalized chain event can use the finalized observation key plus the action type. If your application models a business operation such as a deposit, prefer a contract-emitted deposit identifier scoped to the chain and contract. That preserves the intended identity even when two different observations refer to the same deposit.
Commit the checkpoint and outbox in one transaction
Start with a bounded eth_getLogs query using the address and event topic you actually consume. For a concrete rehearsal, get an endpoint from OnFinality’s Base RPC page or Ethereum RPC page, then check eth_chainId before reading any data. The Ethereum JSON-RPC reference defines the filters and the fields returned in each log.
Verify that a candidate batch belongs to the chain you are following. At minimum retain the last accepted height and block hash, compare the hash again when resuming, and ensure your scan advances along the expected ancestry. For a tentative tip, also retain the hash history needed to find a common ancestor. A range request alone does not make a set of RPC responses an atomic snapshot.
- In one database transaction, upsert the observations, update the application’s derived state, enqueue eligible effects with a unique action key, and save the checkpoint. If the transaction fails, retry the same block range.
- For blocks near the tip, stage changes or make them reversible. Only promote effects once they meet your finality policy. Verify how your network and endpoint implement the
safeandfinalizedblock tags before relying on either. - Dispatch outbox rows separately. If a receiver commits an action but the acknowledgement is lost, resend with the same idempotency key. A unique outbox row alone cannot prevent the receiver from acting twice.
Run a replay drill before wiring the webhook
Save this as replay-drill.mjs and run node replay-drill.mjs. It uses synthetic logs and an in-memory store to isolate key behavior. In production, replace the maps with unique constraints and transactions, and obtain finality from your chain adapter instead of passing a boolean from untrusted input.
import assert from "node:assert/strict";
const observed = new Map();
const outbox = new Set();
function apply({ chainId, blockHash, logIndex }, finalized) {
const key = [BigInt(chainId), blockHash.toLowerCase(), BigInt(logIndex)].join(":");
observed.set(key, { finalized: observed.get(key)?.finalized || finalized });
if (finalized) outbox.add("webhook:" + key);
}
const oldFork = { chainId: 8453, blockHash: "0xaaa", logIndex: "0x01" };
const replacement = { ...oldFork, blockHash: "0xbbb" };
apply(oldFork, false); // observed before the reorg
apply(replacement, false); // replacement at the same height
apply(replacement, true); // promoted after finality
apply({ ...replacement, logIndex: "0x1" }, true); // replay after restart
assert.equal(observed.size, 2);
assert.deepEqual([...outbox], ["webhook:8453:0xbbb:1"]);
console.log("Replay creates one effect for the finalized observation.");Extend the drill to cover an empty block, two logs from the same transaction, and a crash after a webhook was accepted but before the dispatcher saved its acknowledgement. The empty block must advance the checkpoint; the two logs must remain distinct; the retry must reuse the same action key.
Recover from a mismatch without guessing the depth
When the saved block hash no longer matches the canonical hash at that height, pause promotion. Find the latest stored hash that is still canonical, invalidate tentative observations above that point, reverse or rebuild their derived state, and rescan the replacement chain. If the mismatch reaches further back than your retained ancestry, stop and resume from a trusted checkpoint; moving the cursor forward would hide the missing interval.
A WebSocket subscription can deliver a removal notice, but a disconnected consumer may miss it. Use the subscription as a low-latency signal and reconcile against HTTP on startup and after reconnecting. Track the scanned height separately from the highest height safe for irreversible actions so a fast tip never becomes a proxy for correctness.
The OnFinality guide to Ethereum reorg detection covers how to recognize the chain change. Before starting a replay against a new environment, use the deployment verification checklist to make sure the contract address and chain in your log filter agree with the release record.