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 workflowstate_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: 12withrun_on: confirmed, so a reorgedOrderOpenednever pays out; the awaitedOrderSettledalso settles at depth12. - Simulation โ
assert_sim: ["${{ sim.ok }}"]pre-flight simulates the settlement before broadcast. - Gas caps โ
limit_from_simulation+max_priceabort on a gas spike. - Recheck โ
recheck:drops the settlement if the order's ownfillDeadlinepassed while the run got to broadcast. - Budget โ
settlement-hourlycaps cumulative destination payouts at20 ether/hour across every run (durable, race-safe). Add anapproval:gate on thesettlestep for large orders if a human should sign off. - Compensate, don't double-pay โ a timeout routes to a
compensatestep that pages a human rather than auto-retrying;on_failure: dead_letterjournals anything that fails.
Production checklist
- replace the
Escrowaddress and wire your realOrderOpened/OrderSettledevents (rflow abi fetch) - decide the wait-matching strategy (literal pin vs
state:cross-reference) for your escrow's event shape - replace the raw dev mnemonic with a production signer; fund the
settleron the destination chain - rehearse the saga deterministically:
rflow test cross-chain-settlement --with-waits โฆ(see backtesting)