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

Reorgs & confirmations

Chains reorganize. rflow's stance: you choose the trade-off per trigger, rflow advises, and nothing is hidden.

The knob: confirmations

Every event trigger takes:

confirmations: 0           # fire at head (default)
confirmations: 12          # wait 12 blocks
confirmations: finalized   # wait for chain finality
  • 0 (default) — fire the moment the event is seen. Fastest possible reaction; the event may later be orphaned by a reorg.
  • N — fire once the event is N blocks deep. A reorg deeper than N can still orphan it, but the probability falls off fast.
  • finalized — fire only once the chain's finality gadget has sealed the block. On Ethereum this is ~2 epochs (~13 minutes); on L2s it follows the rollup's finality semantics. Orphaning a finalized block would mean a catastrophic chain failure.

Per-chain guidance

rflow validate prints this advice (and never blocks on it):

ChainSuggested depth
ethereum20
base / arbitrum / optimism24
polygon200
everything else12

Alert at head, pay at depth

The pattern that resolves most tension — in one workflow with run_on: both:

workflows:
  swap-watch:
    trigger:
      event:
        contract: AnyPool
        name: Swap
        network: ethereum
        confirmations: 20
        run_on: both          # fire at head AND at depth
    steps:
      - id: alert
        if: "${{ trigger.phase == 'unconfirmed' }}"
        notify: { channel: ops, message: "swap seen at head: ${{ trigger.tx_hash }}" }
      - id: mirror
        if: "${{ trigger.phase == 'confirmed' }}"
        send_transaction: { ... }

Each phase claims its own trigger key, so both fire exactly once for the same swap — one instantly, one when it is safe to move money. (Two separate workflows at different depths work identically, if you prefer the split.)

rflow validate warns specifically about the risky combination: a workflow that sends transactions from a head-fired event trigger. A reorg can orphan the triggering event after your transaction is already out — that transaction does not un-happen.

Actionable reorg responses — on_reorg

When a reorg orphans an event a workflow already ran on, the workflow's on_reorg: steps fire — the hook for alerts and compensations:

workflows:
  fast-mirror:
    trigger:
      event: { contract: USDC, name: Transfer, network: ethereum, confirmations: 0 }
    steps:
      - id: mirror
        send_transaction: { ... }
    on_reorg:
      - id: warn
        notify:
          channel: ops
          message: >-
            REORG: the deposit behind run ${{ reorg.run_id }}
            (tx ${{ reorg.tx_hash }}, block ${{ reorg.block_number }}) was orphaned
            after we already mirrored it — fork at ${{ reorg.fork_block }}.
      - id: compensate
        send_transaction: { ... }   # e.g. claw the mirrored funds back

on_reorg steps see the full expression context (the original trigger.* included) plus a reorg root:

PathDescription
reorg.tx_hash, reorg.block_number, reorg.networkThe orphaned trigger occurrence
reorg.run_id, reorg.workflowThe run that had already acted on it
reorg.fork_block, reorg.detection_blockThe reverted range the indexer reported

The semantics, stated precisely:

  • Exactly-once per (run, fork_block), crash-resumable. The response claim persists in rflow.reorg_runs before the first side effect, so a redelivered reorg notification can never double-fire a compensation. A deepening reorg detected in stages can't either: a re-detection whose [fork_block, detection_block] range overlaps an already-claimed handling's range is refused at the claim — one logical revert compensates once, no matter how many fork points the indexer reports for it. Only a distinct later reorg with a disjoint reverted range fires the response again. The response itself is a miniature journaled workflow: every step's row lands in rflow.reorg_step_runs before its side effect, and a crash mid-response leaves the claim running — the next boot's recovery pass resumes it through that journal. Completed steps never rerun, an interrupted delay wakes from its persisted wake time, and no step is silently dropped.
  • Compensation sends carry idempotency keys. A send_transaction inside on_reorg: journals rflow-reorg:{run_id}:{fork_block}:{step_id}:{attempt} before the relayer can see the transaction — the same discipline as normal sends. On resume an interrupted send is reconciled by that key: adopted if it landed, re-sent under a fresh attempt only if it provably never reached the relayer, and left for the next recovery pass when the relayer is unreachable — never re-sent on ambiguity.
  • Kept simple by validationapproval: gates, foreach:, and wait_for: are rejected inside on_reorg:; steps run sequentially with no retry policy, delays sleep inline (durably journaled), and sends are fire-and-forget: the journal records sent with the tx ids and the relayer keeps driving the transaction.
  • Never at the indexer's expense. The reorg notification only persists the response claims inline; the compensation steps run on a detached task. A response sleeping out a long delay: never stalls event delivery, cursor advance, or further reorg detection on the network it fired from.
  • Inspectable. rflow runs show <source-run-id> renders every reorg response fired for the run — fork block, status, and the per-step compensation trace — and GET /api/runs/{id} returns the same under reorg_responses. A response still shown running resumes on the next boot's recovery pass.
  • rflow never rolls back the original run. on_reorg: is the signal that it acted on something the chain took back; what to do about it is yours to script.

What the engines handle for you

The embedded rindexer detects reorgs and re-emits the canonical chain: rflow's trigger dedupe (chain_id:tx_hash:log_index) means a re-emitted event that already ran is skipped, and an event only present on the new branch fires normally. Cursors track the canonical chain, so backfill/restart never double-processes a range.

Accepted residual risks

Stated plainly, because no automation system can remove them:

  1. Head-fired actions on orphaned events. With confirmations: 0, you accepted speed over certainty. If the triggering event is orphaned after your action ran, the action stands — on_reorg: lets you respond, it cannot undo.
  2. Reorgs deeper than N. confirmations: 12 does not protect against a 13-block reorg. Pick depth by the value at stake; use finalized when it really matters.
  3. Your own transaction being reorged. The relayer waits for the network's confirmations depth before reporting CONFIRMED, and a send step with wait_for: confirmed inherits that — wait_for: confirmed(N) / finalized go deeper when it matters. A reorg between inclusion and confirmation is handled (rrelayer keeps tracking/rebroadcasting); a reorg after you observed CONFIRMED is risk you tuned with that setting.
  4. Provider disagreement. With multiple RPC urls, a lagging fallback can briefly disagree about head. Depth-based triggers absorb this; confirmations: 0 may see events a beat earlier or later than another observer would.