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

Cross-chain settlement saga

Recipe (no bundled template) ยท category: relayers ยท risk: money_moving

The canonical wait_for: saga: a user locks funds in an escrow on the source chain (OrderOpened); you settle the recipient on the destination chain; then the run parks durably until the source escrow releases your repayment (OrderSettled) โ€” or a timeout fires and you compensate instead of silently double-paying. The payout is simulated, gas-capped, recheck-guarded and behind a durable budget.

Closest bundled starting points: the ERC-7683 intent solver template (source event โ†’ destination fill, same reorg discipline) and the bridge message watcher (the state: cross-reference alternative to wait_for, described below).

When to use it

  • settle intents/orders where you pay on one chain and get repaid on another
  • any "act on chain A, wait for the confirming event on chain B, else compensate" flow that must survive restarts while it waits
  • bridge-and-continue flows that must not fire the second leg until the first is confirmed

The config

OrderOpened on the source chain triggers a native settlement on the destination chain; wait_for: then parks the run until the source escrow's OrderSettled (or 30m), routing a timeout to a compensate step.

# recipe: full
rflow_version: 1
name: cross-chain-settlement-saga
 
config:
  port: 3949
  db_connection: ${DATABASE_URL}
  max_concurrent_runs: 64
 
networks:
  # source: users lock funds in the escrow here
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
    confirmations: 12
  # destination: we settle to the recipient here
  - name: base
    chain_id: 8453
    rpc: ${BASE_RPC}
    confirmations: 6
 
# DEV ONLY raw mnemonic (from .env) - `rflow new` generates a fresh dev seed.
# Swap in a production signer before real funds ride on this config.
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}
 
relayers:
  # the settler wallet - pays recipients on the destination chain
  settler:
    networks: [base]
    speed: FAST
 
contracts:
  # the SOURCE-chain escrow: emits OrderOpened when a user locks funds and
  # OrderSettled when it releases our repayment after the settlement is proven
  Escrow:
    abi: ./abis/settlement.json
    addresses:
      ethereum: "0x1111111111111111111111111111111111111111"
 
budgets:
  # blast-radius cap on the destination payouts: at most 20 ETH/hour of
  # settlements no matter how many orders open
  settlement-hourly:
    window: 1h
    max_native_value: "20 ether"
    scope:
      relayers: [settler]
      networks: [base]
 
workflows:
  cross-chain-settlement:
    trigger:
      # a reorged open must never trigger a payout: fire only 12 blocks deep
      event:
        contract: Escrow
        name: OrderOpened
        network: ethereum
        confirmations: 12
        run_on: confirmed
        where: "${{ trigger.args.amount > wei('0', 18) }}"
    # one run per order id - concurrent opens of the same order are skipped
    concurrency:
      group: "settle-${{ trigger.args.orderId }}"
      on_conflict: skip
    budgets: [settlement-hourly]
    steps:
      # the money step, on the DESTINATION chain: pay the recipient natively.
      # Pre-flight simulated, gas-capped, dropped if the order's fill deadline
      # passed while we got here, then held until confirmed.
      - id: settle
        send_transaction:
          network: base
          relayer: settler
          to: "${{ trigger.args.recipient }}"
          value: "${{ trigger.args.amount }}"
          assert_sim:
            - "${{ sim.ok }}"
          gas:
            limit_from_simulation: true
            multiplier: 1.2
            max_price: "50 gwei"
          recheck: "${{ now() < trigger.args.fillDeadline }}"
          wait_for: confirmed
      # the async cross-chain leg: park - durably, for free - until the source
      # escrow releases our repayment, or 30m elapse and we compensate. Inside
      # the wait, trigger.* is the AWAITED OrderSettled event (see notes: pin a
      # concrete orderId literal when known, or cross-reference via state:).
      - id: release_wait
        wait_for:
          any:
            - event:
                contract: Escrow
                network: ethereum
                name: OrderSettled
                where: "${{ trigger.args.amount > wei('0', 18) }}"
                confirmations: 12
            - timeout: 30m
          on_timeout: { goto: { step: compensate } }
      - id: settled
        if: "${{ steps.release_wait.output.timed_out is not defined }}"
        notify:
          channel: ops
          message: "settled order ${{ trigger.args.orderId }} to ${{ trigger.args.recipient }} on base; source escrow released"
      # compensation: we paid on base but the source did not release in 30m.
      # Page a human - never auto-double-settle.
      - id: compensate
        if: "${{ steps.release_wait.output.timed_out is defined }}"
        notify:
          channel: ops
          message: "COMPENSATE order ${{ trigger.args.orderId }}: paid ${{ format_units(trigger.args.amount, 18) }} on base but source escrow did NOT release within 30m - investigate before any re-settle"
    on_failure: dead_letter
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}

Required env vars

DATABASE_URL, ETH_RPC, BASE_RPC, RAW_DANGEROUS_MNEMONIC, TG_BOT_TOKEN, TG_CHAT_ID.

How the wait matches โ€” read this

Inside a wait_for: event condition, trigger.* is the awaited event (here OrderSettled), not the run's original OrderOpened. No other root (steps.*, constants.*, โ€ฆ) is in scope at match time, so the where: can only test the awaited event's own fields against literals. This recipe scopes loosely (amount > 0) and leans on the timeout + the exactly-once settle step. Two ways to key the wait to this order precisely:

  • Pin a literal when the order id is known at deploy time: where: "${{ trigger.args.orderId == '0xโ€ฆ' }}".
  • Cross-reference via state: for fully dynamic matching โ€” the bridge message watcher pattern: a destination workflow state_sets a key per order id, and the waiting side gates on it. Use this when one settlement must resume exactly one run.

Safety notes โ€” money-moving

This workflow pays a recipient on every fire, so it carries the full money-path guard set:

  • Reorg discipline โ€” the trigger fires only at confirmations: 12 with run_on: confirmed, so a reorged OrderOpened never pays out; the awaited OrderSettled also settles at depth 12.
  • Simulation โ€” assert_sim: ["${{ sim.ok }}"] pre-flight simulates the settlement before broadcast.
  • Gas caps โ€” limit_from_simulation + max_price abort on a gas spike.
  • Recheck โ€” recheck: drops the settlement if the order's own fillDeadline passed while the run got to broadcast.
  • Budget โ€” settlement-hourly caps cumulative destination payouts at 20 ether/hour across every run (durable, race-safe). Add an approval: gate on the settle step for large orders if a human should sign off.
  • Compensate, don't double-pay โ€” a timeout routes to a compensate step that pages a human rather than auto-retrying; on_failure: dead_letter journals anything that fails.

Production checklist

  1. replace the Escrow address and wire your real OrderOpened / OrderSettled events (rflow abi fetch)
  2. decide the wait-matching strategy (literal pin vs state: cross-reference) for your escrow's event shape
  3. replace the raw dev mnemonic with a production signer; fund the settler on the destination chain
  4. rehearse the saga deterministically: rflow test cross-chain-settlement --with-waits โ€ฆ (see backtesting)