ERC20 Activity Tracker
A drop-in, concurrent-safe Go service that tells you which tokens are “alive” on-chain right now—without hammering your node.
Why is this useful?
| Challenge |
How the tracker helps |
| Discovering fresh tokens — Traditional token lists are static; brand-new contracts don’t show up for hours (or ever). |
As soon as a contract emits its first Transfer event, the tracker records it in memory—no manual curation required. |
| Avoiding noisy polling — Re-querying every block for every token burns RPC calls and slows your app. |
Uses a bloom-filter pre-check to skip blocks that cannot contain ERC-20 transfers, then fetches logs only when needed. |
| Concurrent access — Dashboards, bots, and alerting daemons all want the same data. |
ActiveTokens() is read-locked (sync.RWMutex) so thousands of goroutines can query the list without blocking each other. |
| Graceful resource cleanup — Long-running services leak goroutines if shutdown logic is sloppy. |
Every worker (blockProcessor, pruner) respects the parent context.Context; cancelling the context stops them cleanly. |
| Stale-token buildup — Dead projects clutter analytics and UI over time. |
A background pruner removes tokens that haven’t moved in N minutes (configurable). |
Integrations & testing — Tight coupling to one client (e.g., ethclient) makes mocking hard. |
All external dependencies are function types (LogFetcher, BloomTestFunc) injected at startup—mock or swap at will. |
Use cases include:
- Portfolio dashboards that surface “hot” assets automatically
- Trading/arbitrage bots that filter for tokens with recent flow
- Whale-alert or analytics feeds that trigger on first activity
Features
- Real-time activity tracking – listens to the live block stream, flags every contract that fires a
Transfer.
- Bloom-filter shortcut – skips blocks that cannot contain ERC-20 transfers before any RPC call is made, reducing load on your node.
- Thread-safe & race-free – proven with
go test -race.
- Configurable “freshness” window – treat tokens as stale after X minutes, prune every Y minutes.
- Pluggable logging – drop in any
slog.Handler or custom Logger implementation.
- Graceful shutdown – one
context.CancelFunc stops the world without leaks.
Installation
go get github.com/your-repo/erc20activity # replace with your actual module path
Quick Start
Below is a minimal example that wires the tracker into a running program. Replace the mock implementations with calls to your own Ethereum client (e.g., ethclient.Client from go-ethereum).
package main
import (
"context"
"fmt"
"log"
"math/big"
"time"
"github.com/ethereum/go-ethereum/core/types"
"github.com/your-repo/erc20activity" // update with your module path
)
// -----------------------------------------------------------------
// Mock plumbing – swap these with real blockchain calls in production.
// -----------------------------------------------------------------
var newBlockEventer = make(chan *types.Block) // incoming blocks
func logFetcher(ctx context.Context, blockNumber uint64) ([]types.Log, error) {
// return ethClient.FilterLogs(ctx, ethereum.FilterQuery{FromBlock: bn, ToBlock: bn})
fmt.Printf("Fetching logs for block %d\n", blockNumber)
return nil, nil
}
func bloomFilterTest(bloom types.Bloom) bool {
// return types.BloomLookup(bloom, erc20activity.ERC20TransferTopic)
return true
}
// -----------------------------------------------------------------
// Main
// -----------------------------------------------------------------
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg := &erc20activity.Config{
NewBlockEventer: newBlockEventer,
LogFetcher: logFetcher,
BloomFilterTest: bloomFilterTest,
TokenStaleDuration: 15 * time.Minute,
ExpiryCheckFrequency: 5 * time.Minute,
Logger: log.Default(),
}
tracker, err := erc20activity.NewTracker(ctx, cfg)
if err != nil {
log.Fatalf("tracker init: %v", err)
}
// Simulate blocks
go func() {
for i := uint64(0); ; i++ {
select {
case newBlockEventer <- types.NewBlockWithHeader(&types.Header{Number: big.NewInt(int64(i))}):
time.Sleep(10 * time.Second)
case <-ctx.Done():
return
}
}
}()
// Dump active tokens every 30 s
for {
select {
case <-time.After(30 * time.Second):
for _, t := range tracker.ActiveTokens() {
fmt.Println(t.Hex())
}
case <-ctx.Done():
return
}
}
}
Configuration
| Field |
Type |
Required |
Description |
NewBlockEventer |
<-chan *types.Block |
✔︎ |
Feed of newly mined blocks. |
LogFetcher |
func(context.Context, uint64) ([]types.Log, error) |
✔︎ |
Fetches logs for a specific block. |
BloomFilterTest |
func(types.Bloom) bool |
✔︎ |
Cheap bloom check to skip log fetch if no Transfer topics present. |
TokenStaleDuration |
time.Duration |
|
Token removed after this period without activity. |
ExpiryCheckFrequency |
time.Duration |
|
How often the pruner runs; 0 disables pruning. |
Logger |
Logger interface |
|
Defaults to a silent logger when nil. |
How it works under the hood
┌ newBlockEventer (chan *Block) ┐
│
▼
┌────── blockProcessor (1 goroutine) ──────┐
│ • bloom pre-filter │
│ • fetch logs only when necessary │
└──────────────────────────────────────────┘
│ writes
▼
┌────── lastSeen map (protected by RWMutex) ──────┐
│ tokenAddr → lastActivityTimestamp │
└────────────────────────────────────────────────┘
▲ │
│ reads ▼
ActiveTokens() pruner (every N min)
(many callers) – purge stale entries
Testing
go test -v -race ./...
Contributing
Pull requests and issues are warmly welcome. Please include:
- A clear description of the change or bug.
- Unit tests where reasonable.
go vet, go fmt, and go test -race passing locally.
License
MIT