Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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 durable state. Each sent message starts a durable delivery clock; if it runs out with no receipt marking 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 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-watch

Non-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=30m

The generated YAML

Two workflows share one contract registry entry (same name, one address per network):

# recipe: partial
rflow_version: 1
name: bridge-message-watch
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
# monitor-only: two networks, no signer - the relayer engine never boots
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
  - name: base
    chain_id: 8453
    rpc: ${BASE_RPC}
 
contracts:
  # one registry name, resolved per network - MessageSent is watched on
  # ethereum, MessageReceived on base
  Bridge:
    abi: ./abis/bridge.json
    addresses:
      ethereum: "0x8d3f4e2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e"
      base: "0x1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d"
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
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_letter

Inputs

keytypedefault
project_namestringbridge-message-watch
source_network / source_chain_id / source_rpc_env / source_rpc_urlnetwork / chain_id / env_var / stringethereum / 1 / ETH_RPC / a public RPC
dest_network / dest_chain_id / dest_rpc_env / dest_rpc_urlnetwork / chain_id / env_var / stringbase / 8453 / BASE_RPC / a public RPC
bridge_namecontractBridge
source_bridge_address / dest_bridge_addressaddressrequired
confirmationsint12 โ€” depth before the delivery clock starts
delivery_timeoutduration30m
channelstringops

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 and no signer:, so 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: 12 on MessageSent keeps 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.json is a generic ABI. Swap in your bridge's ABI and adjust the two name: fields (and the nonce arg); rflow validate checks events against the ABI.

Run it locally

docker compose up -d     # postgres on localhost:5448
# fill .env (both RPCs + telegram credentials)
rflow validate
rflow start

For 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.

Production checklist

  • delivery_timeout > the bridge's honest p99 delivery latency (including proving/challenge delays), or every slow-but-fine message pages
  • confirmations matched 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 a query: over the indexed message_received table by nonce instead
  • pair with workflow-error-pager so a dead-lettered watch run itself pages

Common modifications

  • query instead of state (unbounded volume): replace the if: guard with a query: step over rflow_wf_bridge_message_received.message_received filtered by nonce = $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: MessageReceived carries success in the generic ABI; add a second destination workflow with where: "${{ trigger.args.success == false }}"