Programmable Ethereum Transactions Without Smart Contracts.
Turn your manual transactions into programmable, composable, and reusable actions with Plug. No more tedious contract writing or one-off scripts. Just simple, powerful sentences at your fingertips powered by:
Historical native balances are the missing organ in every crypto wallet, portfolio tracker, and protocol dashboard, and the reason no one has ever shown you the truth about your money.
Imagine logging into any wallet, portfolio tracker, or protocol dashboard and seeing the actual financial story of your address. Every wei in. Every wei out. The block reward you earned that one time you ran a validator three years ago. The gas you've burned across every chain you've ever touched. The a contract sent you in the middle of an arbitrage you forgot about. The cost basis of every position. The realized and unrealized PnL on every native asset, computed from the actual onchain history rather than stitched together from whatever partial exports a tax tool could lay hands on.
That wallet doesn't exist, though the data and the math to build it do. The infrastructure to surface them didn't exist anywhere we could find, so we built that too. This post is the story of what we lived through, and what we wish someone had told us before we started.
Two weeks ago we wrote about tracking every balance and price across every chain. That post covered ERC-20 tokens, rebasing assets, prices in the long tail, and the brutal gap between cached state and live execution. We deliberately left native balances out. They are a different beast and they deserved their own post. isn't an ERC-20, even though the wallet UI puts it right next to them. Spec-compliant tokens like ERC-20, ERC-721, and ERC-1155 live inside a contract, emit a Transfer event on every move, and expose a standard ABI you can query for balances at any block. Native tokens have none of those affordances. There is no contract to call, no event to subscribe to, no ABI to query. The chain holds the balance directly, and everything hard about reconstructing history flows from that. The you sometimes see in token lists is a contract that wraps the native asset one-for-one precisely so it can be handled like every other token, which is why it fits into the same ERC-20 tooling. The native asset itself lives outside any contract at all.
Native balances matter so much because they are the denomination of the chain. Every gas payment, every protocol fee, every block reward, every internal transfer between contracts moves in or whatever native asset the chain uses. If you can't reconstruct the native side of an address's history, you can't reconstruct anything downstream. Cost basis, PnL, tax reporting, and yield attribution all fall apart the same way. The story of the wallet is incomplete, and every derived calculation inherits the incompleteness.
The whole journey started because we wanted complete understanding of the flow of assets on any Ethereum-based chain. We didn't want "good enough for the dashboard." We wanted every wei accounted for, every block, every chain. Along the way we learned that cost-basis calculation for any asset is monumental and impossible without the native side, that some chains are effectively ephemeral because no one you can pay retains their historical state, and that nearly all tax software is wrong for the same reason every wallet is wrong. The list goes on for hours. Instead of cataloguing every hurdle, we'll reframe to why they're worth getting past.
Once you have the native side, every other portfolio feature becomes possible for the first time.
Cost basis on native positions. Every you've ever received has a price at the moment of receipt, and every you've ever spent has a price at the moment of disposal. A complete history turns cost basis into a deterministic computation. Anything less leaves it as a guess nobody can audit.
Realized and unrealized PnL. The realized PnL on native trades is the difference between the cost basis at acquisition and the price at disposal, summed across every disposal event in the address's history. The unrealized PnL is the difference between cost basis on the current balance and the current market price. Both numbers require knowing every single inflow and outflow, including the ones that happened inside contract execution and never emitted an event.
Internal transactions surfaced as first-class events. When a smart contract sends you via a low-level call, you typically have no idea it happened unless you happen to be watching the trace. With historical native balances done right, every internal transfer becomes a line item in your activity feed. The flash loan repayment, the liquidation proceeds, the protocol fee distribution, the unwrap from : visible, attributed, and priced at the block they landed in.
Block rewards as their own asset class. If you have ever validated, run a node, or been the recipient of MEV proceeds, those rewards arrive without a transaction you initiated. They are credited to your address by the protocol itself. A wallet that surfaces these as a distinct category of inflow tells the user something about themselves they probably did not know they could see.
Gas as a real cost line. Every wallet shows you the gas you paid on the transaction you're about to send, but none of them shows you the lifetime gas cost of operating that address. Aggregated across every chain, every tx, every protocol interaction, gas is often the largest invisible line item in a user's onchain life. With complete native history, it becomes a number a wallet can finally put in front of you.
A continuous timeline of net worth. Every chart that every portfolio tracker has ever shown is a partial reconstruction. The honest version requires native balance at every block, multiplied by price at every block, summed across every asset and every chain. When you do that correctly, the shape of the line changes. What wallets show users today has the wrong shape, and it's been wrong for years.
The ledger sits open for anyone to read. Even in a future where privacy is the default, leaving these answers unreachable when the data is already public is not okay.
Before we walked into the rabbit hole, it is worth being honest about what we got for nothing.
Calling eth_getBalance for the current block returns the correct native balance for any address, right now, on any chain. One RPC call. Trivial. Every wallet does this and we did too. Pulling the address's transaction list from any indexer reconstructs the EOA-to-EOA transfers and the gas cost of every transaction the address sent, which covers a large fraction of casual users who never touch contracts. Sampling eth_getBalance at periodic block heights produces a coarse historical chart that is wrong between samples and tells you nothing about why the balance changed, but it draws a line and the line looks like a chart.
For a long time, this is where we stopped, and where every other team we've seen still stops. The coverage gap is invisible to most users because most users never compare their wallet's number to ground truth. They see the number, they believe the number, and the product ships on the number.
The moment we wanted anything more, we fell off the cliff. Cost basis, PnL, internal transactions, block rewards, attribution: all of it required the part that wasn't free. The rest of this post is what we found on the other side.
The villain of this entire post deserves a proper introduction.
When you send from one EOA to another, the chain emits a transaction. It has a from, a to, a value, and a place in the canonical transaction list of whatever block it landed in. Anyone with the most basic indexer can find it. The wallet on the receiving side knows it happened. This is the kind of money movement most wallets and dashboards are built around.
When a smart contract sends to your address in the middle of executing some other transaction, none of that is true. The movement doesn't get its own entry in the transaction list, and it doesn't emit a Transfer event, because Transfer is an ERC-20 concept and the native token isn't an ERC-20. The only record is buried inside the execution trace of the parent transaction, as a CALL opcode with a non-zero value. The chain's state reflects the transfer and your balance reflects the transfer, but there is no log to subscribe to and no transaction-list entry to scan. The money can move without anything announcing itself.
These are internal transactions, and they are everywhere. Unwrapping is one. Repaying a flash loan is one. A liquidation sending seized collateral back to a liquidator is one. Protocols sweeping fees to a treasury, routers refunding excess after a swap, multisigs paying contributors through a contract: internal transactions, invisible to anyone working off events alone. In any wallet that touches DeFi at all, internal transactions are most of the actual financial activity. A user who has never sent a single direct transfer can have hundreds of internal transactions changing their balance over the life of their address.
The only way to see them is to replay the trace of every transaction the address might have appeared in, and pull the call tree apart looking for value-bearing internal calls where the address is the recipient. That is what debug_traceTransaction and trace_block exist for. It is also why those methods are the most expensive thing a node can serve, why most providers refuse to expose them, and why the rest of this post is, in one form or another, the story of how we got our hands on traces and made them tractable.
If you remember nothing else from this post, remember this section. Every architectural decision below is downstream of one fact: the money that matters most moves without knocking, and the only way to hear it is to replay the room it walked through.
Our first instinct was to call eth_getBalance at known intervals and store the results. One call per address per sampled block, per chain. Storage and compute are both small. Every public RPC supports it. The resolution is whatever you can afford to poll.
It worked until the first user asked us why their balance changed between two samples and we had no answer, because the events that moved the balance were invisible at the granularity we had chosen. The naive approach is a chart with no transactions. A sequence of disconnected snapshots pretending to be one, and the moment a real user looks at it they can feel the lie.
Somewhere between the naive sampler and the trace infrastructure, a conversation with a teammate landed us on the trick everyone who spends enough time in RPC-land eventually finds. You write a small contract whose only job is to loop over an array of addresses and return every balance in one shot. You never deploy it. You pass the bytecode into eth_call as a state override, attaching it to an address that does not exist, and the node treats the override as if that contract had always been there at the block you asked about. One RPC call returns five thousand balances.
For current-block queries, the cost per balance collapses, the fan-out disappears, and you come back with every native balance across an entire user base in under a second. If you have never reached for state overrides, you should. They are one of the few places where Ethereum's ergonomics quietly beat what most people assume is possible.
Then we tried to use the same trick to backfill history and met the other side of the hack. At the tip, the archive node has state hot and materializing it for a call is cheap. At a block from two years ago, the node has to reconstruct state from the nearest checkpoint, and that cost does not shrink because you asked for many balances at once. You pay full state-reconstruction cost per historical block, and the batched call at each block becomes the cheap part of an expensive operation. The cost grew with the depth of history, the number of chains, and the number of addresses whose history we were backfilling. Each new user added a column to the matrix, each new chain added a grid, and every additional year of history added a row. The matrix was multiplying against itself, and the state-override trick that saved us at the tip was doing almost nothing for the shape of the problem.
It taught us the limit of RPC tricks. They affect the cost of a single call. They don't affect the cost of reconstructing a single block's worth of state, and historical data is fundamentally a per-block problem.
Once we realized historical queries are always part of a series, we tried to fill the source data lazily on first request. A user would ask for the historical chart of their wallet, and we would treat the request as the series query it actually was. We started backfilling everything we needed to answer the next ten requests, hoping the cache hit on the second one and amortized the pain.
It worked at one user. At a hundred it didn't. At ten thousand it became the kind of failure that makes you stare at your dashboards in a way your partner notices. The timing game pushes the cost from build-time to query-time, and the cost grows faster than the user base.
Every new user pays the full backfill cost the first time, and every subsequent user pays for whatever state has changed since the last backfill. The system is constantly behind itself, and the only way to catch up is to do exactly what we had been avoiding.
Trace every block where the address has activity. Pull debug_traceTransaction or trace_block, parse the call tree, find native transfers in or out, recompute the historical balance from the trace stream. Correct in theory. This is the approach the AI always suggests. So, we setup an autoresearch agent and tried it.
In practice, trace methods are the most expensive RPC operations a node can serve, and most providers know it. They disable traces entirely on public endpoints. The ones that offer trace access charge premium tier rates that scale with how much you actually want to know. On most L2s, traces are simply not available through any public endpoint at any price, because the providers have not built the infrastructure to serve them and have no economic incentive to start. The data exists on the nodes. Nobody will hand it to you at the volume you need.
Even when we had access, eager tracing was the wrong shape of the problem. We were paying full trace cost for every block, including the overwhelming majority of blocks where nothing relevant to the address had happened. We were spending money to learn that nothing happened. That is not a strategy that worked for us.
This was the moment we realized compute is slow and expensive and we needed to stop touching the node so often. We built a raw layer that ingests block headers, transactions, receipts, and logs without decoding anything upfront, just bytes flowing in and getting written down. Then we hammered it as hard as we wanted. The raw layer never had to ask the node for help, because the node had already given us everything we needed and we were just reading our own copy.
Decoding became a read-time concern. Only the addresses and events that someone actually queried got decoded, and only at the moment someone asked. Most data sitting on a node is data nobody will ever ask about. The cost of pre-decoding everything dwarfs the cost of decoding what is actually requested. Pushing the work to the read path and keeping the write path stupid simple and fast meant the node finally stopped being the bottleneck.
This unlocked scale, but only for the parts of history that emit events. Native transfers inside contract execution still required traces. The raw layer alone didn't give us internal transactions, and internal transactions were most of what we cared about. The bottleneck moved but didn't disappear.
So we ran our own archive nodes with trace indexing on every chain we supported, and we pulled trace data into a local store as fast as the nodes could produce it. Disk grew fast. Terabytes per chain. We bought more disk. We bought more disk. We bought more disk.
Eventually disk stopped being the binding constraint. CPU became it, because trace replays are expensive even when the data lives on the same machine doing the computing. We could store everything we wanted, but we couldn't compute on all of it at the speed users expected. The limit had moved again, and this time buying hardware wasn't going to move it back.
The first time we ran our own archive node from genesis, we believed peace was finally available to us. No rate limits, no provider weirdness, just our machines and the chain. The first few hundred thousand blocks flew by at thousands per second and we started to relax.
Then Ethereum hit September of 2016 and we met the Shanghai DoS era for the first time.
The Shanghai attacks exploited underpriced opcodes like EXTCODESIZE and SUICIDE, which let an attacker construct transactions that were nearly free to submit and extraordinarily expensive to execute. The network was spammed with them for weeks, until EIP-150 and EIP-158 corrected the pricing. Those blocks still exist as part of the canonical chain. Every archive node that claims to hold the full history has to replay them, and replaying them with traces enabled takes orders of magnitude longer than the blocks on either side. We watched our sync drop from thousands of blocks per second to a few dozen. Days passed for a region of history we had budgeted minutes for, the hardware grinding the whole time, the monitoring dashboards turning into a single accusatory red line.
Shanghai is the marquee example, but every chain has its own version. The DAO fork left two timelines and your indexer has to know which side it follows. Optimism Bedrock and Arbitrum Nitro were total overhauls of block production, so traces before and after those migrations have incompatible shapes and pre-migration history needs a separate replay path. Polygon needs both Heimdall and Bor running in lockstep to reconstruct anything, because the state is split across two binaries. BNB Chain's aggressive pruning means most "archive" providers quietly don't retain the history they advertise, and you only find out the day you query for a balance at a block they have discarded. Post-merge Ethereum introduced validator withdrawals as a new category of native inflow that doesn't appear in the transaction list at all, since withdrawals aren't transactions but system-level credits processed by the consensus layer. Block reward semantics changed at Constantinople, and again at the merge, when block rewards stopped existing and the entire native inflow model for proposers shifted to the beacon chain.
Finally, if the question was "do you have it," the answer was yes.
Then the first user asked for their complete native balance history. Our system started fetching. Forty-five seconds in, nothing had come back. We killed the query at ninety because nobody should stare at a spinner that long. Narrowing the range didn't help. Picking a smaller address didn't help. Switching to a younger chain didn't help. Nothing we tried got a response back in under twenty seconds, and anything over twenty seconds is effectively broken.
We spent a week blaming infrastructure. We added indexes, redesigned the warehouse schema, and moved to a columnar store tuned for analytical queries. The columnar store was genuinely fast on its own terms. It could scan a few billion rows in under a second when the access pattern was sequential and the filter was simple. The first analytical query took ninety seconds the first time it ran. The hundredth concurrent query took the whole cluster down. We kept adding nodes and tuning the query planner, kept hitting the same ceiling, and kept blaming the hardware under it.
The realization was that we had been solving the wrong problem the whole time. A user query has a shape, and the warehouse was never organized to produce that shape.
The shape of the query a user actually sends is something like this: "Every native inflow and outflow for this address, across every chain this address has ever touched, from genesis to now, with the price at each event, broken into categories (EOA transfer, internal call, block reward, gas burn, validator withdrawal), and rendered as a continuous net-worth line that updates as new blocks land."
In database terms that's scattered reads across millions of rows, filtered by address, ordered by time, joined with a separate price history, and categorized using information that only lives in the trace stream. And it has to come back in milliseconds, because the user expects the chart to paint as soon as they land on the page.
The warehouse we had built was shaped for ingestion. Keep the raw stream contiguous, partition by block, compress hard, make re-ingestion cheap. Those are the right goals for a write path and the wrong ones for a read. No amount of cluster capacity closes that kind of gap. Doubling the machines would have cut the first query to forty-five seconds and made it twice as expensive to wait.
The pivot was to admit that the read path and the write path want different things, and to stop trying to serve both from one system. Writes want to be dumb and fast; the bytes come in from the node, they go onto disk, we move on. Reads want to be narrow and already-arranged, with the work of assembling an answer done before the query arrives. Everything in between is a compromise, and the compromise always comes out of the read path, which slowly becomes a second-class citizen in a warehouse that was never built for it.
We decided to build two systems that shared only the raw bytes beneath them. The write side would stay as the raw layer we already had: ground truth, nothing decoded, nothing indexed beyond what the storage engine gave us for free. The read side would become its own thing, built from projections that ran at ingestion time and shaped around the questions a user actually asks. All the work that had been happening at query time moved to ingestion time, where we had patience and nobody was waiting.
We had built the pile. We had not built the path through it. So we started there.
The first question the read side had to answer was: what's the unit of information a user actually cares about?
The answer is the address. Every query a wallet makes is scoped to one: the address the user is logged in as, or the address they're inspecting. The shape of one address's activity is orders of magnitude smaller than the chain it lives on. A chain might produce a hundred million native-value events over its lifetime; a typical address participates in a few hundred. Every time we served a query by scanning the chain, we were reading a million rows to return four.
The move was to project the chain onto each address once, at ingestion time. For every address that ever appears as sender, recipient, or value-bearing party in a native event, we maintain a per-address sidecar: a chronologically ordered list of every native-value event that address participated in. The sidecar carries enough metadata to answer most balance-history questions without having to fall through to the raw layer.
A row holds the block number, transaction hash, event category, signed wei delta, and a pointer back into the raw layer in case we need to hydrate the full details at read time. The categories cover every way the native balance can move: EOA transfer, internal call, block reward, gas burn, validator withdrawal. The sidecar is append-only. When a new block lands in the raw layer, our ingestion pipeline scans its traces and receipts for native-value events, groups them by participating address, and appends one row to each affected sidecar. Nothing is updated, backfilled, or joined at write time.
The payoff is asymmetric. A user's full native history is already assembled, sorted, categorized, and ready to stream. Every piece of work that used to run at query time has already been done at ingestion time: cross-chain joins, category inference from trace calls, ordering across block heights. Answering a query becomes reading one sidecar rather than scanning the warehouse.
The cost is that we now have tens of millions of sidecars to keep in sync. Every block produces writes against many addresses at once, and the storage engine has to handle a wide fan-out of tiny files. We pay for that in write-side complexity and operational discipline. The sidecar is a projection of the raw layer, and the raw layer stays authoritative. If a sidecar ever has to be rebuilt from scratch, the raw layer is where it gets rebuilt from.
Sidecars made per-address queries cheap. Block-scoped queries were still a problem. When a new block landed and we needed to know which sidecars it affected, we had to scan the block's events. When a user asked about recent activity on a specific chain, we had to figure out which of those blocks actually had anything relevant for them. Sidecars are keyed by address, so answering "which addresses showed up in this block" requires reading the block back out, which is the expensive path we were trying to avoid.
The cheap answer to that second question is a bloom filter.
A bloom filter is a small probabilistic data structure with a narrow contract: given a key, it returns either "definitely not in the set" or "possibly in the set," in constant time, using a fixed amount of memory regardless of how large the underlying set grew. We build one per block at ingestion time, seeded with every address that appeared in that block: senders, recipients, value-bearing participants inside traces, validators receiving withdrawals. Once written, the filter never changes.
At query time, a lookup asks whether address X appeared in block N. The filter answers in microseconds. A no means we skip the block. No disk read, no sidecar consultation, nothing. A yes sends us to the address's sidecar to assemble the actual events.
The leverage is enormous. A typical chain produces tens of thousands of blocks per day; a typical address appears in a handful of them over its entire lifetime. A query that spans a few hundred thousand blocks gets reduced, in microseconds, to the few thousand that actually matter. Everything else has been discarded before we opened a file.
The false-positive rate is a tuning knob. We set it at about one percent per chain, which means one in a hundred "probably yes" answers is wrong and the sidecar lookup for that block comes back empty. That's a fine trade in exchange for microsecond-time filtering across millions of blocks. We could buy a lower rate by spending more memory on the filters, but we don't need to. The sidecar absorbs the cost of the occasional empty lookup, and the memory budget is better spent elsewhere.
With all three pieces in place, every query follows the same cascade.
A request arrives scoped to an address and usually to a time or block range. Bloom filters go first, eliminating the overwhelming majority of blocks in microseconds. What's left is a small set of candidate blocks where the address actually appears. For those, we read the address's sidecar, which already has the events in order with categories, signed wei deltas, and pointers back to the raw layer. Sometimes an event needs its full detail hydrated, because the user clicked into a specific transaction or the category needs to be re-derived. The pointer takes us back to the raw layer for that single row. Everything else is already assembled. The response comes back in milliseconds.
It's a strange thing to spend years convincing yourself the answer has to be exotic, and to find out the answer is three boring data structures used together with discipline. Bloom filters date to 1970. The raw layer is how every database used to work before we started gluing indexes onto them. Projecting records per entity is an OLTP pattern so standard it predates the acronym. None of the individual pieces is new. The new part is the discipline to stop making one system serve both the read path and the write path. That's what the rest of this post was us earning.
This is the only architecture we've found that gives correct, complete native balance histories at interactive latency. Every other path we tried ended up lying, timing out under load, or costing money nobody has.
If we were starting again, we would begin with the raw layer. Ingest blocks, transactions, receipts, logs, and traces as bytes, and don't decode them on write. The raw layer becomes the ground truth you trust, and everything else becomes a projection of it.
We would have treated trace acquisition as a first-class problem from the very first week. You either run archive nodes with trace indexing on every chain you support, or you contract with the small number of providers who will actually serve traces, or you do both. Pretending there's a third option costs you a year of partial implementations.
We would have built per-address sidecars from the start instead of trying to make the primary store also be the read path. The shape of "what does this address care about" is dramatically smaller than the shape of "the entire chain," and projecting the relevant slice early is what makes interactive latency possible at all. We would have layered bloom filters at the block level under everything, because the cheapest possible "is this block relevant" check applied before any decoding is the difference between scanning every block and scanning the few thousand that matter.
We would have kept the price layer separate and joined it to the balance layer at read time. Price methodologies change, and you don't want to rewrite balance history every time a price source corrects itself. We would have left cost basis as a derived view computed on demand from the inflow stream. Storing it commits you to recomputing every downstream value when methodology changes, and methodology will change.
And we would have surfaced internal transactions, block rewards, and gas as first-class categories from the very first version of the activity feed. The user wants one plain line item: "you received 0.04 as a block reward at block 18,492,031." Whether that line came from the transaction list or the trace stream is an implementation detail the wallet should hide.
This is the architecture we would build first, knowing what we know now. Others are possible, but every one we've seen that works has these same properties. If your design is missing any of them, you haven't finished thinking through the problem.
Every consumer crypto wallet, portfolio tracker, dashboard, and tax tool today shows the user a fraction of the truth about their money. Most users never notice. The ones who do usually conclude that crypto is just like that, and they're wrong. The data is on the chain. The infrastructure to surface it correctly is buildable. We know because we built it.
The reason it was worth two years of work, terabytes of disk per chain, archive nodes we still babysit, and an architecture that took us through every dead end above before the right one revealed itself: the wallet on the other side of all of it is the first wallet in this industry that tells the user the truth.
The Plug API exposes this directly. Historical native balances at any block, internal transactions surfaced as first-class events, block rewards categorized separately, gas attribution across every chain, cost basis and PnL computed from the actual onchain history. If you're building something that needs the full ledger instead of the partial reconstruction every other provider hands you, the documentation is interactive and waiting.
If you would rather walk the path yourself, you now have the map. Reach out when you hit a hard part. We have hit all of them, and we would much rather talk you through one than watch you lose a year to it.