Ethereum contract watcher with Redis cache invalidation.
Blockpulse listens to new blocks, detects transactions to your contracts, and instantly deletes the affected Redis keys β so your next request always hits the RPC with fresh data. Cache TTL can be infinite; invalidation is event-driven, not time-based.
Most backends cache contract reads with a 60-second TTL: stale for 60 seconds by design.
Blockpulse flips the model:
Without Blockpulse With Blockpulse
ββββββββββββββββββββββ ββββββββββββββββββββββββββββ
cache(balance, TTL=60s) cache(balance, TTL=β)
β stale for up to 60s β stale only between tx and next block
β RPC call every 60s regardless β RPC call only when state actually changed
| Scenario | 60s TTL | Blockpulse (TTL=0) |
|---|---|---|
| No activity, 1h | 60 RPC calls | 0 RPC calls |
| 1 tx per minute | 60 RPC calls | 1 RPC call |
| 10 tx per second | 60 RPC calls | 10 RPC calls |
Ethereum Node
(WebSocket / HTTP)
β
βΌ
βββββββββββββββ new block / tx
β Blockpulse β βββββββββββββββββββββββββββΊ Redis DEL
β watcher β (configured key patterns)
ββββββββ¬βββββββ
β
βΌ
βββββββββββββββ
β Health API β :3002
β REST + WS β /health Β· /api/call Β· /api/events Β· /api/batch
ββββββββ¬βββββββ
β
βΌ
ββββββββββββββββ
β Redis β TTL=0, invalidated on tx
ββββββββββββββββ
- π΄ Event-driven invalidation β Redis keys deleted on every matching transaction; no fixed TTL needed
- π Event & transaction indexing β stores decoded logs and tx history per contract in Redis
- π REST API β
/api/call,/api/batch,/api/events,/api/transactions,/api/eth/getBalance - π‘ Historical sync β backfills past events via Etherscan-compatible API on startup
- π Cache dependencies β invalidate contract B's keys whenever contract A changes
- π Event dependencies β re-index contract B's events when contract A triggers them (e.g. DEX swaps β token Transfer)
- π fullScan mode β scan every block for token transfers regardless of
toaddress (DEX-transferred tokens) - π― Pool ID filtering β index only specific Uniswap V4 pool IDs
- βοΈ Multi-chain β Ethereum, Polygon, Arbitrum, Base, or any EVM chain via
CHAIN_ID - π WebSocket health broadcast β real-time status stream on port
HEALTH_BROADCAST_PORT+1
cp .env.example .env
cp config/config.example.js config/config.js
# edit .env and config/config.js
docker compose up
# health: http://localhost:3002/healthnpm install
cp .env.example .env
cp config/config.example.js config/config.js
# edit .env and config/config.js
npm start| Variable | Required | Default | Description |
|---|---|---|---|
REDIS_URL |
β | β | Redis connection URL |
RPC_WS_URL |
β | β | WebSocket RPC endpoint |
RPC_HTTP_URL |
β | β | HTTP RPC endpoint |
CHAIN_ID |
β | 1 |
Chain ID (1=mainnet, 137=polygon, 42161=arbitrum, 8453=baseβ¦) |
ETHERSCAN_API_KEY |
β | β | For historical sync |
ETHERSCAN_API_URL |
β | etherscan.io | Etherscan-compatible API URL |
START_BLOCK |
β | 0 |
Starting block for historical sync |
HEALTH_BROADCAST_PORT |
β | 3002 |
HTTP API port (WS is port+1) |
LOG_LEVEL |
β | info |
debug / info / warn / error |
USE_WS |
β | β | Set to 1 to force WebSocket mode (default: HTTP polling) |
module.exports = {
contracts: [
{
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
name: "usdc",
abi: require("./abis/usdc.json"), // optional: enables /api/call + event decoding
events: ["Transfer", "Approval"], // optional: events to index
fullScan: false, // true = scan every block (for DEX-transferred tokens)
cacheKeys: [ // Redis patterns to delete on any tx to this contract
"myapp:balance:*",
"myapp:token:usdc:*"
]
}
],
// Invalidate contract B's keys whenever contract A changes
cacheDependencies: {
"myPool": ["usdc"]
},
// Re-index contract B's events whenever contract A changes
// (e.g. DEX swap triggers token Transfer on a different contract)
eventDependencies: {
"myPool": ["usdc"]
},
settings: {
blockConfirmations: 1, // wait N blocks before processing
cacheTTL: 0 // 0 = no TTL, pure event-driven invalidation
}
};All endpoints are on http://localhost:${HEALTH_BROADCAST_PORT} (default 3002).
| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness β always 200; includes Redis stats |
GET |
/ready |
Readiness β 503 if Redis not connected |
GET |
/api/contracts |
List all watched contracts |
| Method | Path | Description |
|---|---|---|
GET |
/api/call/:contract/:method?args=[...] |
Call a contract method, result cached in Redis |
GET |
/api/call/:contract/:method?noCache=1 |
Bypass cache (time-dependent methods) |
POST |
/api/batch |
Multiple calls in one request |
GET |
/api/eth/getBalance?address=0x... |
ETH balance, cached |
| Method | Path | Description |
|---|---|---|
GET |
/api/events/:address?event=Transfer&page=1&limit=25 |
Decoded event logs |
GET |
/api/transactions/:address?page=1&limit=25 |
Transaction history |
| Method | Path | Description |
|---|---|---|
POST |
/api/renewCache/:name |
Invalidate + re-index a contract |
POST |
/api/invalidateCache/:name |
Invalidate only (no re-index) |
POST |
/api/resync |
Full re-index from START_BLOCK |
curl -X POST http://localhost:3002/api/batch \
-H "Content-Type: application/json" \
-d '[
{"contract": "usdc", "method": "totalSupply"},
{"contract": "usdc", "method": "balanceOf", "args": ["0x..."]},
{"contract": "usdc", "method": "decimals", "noCache": false}
]'Connect to ws://localhost:3003 (port+1) to receive real-time health broadcasts:
const ws = new WebSocket('ws://localhost:3003');
ws.onmessage = (e) => console.log(JSON.parse(e.data));
// { status, blockNumber, watchedContracts, uptime, redis, ... }FROM node:20-alpine
# ...
HEALTHCHECK CMD node -e "require('http').get('http://localhost:${HEALTH_BROADCAST_PORT:-3002}/health', ...)"The container exposes both the HTTP API port and the WebSocket port (port+1). Mount your config/config.js as a volume or bake it into a custom image.
MIT β free for commercial use. See LICENSE.