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 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-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
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, 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: 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. Real bridges name their events differently โ€” 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 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
  • 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_indexer_rflow_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 }}"