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 finality0(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):
| Chain | Suggested depth |
|---|---|
| ethereum | 20 |
| base / arbitrum / optimism | 24 |
| polygon | 200 |
| everything else | 12 |
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 backon_reorg steps see the full expression context (the original trigger.*
included) plus a reorg root:
| Path | Description |
|---|---|
reorg.tx_hash, reorg.block_number, reorg.network | The orphaned trigger occurrence |
reorg.run_id, reorg.workflow | The run that had already acted on it |
reorg.fork_block, reorg.detection_block | The reverted range the indexer reported |
The semantics, stated precisely:
- Exactly-once per
(run, fork_block), crash-resumable. The response claim persists inrflow.reorg_runsbefore 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 inrflow.reorg_step_runsbefore its side effect, and a crash mid-response leaves the claimrunning— 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_transactioninsideon_reorg:journalsrflow-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 validation —
approval:gates,foreach:, andwait_for:are rejected insideon_reorg:; steps run sequentially with no retry policy, delays sleep inline (durably journaled), and sends are fire-and-forget: the journal recordssentwith 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 — andGET /api/runs/{id}returns the same underreorg_responses. A response still shownrunningresumes 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:
- 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. - Reorgs deeper than N.
confirmations: 12does not protect against a 13-block reorg. Pick depth by the value at stake; usefinalizedwhen it really matters. - Your own transaction being reorged. The relayer waits for the network's
confirmationsdepth before reporting CONFIRMED, and a send step withwait_for: confirmedinherits that —wait_for: confirmed(N)/finalizedgo 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. - Provider disagreement. With multiple RPC urls, a lagging fallback can briefly
disagree about head. Depth-based triggers absorb this;
confirmations: 0may see events a beat earlier or later than another observer would.