Bridge message watch
Template: bridge-message-watch ยท category: bridge-ops ยท risk: monitor_only
Delivery-liveness monitoring for a message bridge: watch MessageSent on the
source chain and MessageReceived on the destination chain, cross-referenced
by their shared nonce through rflow's durable
state. Every sent message starts a
durable delivery clock; when the clock runs out and no receipt marked the
nonce delivered, a channel gets paged. Two networks, no signer, no
transactions.
When to use it
- you operate (or depend on) a bridge/messenger and want to know about stuck messages before your users do
- watch a canonical rollup messenger, a token bridge's send/receive pair, or any cross-chain protocol with a nonce-correlated send/receive event pair
- as the alerting layer next to your relayer infrastructure: a page here means "the relayer/prover is behind"
Generate it
rflow new --template bridge-message-watch
# or into an existing project:
rflow add workflow bridge-message-watchNon-interactive (CI/agents):
rflow new --template bridge-message-watch --yes --output ./bridge-watch \
--answer source_bridge_address=0x8d3f4e2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e \
--answer dest_bridge_address=0x1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d \
--answer delivery_timeout=30mThe generated YAML
Two workflows share one contract registry entry (same name, one address per network):
# recipe: partial
workflows:
# source side: wait out the delivery window, then alert unless delivered
bridge-message-watch:
trigger:
event:
contract: Bridge
name: MessageSent
network: ethereum
confirmations: 12
start_block: latest
end_block: live
steps:
- id: window
delay: 30m # durable - survives restarts
- id: alert
if: "${{ state['bridge.delivered.' ~ trigger.args.nonce] is not defined }}"
notify:
channel: ops
message: "bridge message nonce ${{ trigger.args.nonce }} sent on ethereum (${{ trigger.tx_hash }}) has NOT been received on base within 30m"
on_failure: dead_letter
# destination side: mark each delivered nonce - the cross-reference
bridge-message-received:
trigger:
event:
contract: Bridge
name: MessageReceived
network: base
confirmations: 0
start_block: latest
end_block: live
steps:
- id: mark
state_set:
key: "bridge.delivered.${{ trigger.args.nonce }}"
value: "${{ trigger.tx_hash }}"
on_failure: dead_letterInputs
| key | type | default |
|---|---|---|
project_name | string | bridge-message-watch |
source_network / source_chain_id / source_rpc_env / source_rpc_url | network / chain_id / env_var / string | ethereum / 1 / ETH_RPC / a public RPC |
dest_network / dest_chain_id / dest_rpc_env / dest_rpc_url | network / chain_id / env_var / string | base / 8453 / BASE_RPC / a public RPC |
bridge_name | contract | Bridge |
source_bridge_address / dest_bridge_address | address | required |
confirmations | int | 12 โ depth before the delivery clock starts |
delivery_timeout | duration | 30m |
channel | string | ops |
Required env vars
DATABASE_URL, both RPC env vars (default ETH_RPC and BASE_RPC),
TG_BOT_TOKEN, TG_CHAT_ID โ all listed in the generated .env.example.
Safety notes
- Monitor-only: two networks, no
signer:โ the relayer engine never boots. - The
delay:is durable (persisted wake-at): a restart mid-window does not lose the watch, and the parked run holds no executor permit. confirmations: 12onMessageSentkeeps reorged sends from starting phantom clocks; receipts are marked at head (confirmations: 0) so a delivery is never counted late.- Both workflows end in
on_failure: dead_letterโ a failed mark or page is parked and journaled, never lost. - The packaged
abis/bridge.jsonis a generic ABI. Real bridges name their events differently โ swap in your bridge's ABI and adjust the twoname:fields (and thenoncearg);rflow validatechecks events against the ABI.
Run it locally
docker compose up -d # postgres on localhost:5448
# fill .env (both RPCs + telegram credentials)
rflow validate
rflow startFor a fast rehearsal on anvil: deploy any contract emitting the two events,
answer both networks to your fork, set delivery_timeout=30s, emit
MessageSent without the matching receive, and watch the alert land.
fixtures/message-sent-event.json documents the decoded trigger context the
check and the alert message render against.
Production checklist
-
delivery_timeout> the bridge's honest p99 delivery latency (including proving/challenge delays), or every slow-but-fine message pages -
confirmationsmatched to the source chain's reorg reality โ validate prints per-chain advice - destination RPC dependable: if the receive-side indexer stalls, unmarked nonces will page โ add indexer stall detection
- state is working memory (first-1000-keys cap on the
state.*root, no state-delete step): on a high-volume bridge, cross-reference through aquery:over the indexedmessage_receivedtable by nonce instead - pair with
workflow-error-pagerso a dead-lettered watch run itself pages
Common modifications
- query instead of state (unbounded volume): replace the
if:guard with aquery:step overrflow_indexer_rflow_bridge_message_received.message_receivedfiltered bynonce = $1, and assert on the count - page harder on misses: point the alert at a pagerduty channel
(
rflow add notification), keep telegram for receipts - watch both directions:
rflow add workflow bridge-message-watch --name reverse --answer source_network=base ...with the networks swapped - alert on failed deliveries too:
MessageReceivedcarriessuccessin the generic ABI โ add a second destination workflow withwhere: "${{ trigger.args.success == false }}"