Ethereum Dev Hacks: Catching Hidden Transfers, Real-Time Events, and Multicalls
After spending months knee-deep in Ethereum internals, I started collecting a set of developer hacks that make life way easier. These are…
After spending months knee-deep in Ethereum internals, I started collecting a set of developer hacks that make life way easier. These are the tricks that don’t show up in docs but save you hours of debugging.
In this post, I’ll share a few of my favorites:
1. Understanding Events
2. Streaming On-Chain Activity in Real Time
3. Catching ETH Transfers Without Events
4. Batching Calls with Multicall
These are the kind of tools that make you feel like you’ve unlocked Ethereum x-ray vision.
Understanding Events
When most developers want to know what’s happening on EVM, they start with events (also called logs).
what events are:
Smart contracts can emit events during execution.
Events are not stored in contract storage they live in the transaction receipt logs.
Example: an ERC-20 token transfer Event:
event Transfer(address indexed from, address indexed to, uint256 value);
Each event has:
A topic list (indexed fields, used for filtering):
They are the search keys of an event. They allow you/node provider to filter logs without reading everything. Each event always has:
Topic[0] → the event signature hash, e.g:keccack256("Transfer(address,address,uint256)")
Topic[1..3] → up to three parameters marked asindexed
that are always stored as fixed 32-byte values. This means you can easily query for “all transfers from Alice” or “all swaps for a given pair” directly at the RPC layer.A data blob (unindexed fields, stored as raw bytes):
Everything not marked asindexed
gets packed into the data field
Data is not searchable you need to fetch the logs and decode them yourself. There’s no hard limit to how many non-indexed fields you can include, beyond gas.
curl -s -X POST https://polygon-amoy-bor-rpc.publicnode.com \
-H "Content-Type: application/json" \
--data '{
"jsonrpc":"2.0",
"method":"eth_getLogs",
"params":[{
"fromBlock":"0x182e86c",
"toBlock":"0x182e86c",
"address":"0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904",
"topics":[
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000007f8b1ca29f95274e06367b60fc4a539e4910fd0c"
]
}],
"id":1
}' | jq
Here we search for Transfer event on block 0x182e86c where 0x7f8b1ca29f95274e06367b60fc4a539e4910fd0c sent LINK token.
the response will look like:
{
"jsonrpc": "2.0",
"id": 1,
"result": [
{
"address": "0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000007f8b1ca29f95274e06367b60fc4a539e4910fd0c",
"0x0000000000000000000000002a51ae0ad42dc7d2eb89462a7d41e79502bcf697"
],
"data": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000",
"blockNumber": "0x182e86c",
"transactionHash": "0x330e48c4c3adcc17b0819b7bf7344bb5010beee59551713231e977508ee1b236",
"transactionIndex": "0x2",
"blockHash": "0xb48487df956cb9fd6cc9750e2438b03c99d146910a2a1159850712c38ee85681",
"logIndex": "0x3",
"removed": false
}
]
}
What we see here:address
→ the contract that emitted the event
Here:
0x0fd9…b1904
= LINK token on Polygon Amoy.
blockNumber
→ the block containing the tx (hex).
0x182e86c
=25356396
decimal.
transactionHash
→ hash of the tx that triggered this log.
Lets you look up the full transaction.
logIndex
→ position of this log within the block (events are ordered).
topics
→ the indexed parameters:
topics[0]
= event signature:0xddf252ad...
iskeccak256("Transfer(address,address,uint256)")
.topics[1]
=from
address, padded to 32 bytes:0x7f8b1c…fd0c
= the sender.topics[2]
=to
address, padded to 32 bytes:0x2a51ae…f697
= the recipient.
data
→ the non-indexed parameters (in this case, just value
)
0x...0de0b6b3a7640000
=1000000000000000000
in decimal = 1.0 LINK.
Event on the scanner will look like this:

