The Hook
I'd read about blockchains plenty. Reading an explorer, querying past data — that felt familiar, like querying any other database. Subscribing to a live node and reacting to blocks as they happen was a completely different feeling, and I didn't fully get why until I built it.
This week, for Web3 Shield (an open-source project I'm building to practice backend and security fundamentals — not because I'm an expert in any of this), I got a first real-time pipeline working. Sharing what I learned, not claiming I did it the "right" way.
Step 1: A Sandbox to Learn On
Before touching a real network, I forked Base (Coinbase's L2) locally using Anvil, from the Foundry toolchain. Real chain state, no gas cost, and — this mattered a lot for someone still learning — no fear of breaking anything real:
client, err := ethclient.Dial("ws://127.0.0.1:8545")
if err != nil {
log.Fatalf("🚨 Erro ao conectar no Anvil: %v", err)
}
Step 2: Learning the Difference Between Asking and Listening
My first instinct, honestly, would've been to poll — ask "any new blocks?" on a timer. Turns out there's a better way: subscribe, and let the node push updates to you.
headers := make(chan *types.Header)
sub, err := client.SubscribeNewHead(context.Background(), headers)
if err != nil {
log.Fatal(err)
}
SubscribeNewHead gives back a channel, and a new *types.Header shows up on it every time a block gets mined. No wasted requests, no guessing.
Step 3: The Part That Took Me a Minute to Understand
The subscription can die on you — node restarts, network hiccups — and I needed to know when that happens without spinning up a second thread just to watch for it. This is where Go's select finally clicked for me:
for {
select {
case err := <-sub.Err():
log.Fatal("🚨 Erro na inscrição:", err)
case header := <-headers:
block, err := client.BlockByHash(context.Background(), header.Hash())
if err != nil {
continue
}
// ...process block
}
}
One goroutine, one select, two channels. I'd read about this pattern before starting, but it didn't really mean anything until I watched it hold a live connection open and react to real blocks landing.
Step 4: Finding the Signal I Didn't Know Existed
For every transaction, I pull out the hash, the destination address, and something I'd never worked with before — the function signature: the first 4 bytes of the call data, which apparently tell you which contract function is being invoked, before the transaction is even confirmed.
toAddress := ""
if tx.To() != nil {
toAddress = tx.To().Hex()
}
txData := tx.Data()
funcSig := "0x00000000" // plain ETH transfer, no calldata
if len(txData) >= 4 {
funcSig = "0x" + hex.EncodeToString(txData[:4])
}
I didn't know this signal existed before this week. Now it's the whole reason this project might eventually be useful for something.
Step 5: Not Losing Data When Things Repeat
insertQuery := `
INSERT INTO raw_transactions (tx_hash, to_address, function_signature)
VALUES ($1, $2, $3)
ON CONFLICT (tx_hash) DO NOTHING;`
tx_hash is unique, and ON CONFLICT DO NOTHING means reprocessing a block twice isn't a crash — it's a no-op. Small thing, but it's the kind of detail I wouldn't have thought about before actually hitting it.
What I Deliberately Didn't Build Yet
No scoring, no "this looks suspicious" logic — this piece only captures data, correctly and without duplicating it. Deciding what's actually risky is a problem for a future post, once I've learned enough to attempt it.
What's Next
I'm going to try wiring in a Python service to read this raw data and actually attempt a risk score. I have no idea yet how much of my plan survives that.
Let's Discuss! 👇
If you've built something like this before — a live chain listener — what's the thing you wish someone had told you before you started? I'm early enough that I can still design around it.

Top comments (2)
The thing I wish someone had told me: your sub.Err() branch cannot call log.Fatal. A websocket drop is a Tuesday, not a crash — the node restarts, a load balancer recycles the connection. What broke my first listener wasn't the disconnect, it was restarting into a gap I couldn't close, because I never stored the last height I actually processed. Once that branch becomes reconnect-and-backfill, subscribe turns into an accelerator and polling by range becomes the floor that proves nothing was missed.
And ON CONFLICT DO NOTHING makes duplicates harmless, but a reorg isn't a duplicate — same tx_hash, now inside a block that no longer exists, so it quietly keeps the stale row. I now store block number and hash beside each transaction and treat a header whose parent isn't my last row as a rewind signal: delete back to the common ancestor, re-ingest.
Thank you so much, That didn't even cross my mind.