Context
Everything interesting about an Ethereum transaction — is this a sandwich, is this liquidation about to be profitable, is gas about to spike — is decided in the window it spends pending, before a block confirms it. A block explorer only answers these questions after they've stopped mattering. Building a platform that operates in that window is a distributed-systems problem before it's a blockchain problem.
The problem
Real-time mempool decoding has to hold two constraints that pull against each other. The hot path needs to stay fast enough to keep up with mempool throughput, which argues for doing as little work per transaction as possible. But a meaningful fraction of that traffic arrives truncated or calling contracts with no published ABI, which argues for doing more work — fetching full calldata, running traces — to get a usable answer at all.
What we built
Thirteen independently deployable services, each owning one concern, talking only through a shared message-bus abstraction rather than to each other directly. Core subscribes to a node's pending-transaction feed and runs cheap sandwich and EIP-1559 heuristics inline. Decoder runs a waterfall — ABI decoding, then a Sourcify lookup, then DEX-swap and liquidation heuristics — and always publishes a result, flagging failure explicitly rather than dropping the transaction. Enricher is the expensive path, triggered only for transactions the decoder couldn't fully resolve from truncated calldata: it fetches full calldata and, where the node supports it, trace data, then re-queues for decoding. A fan-out of analytics services — gas-station, swap-service, bridge-tracker, liquidation-tracker, auction-tracker, sentiment-service — each consume the decoded stream independently for their own domain, and an API gateway aggregates all of it behind REST and server-sent events for the dashboard.
The message bus itself is swappable by design: RabbitMQ in production, an in-memory implementation for local development, selected by a single environment variable, so the full 13-service stack runs locally without a broker to stand up first.
Architectural decisions worth naming
Service boundaries enforced by convention, not just topology. No service imports another directly or calls it over HTTP except client-to-gateway traffic — every cross-service interaction goes through the shared library and the message bus, which keeps each service independently deployable and independently scalable under its own load profile.
Numeric precision as a platform-wide rule. Every monetary and gas value
travels as a string or BigInt across every service boundary, never a native
JS number — a constraint applied uniformly rather than left to each service's
own judgment, because a single float-precision slip in liquidation math is
worth more than the convenience of native numbers.
A self-improving decoder as infrastructure, not a manual process. Unknown function selectors feed a Model Context Protocol tool server and a periodic LLM-driven triage pass that can fetch ABIs, trigger traces and propose new classifications — behind dry-run and auto-apply gates, so decoder coverage compounds over time instead of depending on someone manually triaging a backlog.
Outcome
The platform decodes pending transactions through a graceful-degradation pipeline, isolates its expensive enrichment path to only the traffic that needs it, and improves its own decoder coverage on a closed loop — see Mempool Analyzer.
Lessons learned
Service boundaries are a scaling decision, not a style preference. Keeping every service reachable only through the bus meant gas-station, swap-service and the rest could each be scaled or restarted independently under real load, without any of them knowing the others exist.
Put the expensive path behind a gate, not in the default flow. Enrichment exists specifically so the hot path doesn't pay for every transaction's worst case — a pattern that generalizes to almost any pipeline with a long tail of harder inputs.
A swappable transport layer pays for itself in the dev loop. Being able to run the entire stack against an in-memory bus locally, with the same code path that talks to RabbitMQ in production, kept local iteration fast without a second implementation to maintain.