wait_for
Park the run durably until the first of several conditions. This is the cross-chain saga construct: send on chain A, wait for the matching event on chain B, continue (or compensate on timeout).
rflow_version: 1
name: bridge-saga
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
- name: base
chain_id: 8453
rpc: ${BASE_RPC}
# DEV ONLY raw mnemonic - swap in a production signer before real funds
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
payout:
networks: [base]
contracts:
Bridge:
abi: ./abis/bridge.json
addresses:
ethereum: "0x0000000000000000000000000000000000000000" # replace
BridgeReceiver:
abi: ./abis/bridge-receiver.json
addresses:
base: "0x0000000000000000000000000000000000000000" # replace
notifications:
channels:
ops:
console: {}
workflows:
bridge-and-sweep:
trigger:
event: { contract: Bridge, name: DepositInitiated, network: ethereum, confirmations: 20 }
steps:
- id: send-l2
send_transaction: { network: base, relayer: payout, ... }
- id: await-arrival
wait_for:
any:
- event:
contract: BridgeReceiver
network: base
name: DepositFinalized
where: "${{ trigger.args.depositId == 12345 }}"
- timeout: 30m
on_timeout: { goto: { step: compensate } } # or: fail | continue
- id: sweep
send_transaction: { ... }
- id: compensate
if: "${{ steps.await-arrival.output.timed_out is defined }}"
notify: { channel: ops, message: "bridge deposit did not arrive in 30m" }| Field | Required | Description |
|---|---|---|
any | ✅ | Conditions raced against each other (non-empty, validated): event: {...} and/or timeout: <duration> |
on_timeout | fail | What a winning timeout: does: fail | continue | goto: { step: <id> } |
The awaited event
any: - event: takes contract (registry name), network, name, an
optional where: and an optional confirmations:. Inside the wait's
where:, trigger.* refers to the awaited event, not the run's original
trigger; no other root (steps.*, state.*, constants.*, item, ...)
exists at match time, so bind concrete values via the surrounding YAML.
rflow validate enforces this: a where: referencing anything besides
trigger.* can never match, so it is a hard error when the wait has no
timeout: condition (the run would park forever) and an Advice when it has
one.
A match settles the step succeeded with the decoded event as
steps.<id>.output; on an on_timeout: continue/goto, the output is
{"timed_out": true}.
confirmations: — how deep before the wait settles
| Value | Behaviour |
|---|---|
| absent (default) | A confirmed delivery settles the wait immediately; a head delivery only ARMS it: the parked run then settles once the chain reaches the network's confirmations depth past the event |
0 | Settle the moment the event is first seen (head). rflow validate prints a reorg Advice when the workflow sends transactions |
N | The match is journaled (armed) and the wait settles once the event is N blocks deep (verified against the chain, like a send's confirmed(N)) |
finalized | Armed until the chain's finalized block reaches the event (rflow doctor probes finality support; validate advises on chains rflow has no guidance for) |
Internal wait subscriptions
Every wait_for: event condition gets its own indexer subscription: at boot,
the (contract, event, network) triples across all workflows (main steps,
foreach bodies, finally: blocks) are collected and any triple no declared
event trigger already feeds the matcher for gets subscribed. Only a trigger's
confirmed phase feeds the matcher (run_on: confirmed, the default, or
both); a head-only run_on: unconfirmed trigger covers nothing, and a
confirmations: 0 wait always gets its own head-indexing subscription.
Wait-only subscriptions route decoded events only to the matcher (they
never create runs) and start at the latest block: they catch future
events while a run is parked, never backfill. A contract declared on exactly
one network resolves for waits on any network: the subscription indexes
that single address on the awaited chain (validate and the engine apply the
same fallback).
rflow validate rejects impossible waits before runtime: an awaited event
missing from the contract's ABI (with a did-you-mean hint), an unknown
contract/network, a wait whose contract has no address on the awaited
network and no timeout: condition (with a timeout it degrades to an
Advice), and a where: that references anything besides trigger.* (same
error/Advice split).
Durability
Every event condition is persisted (rflow.run_event_waits, including its
confirmations requirement and any armed match) and the timeout deadline is
journaled before the run parks (waiting_event). All survive restarts,
and a parked saga costs nothing while it waits. The goto: decision is part
of the settle output, so a crash right after a timeout-goto resumes at the
same named step. A goto that re-enters a foreach: step starts a
fresh pass: the collection is re-evaluated and every iteration re-runs
with fresh attempt numbers (fresh send idempotency keys), while a plain
retry of a failed fan-out still reuses the pass's journaled plan and never
re-runs its settled iterations.
Placement
wait_for also runs inside foreach
iterations and finally: blocks. In
a foreach, each item parks its own <id>[<i>] journal row and wait rows,
resumes independently and settles exactly once (a parked item holds its
max_parallel slot). In a finally: block, the step parks and resumes but
the run's terminal status never changes. Two caveats: on_timeout: goto is
rejected inside foreach/finally (no step sequence to jump to), and the
match-time where: only sees trigger.*, so foreach items share their
conditions: one matching event settles every parked item it matches.
Reorg safety
Dry-run behaviour
In rflow replay / rflow test sessions, wait_for steps
settle immediately with a would_wait_for output: a historical event must
never resume a live parked saga, and a rehearsal must terminate
deterministically. --with-waits pins deterministic per-step outcomes
(matched event or timeout) for saga rehearsals; fixtures key on the config
step id, so foreach items and finally waits resolve through their base id.