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

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" }
FieldRequiredDescription
anyConditions raced against each other (non-empty, validated): event: {...} and/or timeout: <duration>
on_timeoutfailWhat 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

ValueBehaviour
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
0Settle the moment the event is first seen (head). rflow validate prints a reorg Advice when the workflow sends transactions
NThe 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))
finalizedArmed 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.