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).
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: expression, trigger.* refers to the awaited event — its args and
envelope — not the run's original trigger. No other root (steps.*,
state.*, constants.*, item, ...) exists at match time, so bind concrete
values into the expression via the surrounding YAML where you can.
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,
manifest generation collects the (contract, event, network) triples across
all workflows (main steps, foreach bodies and finally: blocks) and
subscribes to any triple no declared event trigger already feeds the wait
matcher for. 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 — no trigger path delivers
head fires to the matcher. These wait-only subscriptions route decoded
events only to the wait matcher — they never create runs — and they
start at the latest block: they exist to catch future events while a run
is parked, never to backfill history. A contract declared on exactly one
network resolves for waits on any network: the subscription indexes that
single address on the awaited chain (the same fallback validate and the
engine apply).
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 (it would park forever — 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 — crash-resume is exact, not just journal-order. 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 — the point of a fan-out/wait/re-do saga), 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 — 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, like a long delay) — and inside
finally: blocks, where the step
parks and resumes but the run's terminal status never changes (finally stays
best-effort across restarts). Two caveats: on_timeout: goto is rejected
inside foreach/finally (there is 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.