Chain Monitoring
The Web3 variant includes a blockchain event monitoring system that polls for on-chain events and processes them into user notifications. This is how the app stays in sync with contract activity without requiring users to refresh.
How It Works
The chain monitoring system has three layers:
watchChainworker job — A cron-scheduled background job that polls the blockchain for new events- Chain filter modules — Event handlers that define which events to watch and how to process them
- Notification delivery — Processed events become user notifications delivered via the existing notification system
Blockchain → watchChain job → Chain Filters → Notifications → WebSocket → User
watchChain Worker Job
The watchChain job runs on a cron schedule (every 3 seconds by default). Each execution:
- Gets the current block number from the chain
- Loads the last processed block from the database (
settingstable) - Calculates the block range to process (capped at 500 blocks per run to avoid timeout)
- Runs each chain filter module against the block range
- Updates the last processed block in the database
On first run, the watcher starts from the current block (in production) or block 1 (in test mode). This means it only processes events that occur after deployment — historical events are not backfilled.
Block Range Limiting
To prevent long-running queries against the RPC endpoint, each run processes at most 500 blocks. If the app falls behind (e.g., after being offline), it catches up incrementally over multiple runs.
Persistence
The last processed block is stored per chain in the database. This means the watcher survives server restarts without reprocessing blocks or missing events.
Chain Filter Modules
Chain filters are modular event handlers registered in the watchChain job. Each filter module implements the ChainLogModule interface:
interface ChainLogModule {
getEvent: () => AbiEvent
getContractAddress: () => `0x${string}` | null
processLogs: (serverApp: ServerApp, log: Logger, logs: Log[]) => Promise<void>
}
getEvent()— Returns the ABI event definition to watch forgetContractAddress()— Returns a specific contract address to filter by, ornullto watch all contractsprocessLogs()— Handles matched events (e.g., creating notifications)
Built-in Filters
The Web3 variant includes two chain filter modules:
createToken
src/server/workers/chainFilters/createToken.ts watches for ERC20NewToken events from the factory contract:
event ERC20NewToken(
address indexed token,
string name,
string symbol,
address indexed creator,
uint256 initialSupply
)
When a new token is created, the filter:
- Extracts the creator address from the event
- Looks up the user by wallet address in the
userAuthtable - Creates a
TOKEN_CREATEDnotification for that user
In production, it only watches the configured factory contract address. In test mode, it watches all contracts to support test factories.
sendToken
src/server/workers/chainFilters/sendToken.ts watches for TokenTransferred events from SimpleERC20 contracts:
event TokenTransferred(
address indexed from,
address indexed to,
uint256 value,
string name,
string symbol,
uint8 decimals
)
When a token transfer occurs, the filter:
- Extracts the sender address from the event
- Looks up the user by wallet address
- Creates a
TOKEN_TRANSFERnotification with the formatted transfer amount
This filter watches all contract addresses since any SimpleERC20 token can emit these events.
Adding Custom Filters
To add a new chain filter:
- Create a module in
src/server/workers/chainFilters/:
import type { AbiEvent } from "viem"
import { parseAbiItem } from "viem"
import type { ChainLogModule } from "../jobs/types"
const MY_EVENT = parseAbiItem(
"event MyEvent(address indexed sender, uint256 value)"
)
export const getEvent: ChainLogModule["getEvent"] = () => {
return MY_EVENT as AbiEvent
}
export const getContractAddress: ChainLogModule["getContractAddress"] = () => {
return "0x1234..." as `0x${string}` // or null to watch all contracts
}
export const processLogs: ChainLogModule["processLogs"] = async (
serverApp, log, logs
) => {
for (const logEntry of logs) {
const { args: { sender, value } } = logEntry
// Process the event...
}
}
- Register it in
watchChain.ts:
import * as myFilter from "../chainFilters/myFilter"
const chainLogModules: ChainLogRegistry = {
sendToken: sendTokenFilter,
createToken: createTokenFilter,
myFilter: myFilter, // Add here
}
deployMulticall3 Job
The deployMulticall3 worker job ensures the Multicall3 contract is available on the development chain. It runs once on startup:
- Checks if Multicall3 is already deployed at the expected address
- If not deployed, funds the deterministic deployer address
- Sends the pre-signed deployment transaction
- Verifies the contract was deployed successfully
This is only necessary on local development chains (Anvil) where Multicall3 isn't available by default. On mainnet, testnets, and L2s, Multicall3 is already deployed at a well-known address.
Notification Flow
When a chain filter detects a relevant event, it creates a notification through serverApp.createNotification(). This:
- Inserts the notification into the
notificationsdatabase table - Sends a real-time WebSocket message to the user's connected browser sessions
- The frontend
NotificationsIndicatorupdates the unread badge count - The
NotificationsDialogshows the new notification with event-specific details
This means users see blockchain activity in real time without polling — the chain watcher polls the blockchain, and WebSockets push updates to the browser.