Crypto Training
Transaction Forensics with TX Graph: Reading Flash Loans, Routes, and MEV in the Receipt
Block explorers show what happened, not why it happened. This is a practical workflow for reconstructing intent using receipts, logs, and call graphs, with two mainnet transactions as examples.
If you audit protocols long enough, you stop treating “a transaction” as a single action.
Most interesting mainnet transactions are programs:
- borrow
- route
- manipulate
- repay
- settle
And most exploits are just “that same program, but your contract was the thing being routed through”.
This post is a workflow for:
- reading a transaction without getting hypnotized by noise
- extracting the security-relevant facts
- turning those facts into engineering mitigations
I will use TX Graph (a call-graph viewer) as a visualization tool, but the core method works with any explorer.
Receipt vs trace: know what you can and cannot see#
Two artifacts matter:
- transaction receipt: status, gas used, logs (events)
- execution trace: internal calls, value flow, reverts, subcalls
Most public data sources give you the receipt easily.
Traces require a node with tracing enabled or a third-party service.
When you do not have a trace, your job is to treat the receipt as a set of signals, not a full story.
Signals that reliably show “this is a complex program”:
- a huge number of logs
- many distinct contract addresses in logs
- repeated approvals (you are looking at a router)
- event bursts from pools (swaps / sync / mint / burn patterns)
- gas usage unusually high for the block era
Two mainnet examples#
Example TX Graph pages:
0x78921ce8d0361193b0d34bc76800ef4754ba9151a1837492f17c559f23771c430x569733b8016ef9418f0b6bde8c14224d9e759e79301499908ecbcd956a0651f5
Even without deep trace access, the receipts tell you a lot.
I pulled the basic stats via JSON-RPC:
| Tx | Block | Gas used | ETH value | Log count | Notable log addresses |
|---|---|---|---|---|---|
0x7892...71c43 | 24,027,660 | 13,728,787 | 0.01 ETH | 196 | USDC, DAI, Curve 3pool, many stables |
0x5697...651f5 | 24,273,362 | 4,709,593 | ~0 | 100 | USDC, WETH, Aave, Curve 3pool, router-like patterns |
The first transaction uses almost a full block worth of gas. That is often a “bundle-shaped” transaction: something the sender wanted to complete atomically, without being interrupted by other ordering.
The second transaction has large calldata and repeated approve selectors embedded inside. That pattern is typical for generalized routers that execute a sequence of calls.
Identify what you can identify (even without labels)#
Even if you cannot fully decode the internal trace, some addresses are canonical:
| Address | What it is | Why it matters in forensics |
|---|---|---|
0xA0b8...6eB48 | USDC | 6 decimals, ubiquitous in routes, frequent rounding edges |
0x6B17...71d0F | DAI | 18 decimals, stable routes, frequent in Curve pools |
0xC02a...6Cc2 | WETH | ETH legs, wraps/unwraps, liquidation settlement |
0xbEbc...Ff1c7 | Curve 3pool | stable routing hub; heavy log emitter |
0x8787...fa4e2 | Aave v3 Pool | liquidations, flash loans, debt operations |
So you can already infer:
- these transactions touch stable routing (Curve) heavily
- at least one of them likely uses a lending leg (Aave) for liquidity or settlement
That narrows your “archetype” hypotheses significantly.
A simple workflow that scales#
Step 1: identify “entry contracts”#
Start with:
toaddress- any address that appears in many logs
For the two examples:
- the stablecoins and Curve pool address show up in logs
- Aave’s pool address appears in the second tx’s log address set
Even if you cannot label every address, you can still reason about classes:
- “lending protocol”
- “stable swap pool”
- “router”
- “wrapped ETH”
Step 2: classify the transaction archetype#
Most of mainnet falls into a few archetypes:
| Archetype | Typical signals |
|---|---|
| liquidation | lending events + collateral transfers + repay pattern |
| flash loan arb | flashloan event + swap events + repay event |
| sandwich | victim swap + attacker swap + attacker back-run in same block |
| oracle manipulation | large pool swap + protocol call that reads price + reversal swap |
| governance/admin | role-gated events, upgrades, parameter changes |
Your job is to decide which bucket you are in, then verify.
In the second tx’s calldata, there is an embedded selector 0xe0232b42, which is a known signature for flashLoan(address,uint256,bytes) on multiple protocols.
That strongly suggests “flash-loan-shaped program”.
A warning about function selectors: 4byte is not a source of truth#
You can often decode a function selector via public databases (4byte).
But selector collisions exist.
For the first tx, the top-level selector 0x2f570a23 resolves to a generic test(bytes) entry in 4byte, which is obviously not the real meaning of a 13M gas mainnet transaction.
Treat selector decoding as:
- a hint
- not a fact
Facts come from:
- contract ABI verified on explorer
- trace decoding (function IDs + target code)
- consistent patterns across logs
Step 3: map “trust boundary crossings”#
For protocol security, the interesting part is not “they swapped”.
It is: where untrusted behavior crosses into accounting.
Trust boundaries include:
- external token calls
- oracle calls
- callbacks (hooks, ERC-777, receiver hooks)
- low-level calls where return data is assumed sane
Write them as edges:
protocol -> token.transferFromprotocol -> oracle.latestAnswerprotocol -> hook.beforeSwap
Then ask: what can that external party lie about?
Step 4: look for liveness hazards#
Even “non-exploit” transactions can teach you liveness hazards:
- DoS via revert (pool bricked if hook reverts)
- gas griefing (a single swap becomes too expensive)
- unbounded loops (state growth makes the path unusable)
High gas usage transactions are a red flag for any path your users need to rely on.
If the “common” path can be made to cost 10M gas, attackers can grief you even without stealing.
Decode events from topics (the receipt is richer than it looks)#
Receipt logs include topics. Topic0 is the event signature hash.
You do not need an ABI to recognize the most common patterns.
Here is a short cheat table:
| Topic0 signature | Event class | What it tells you |
|---|---|---|
0xddf252ad... | ERC-20 Transfer | token flow edges; identify routes and net direction |
0x8c5be1e5... | ERC-20 Approval | routers, allowance patterns, possible “approval spam” |
pool-specific Swap | AMM swap | legs of the route (where price moved) |
Practical trick:
- count how many Transfer logs exist per token
- identify tokens with unusually high event counts
- those are often the “routing backbone” assets
This works even when your trace is unavailable.
Using TX Graph productively (what to look at first)#
Tools like TX Graph are powerful because they show structure:
- which contracts called which other contracts
- how many branches exist
- where value and control flow concentrate
When you open a complex tx graph, do not start by zooming into every node.
Start with three questions:
- where is the root? (top-level
to) - where are the hubs? (nodes with many edges)
- where are the edges across trust boundaries? (token/oracle/hook calls)
Hubs are often:
- routers
- lending pools (flash loans, liquidations)
- stable swap pools (heavy routing)
Trust edges are where you derive security posture:
- “this contract can call me back”
- “this return value can be garbage”
- “this price can be manipulated”
Recognizing MEV structure from the outside#
Even without builder data, you can sometimes infer MEV involvement:
| Signal | Why it is MEV-shaped |
|---|---|
| gas extremely high | one-shot programs often run in bundles |
| dense stable routing | common in arb and liquidation settlement |
| repeated swap legs | typical of multi-hop routes and reversal legs |
| short-lived approvals | router-style programs (often searcher infra) |
This is not proof. But it is enough to change how you evaluate risk:
if MEV is plausible, assume the attacker can choose ordering.
That assumption should be visible in your protocol design (bounded slippage, TWAP windows, caps, circuit breakers).
Minimal script: count Transfer logs by token#
import https from "node:https";
const RPC = "https://ethereum.publicnode.com";
const TRANSFER_TOPIC0 =
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
async function rpc(method, params) {
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
return new Promise((resolve, reject) => {
const req = https.request(
RPC,
{ method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } },
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
const j = JSON.parse(data);
if (j.error) return reject(j.error);
resolve(j.result);
});
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
export async function transfersByToken(txHash) {
const rc = await rpc("eth_getTransactionReceipt", [txHash]);
const counts = new Map();
for (const log of rc.logs) {
if (!log.topics || log.topics.length === 0) continue;
if (String(log.topics[0]).toLowerCase() !== TRANSFER_TOPIC0) continue;
const addr = String(log.address).toLowerCase();
counts.set(addr, (counts.get(addr) || 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}
If you run that on the example transactions, you will see stablecoins and Curve-like assets dominate the Transfer counts, consistent with “route program” behavior.
Quick selector decoding (and why it sometimes misleads)#
If you want to decode selectors anyway, do it programmatically:
export async function selectorToCandidates(sel) {
const url = `https://www.4byte.directory/api/v1/signatures/?hex_signature=${sel}`;
const res = await fetch(url);
const j = await res.json();
return (j.results || []).map((r) => r.text_signature);
}
Then apply a sanity rule:
- if the candidates look generic (
test(bytes)), ignore them - if the target contract is known, prefer verified ABI
This prevents you from “hallucinating” a function name from a misleading database hit.
Minimal code: pull receipt signals yourself#
I strongly recommend you keep a tiny script that fetches:
- gas used
- log count
- distinct log addresses
- function selector (first 4 bytes of calldata)
That gets you 80% of the forensic value in 20 lines.
Here is a Node script you can paste and run:
import https from "node:https";
const RPC = "https://ethereum.publicnode.com";
async function rpc(method, params) {
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
return new Promise((resolve, reject) => {
const req = https.request(
RPC,
{ method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } },
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
const j = JSON.parse(data);
if (j.error) return reject(j.error);
resolve(j.result);
});
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
function hexToInt(h) {
return parseInt(h, 16);
}
function selector(input) {
return !input || input === "0x" ? null : input.slice(0, 10);
}
export async function summarize(txHash) {
const tx = await rpc("eth_getTransactionByHash", [txHash]);
const rc = await rpc("eth_getTransactionReceipt", [txHash]);
const logAddresses = [...new Set(rc.logs.map((l) => l.address.toLowerCase()))];
return {
tx: txHash,
block: hexToInt(tx.blockNumber),
to: tx.to,
selector: selector(tx.input),
gasUsed: hexToInt(rc.gasUsed),
logCount: rc.logs.length,
logAddresses: logAddresses.slice(0, 12)
};
}
Then you can build your own “receipt smell test” before you invest time in deep tracing.
Turning forensic insight into protocol defense#
Reading transactions is not just a hobby.
It changes how you write contracts.
Three defensive design moves that come directly from mainnet forensics:
1) Assume transactions are programs, not calls#
If your protocol assumes “a user calls swap() once”, you will lose to:
- routers that batch calls
- flash loan compositions
- MEV bundles that call you multiple times in the same block
Design invariants that hold across sequences.
2) Make “price reads” explicit and bounded#
If any path reads a price (spot or TWAP), make it a first-class parameter:
- document which oracle (spot, TWAP, Chainlink, virtual price)
- document the manipulation cost assumptions
- bound the impact with slippage checks / max deviation / pause on anomaly
3) Engineer for liveness#
Reverts and gas blowups are security failures.
If a hook can revert the pool, treat that as critical severity even if no funds are stolen.
A note on "from == to" (modern account behavior)#
One of the example transactions reports from == to at the top-level call, while still producing a rich execution and logs.
If you see patterns like that, treat it as a signal that you are not dealing with a simple human EOA + one contract call flow.
Modern account behaviors include:
- smart accounts that batch many actions
- routers that use unusual call shapes
- emerging delegation patterns (for EOAs acting like contracts)
Security implication:
If your protocol has any “onlyEOA” assumptions (directly or indirectly), mainnet will eventually violate them.
Further reading#
- TX Graph (call graph viewer): https://tx-graph-eight.vercel.app/
- Paradigm: "Ethereum is a Dark Forest" (MEV mindset): https://www.paradigm.xyz/2020/08/ethereum-is-a-dark-forest
- The EVM opcode reference for decoding patterns: https://www.evm.codes/