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 is one workflow with run_on: both:

rflow_version: 1
name: swap-watch
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
contracts:
  AnyPool:
    abi: ./abis/pool.json
    addresses:
      ethereum: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
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. They are the hook for alerts and compensations:

rflow_version: 1
name: fast-mirror
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
contracts:
  USDC:
    abi: ./abis/erc20.json
    addresses:
      ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
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:

  • One response claim per (run, fork_block), crash-resumable. The response claim persists in rflow.reorg_runs before the first side effect, so a redelivered reorg notification cannot claim a second response. Neither can a deepening reorg detected in stages: a re-detection whose [fork_block, detection_block] range overlaps an already-claimed range is refused; only a distinct later reorg with a disjoint reverted range fires again. Every step's row lands in rflow.reorg_step_runs before its side effect, a crash mid-response leaves the claim running, and the next boot's recovery pass resumes it. 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; it is never re-sent on ambiguity.
  • External effects still need idempotency. An HTTP call or notification can be delivered before its result reaches the response journal. Recovery can repeat it in that window, just as in a normal workflow. A local command's effects have the same limitation. Compensation is a new action; it cannot guarantee that an earlier external effect or payment can be undone.
  • Kept simple by validation. approval: 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. Claims persist inline; compensation steps run on a detached task, so 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.
  • 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

No automation system can remove these:

  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, endpoints can briefly disagree about head. rflow's own reads evict an endpoint that trails the best-known head past rpc.policy.max_lag, and the indexer serves one sticky endpoint at a time, suspending its reorg parent-hash window across a switch so a laggier fallback cannot trigger a spurious rollback. But depth-based triggers are still what absorbs disagreement; confirmations: 0 may see events a beat earlier or later than another observer would.