Streaming On-Chain Activity in Real Time
Catching past logs with eth_getLogs
is great, but products feel alive when you stream changes as they happen. For that you’ll use WebSocket subscriptions:
Protocol:
eth_subscribe
over WS, not HTTP.What you get: a push stream of matching logs as soon as a tx is mined.
What to watch for: temporary disconnects, RPC restarts, and chain reorgs (logs can be marked
removed: true
).
Below is a Go snippet using go-ethereum
that:
Example: stream ERC‑20 Transfer
events from a token, optionally filtered by from
/to
package main
import (
"context"
"fmt"
"log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
func main() {
// Connect to Polygon Amoy WS endpoint
client, err := ethclient.Dial("wss://polygon-amoy-bor-rpc.publicnode.com")
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Transfer event signature
transferSig := common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
// Filter: all Transfer events from this token
query := ethereum.FilterQuery{
Addresses: []common.Address{
common.HexToAddress("0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904"), // LINK on Amoy
},
Topics: [][]common.Hash{
{transferSig},
{common.HexToHash("0x0000000000000000000000007f8b1ca29f95274e06367b60fc4a539e4910fd0c")}, // topic[1]: from
},
}
logs := make(chan types.Log)
// Subscribe to logs
sub, err := client.SubscribeFilterLogs(context.Background(), query, logs)
if err != nil {
log.Fatal(err)
}
fmt.Println("Listening for Transfer events...")
// Print events as they come in
for {
select {
case err := <-sub.Err():
log.Fatal(err)
case vLog := <-logs:
fmt.Printf("New Transfer log in block %d, tx %s\n", vLog.BlockNumber, vLog.TxHash.Hex())
fmt.Println("Raw topics:", vLog.Topics)
fmt.Println("Data:", vLog.Data)
}
}
}
The live output will look like:
we can see here the indexed topics that include the method signature, from, to and the data blob field that contains the unindexed value.
Catching ETH Transfers Without Events
Events are great, but they’re not the whole story.
A contract can send native ETH with a simple CALL{value: ...}
and emit no logs at all. Unless the contract chooses to emit
, that movement won’t show up in eth_getLogs
. So if you only listen to events, you’ll miss:
Payouts from routers/bridges/treasuries to users
Refunds via
CALL{value}
Self‑destruct payouts (
SELFDESTRUCT
sending the contract’s balance)Any internal ETH moves hidden inside a larger transaction
There are two reliable ways to see ETH moving:
Top‑level ETH transfers (easy): look at the transaction itself
If an EOA sends ETH directly to to
, it’s visible without logs:
Read
tx.to
andtx.value
(wei).If the tx succeeds, that amount of ETH moved from
from
→to
.
Caveats:
If the tx reverts, nothing moved.
If
to
is a contract and it makes further payments, those are internal (see next).
Internal ETH transfers (the real deal): trace the execution
To see ETH moved inside a transaction (contract → user), you must parse the call tree. Use node tracing APIs (as we learned in the previous post).
You’re looking for calls with non‑zero value
:
curl -s -X POST <YOUR-BLOCKCHAIN-NODE-URL>\
-H "Content-Type: application/json" \
--data '{
"jsonrpc":"2.0",
"id":1,
"method":"trace_replayTransaction",
"params":[
"<tx-hash>",
["trace"]
]
}' \
| jq -r '.result.trace[]
| select(.action.value!="0x0")
| {type, from:.action.from, to:.action.to, value:.action.value}'
The output will look like this:
{
"type": "call",
"from": "<addr1>",
"to": "<addr2>",
"value": "0x22e92f1cfbaaacd5d"
}
{
"type": "call",
"from": "<addr3>",
"to": "<addr4>",
"value": "0x22e92f1cfbaaacd5d"
}
Walk the tree depth‑first; collect any object where
value != 0
.
Also you can handle:
"type": "SELFDESTRUCT"
→ has ato
andvalue
payout."type": "DELEGATECALL"
→ value is always 0 (it forwards context), so skip unless you track side‑effects(you can see inner call after delegation).
Note: You can just parse every “node” recursievly and see if value != 0.
Small pseudo-code example:
function collectEthTransfers(node):
if node.value > 0:
record(from=node.from, to=node.to, value=node.value, type=node.type)
for child in node.calls:
collectEthTransfers(child)
Batching Calls with Multicall
When your app needs lots of view calls: balances, allowances, symbols, pool reserves, doing them one‑by‑one means:
extra latency (N round‑trips),
higher provider bill (N requests),
more rate‑limit pain (bursts get throttled),
and inconsistent snapshots (results from slightly different blocks).
Multicall fixes all of that. It’s a tiny contract that executes many staticcall
s in one go and returns all results so you make one eth_call
, get N answers, all at the same block.
Why Multicall is a must have
Fewer RPCs → fewer rate‑limit hits + lower cost.
Lower latency → one round‑trip instead of many.
Consistent state → all reads at the same block (no “half-old” data).
How its done
You ABI‑encode each read (e.g., balanceOf(user)
), pack them into an array → call Multicall’s aggregate/tryAggregate
via one eth_call
→ decode each return blob.
Note: you can find Multicall deployments here
Example:
package main
import (
"context"
"fmt"
"log"
"math/big"
"strings"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
)
const multicallABI = `[
{
"inputs":[
{"internalType":"bool","name":"requireSuccess","type":"bool"},
{
"components":[
{"internalType":"address","name":"target","type":"address"},
{"internalType":"bytes","name":"callData","type":"bytes"}
],
"internalType":"struct Call[]",
"name":"calls",
"type":"tuple[]"
}
],
"name":"tryAggregate",
"outputs":[
{
"components":[
{"internalType":"bool","name":"success","type":"bool"},
{"internalType":"bytes","name":"returnData","type":"bytes"}
],
"internalType":"struct Result[]",
"name":"returnData",
"type":"tuple[]"
}
],
"stateMutability":"nonpayable",
"type":"function"
}
]`
const erc20ABI = `[
{"name":"balanceOf","type":"function","stateMutability":"view","inputs":[{"name":"owner","type":"address"}],"outputs":[{"type":"uint256"}]},
{"name":"symbol","type":"function","stateMutability":"view","inputs":[],"outputs":[{"type":"string"}]},
{"name":"decimals","type":"function","stateMutability":"view","inputs":[],"outputs":[{"type":"uint8"}]}
]`
var (
RPC = "https://polygon-amoy.drpc.org"
MULTICALL_ADDR = common.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11") // Multicall3
TOKEN_ADDR = common.HexToAddress("0x0Fd9e8d3aF1aaee056EB9e802c3A762a667b1904") // LINK on Amoy
USER = common.HexToAddress("0x7F8b1ca29F95274E06367b60fC4a539E4910FD0c")
)
func main() {
ctx := context.Background()
client, err := ethclient.Dial(RPC)
if err != nil {
log.Fatalf("dial rpc: %v", err)
}
mabi, err := abi.JSON(strings.NewReader(multicallABI))
if err != nil {
log.Fatalf("parse multicall abi: %v", err)
}
eabi, err := abi.JSON(strings.NewReader(erc20ABI))
if err != nil {
log.Fatalf("parse erc20 abi: %v", err)
}
// Build calldata for ERC20 reads
balData, err := eabi.Pack("balanceOf", USER)
if err != nil {
log.Fatalf("pack balanceOf: %v", err)
}
symData, err := eabi.Pack("symbol")
if err != nil {
log.Fatalf("pack symbol: %v", err)
}
decData, err := eabi.Pack("decimals")
if err != nil {
log.Fatalf("pack decimals: %v", err)
}
type Call struct {
Target common.Address
CallData []byte
}
calls := []Call{
{Target: TOKEN_ADDR, CallData: balData},
{Target: TOKEN_ADDR, CallData: symData},
{Target: TOKEN_ADDR, CallData: decData},
}
input, err := mabi.Pack("tryAggregate", false, calls)
if err != nil {
log.Fatalf("pack tryAggregate: %v", err)
}
// Single eth_call
msg := ethereum.CallMsg{To: &MULTICALL_ADDR, Data: input}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
log.Fatalf("CallContract (eth_call): %v", err)
}
// Decode results
var results []struct {
Success bool
ReturnData []byte
}
if err := mabi.UnpackIntoInterface(&results, "tryAggregate", out); err != nil {
log.Fatalf("unpack tryAggregate: %v", err)
}
if len(results) != 3 {
log.Fatalf("unexpected results len: %d", len(results))
}
var (
balance *big.Int
symbol string
decimals uint8
)
// 0: balanceOf
if results[0].Success {
vals, err := eabi.Unpack("balanceOf", results[0].ReturnData)
if err != nil {
log.Fatalf("unpack balanceOf: %v", err)
}
balance = vals[0].(*big.Int)
} else {
log.Printf("balanceOf failed")
}
// 1: symbol
if results[1].Success {
vals, err := eabi.Unpack("symbol", results[1].ReturnData)
if err != nil {
log.Fatalf("unpack symbol: %v", err)
}
symbol = vals[0].(string)
} else {
log.Printf("symbol failed")
}
// 2: decimals
if results[2].Success {
vals, err := eabi.Unpack("decimals", results[2].ReturnData)
if err != nil {
log.Fatalf("unpack decimals: %v", err)
}
decimals = vals[0].(uint8)
} else {
log.Printf("decimals failed")
}
fmt.Printf("Symbol: %s, Decimals: %d\n", symbol, decimals)
if balance != nil {
fmt.Printf("Balance(%s): %s\n", TOKEN_ADDR.Hex(), balance.String())
}
}
The result will be something like:
Symbol: LINK, Decimals: 18
Balance(0x0Fd9e8d3aF1aaee056EB9e802c3A762a667b1904): 38000000000000000000
JSON-RPC Batching
Some providers support sending an array of eth_*
requests in one HTTP call. This can be handy for things like fetching balances, block headers, or transaction receipts. But for eth_call
it’s usually not recommended. The node still executes each call separately, and you lose the guarantee that results come from the exact same block. If you care about consistent snapshots and rate-limit savings, Multicall on-chain is the better option.
Note: JSON-RPC batching helps reduce HTTP requests, but only Multicall guarantees a consistent snapshot across all calls at the same block.
Summary
With these hacks, you now have the building blocks to make your dashboards bulletproof, your alerts real-time, and your RPC usage efficient. Try one of these tricks in your next dApp you’ll feel the difference instantly.