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

Event trigger

Fire a workflow on a decoded onchain event.

rflow_version: 1
name: usdc-watch
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
contracts:
  USDC:
    abi: ./abis/erc20.json
    network: ethereum
    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
workflows:
  large-transfers:
    trigger: 
      event: 
        contract: USDC              # name in the contracts: registry 
        name: Transfer              # event name 
        network: ethereum
        where: "${{ trigger.args.value > wei('1000', 6) }}"   # optional filter 
        confirmations: 0            # 0 (head, default) | N | finalized 
        run_on: confirmed           # unconfirmed | confirmed (default) | both 
        start_block: latest         # earliest | latest | <n> 
        end_block: live             # live | <n> 
        throttle:                   # optional — cap noisy triggers 
          threshold: { count: 5, window: 1h } 
          cooldown: 10m
    steps:
      - id: alert
        notify:
          channel: ops
          message: "${{ format_units(trigger.args.value, 6) }} USDC from ${{ trigger.args.from }} - tx ${{ trigger.tx_hash }}"
FieldRequiredDescription
contract✅A contract registry name. Factory-scoped contracts fire for every child
name✅The event name from the ABI, e.g. Transfer
network✅A declared network
whereFilter expression; the run only starts when it is true
confirmations0Depth the event must reach before firing; see below
run_onconfirmedWhich phase(s) fire: unconfirmed | confirmed | both (two-phase); see below
start_blocklatestWhere the cursor starts; see backfill below
end_blocklivelive tails forever; a number makes a bounded job that exits cleanly
throttleDurable rate limiting for noisy triggers; see below

The trigger context

Inside expressions, the firing event is trigger.*:

PathDescription
trigger.args.<name>Decoded event arguments (uint256 values stay exact; see U256 semantics)
trigger.tx_hash, trigger.log_indexEvent identity
trigger.block_number, trigger.block_hashPosition
trigger.addressThe emitting contract (useful with factory-scoped triggers)
trigger.network, trigger.chain_idWhere
trigger.topicsRaw log topics
trigger.phaseunconfirmed | confirmed — which phase fired this run (see run_on)

where

One expression, evaluated per event. Combine conditions with and / or / not, not && / ||:

where: "${{ trigger.args.sender in lists.watched_traders and trigger.args.amount0 > wei('1', 18) }}"

A where that evaluates false skips the event without creating a run. An expression error (typo'd key, bad type) never silently evaluates false. It routes to failure handling so you see it.

confirmations

How deep the event must be before the workflow fires: 0 fires at head (the default; rflow is built to be fast), N waits N blocks, finalized waits for chain finality.

Per-chain guidance (what rflow validate prints; advice only, never blocking):

ChainSuggested depth for irreversible actions
ethereum20
base / arbitrum / optimism24
polygon200
anything else12

Rule of thumb: alert at 0, pay at depth. A head-fired trigger that sends funds gets a validation warning, because a reorg can orphan the triggering event after your transaction is sent. Full discussion in Reorgs.

run_on — two-phase firing

confirmations sets the depth; run_on picks which observation(s) of the event start a run:

  • confirmed (default) — fire once the event reaches the trigger's confirmation depth
  • unconfirmed — fire the moment the event is first seen at head
  • both — fire twice for the same event: once at head, once at depth. Each phase claims its own trigger key, so both runs are individually exactly-once. Steps distinguish them with trigger.phase:
rflow_version: 1
name: swap-mirror
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
# DEV ONLY raw mnemonic - the mirror step below sends transactions
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}
 
relayers:
  hot-wallet:
    networks: [ethereum]
 
contracts:
  UniV3Factory:
    abi: ./abis/univ3-factory.json
    network: ethereum
    address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
  AnyPool:
    abi: ./abis/univ3-pool.json
    factory: { contract: UniV3Factory, event: PoolCreated, network: ethereum }
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
workflows:
  swap-mirror:
    trigger:
      event:
        contract: AnyPool
        name: Swap
        network: ethereum
        confirmations: 20
        run_on: both
    steps:
      - id: alert            # instant heads-up 
        if: "${{ trigger.phase == 'unconfirmed' }}"
        notify: { channel: ops, message: "swap seen at head: ${{ trigger.tx_hash }}" }
      - id: mirror           # money moves only at depth 
        if: "${{ trigger.phase == 'confirmed' }}"
        send_transaction: { ... }

This is the single-workflow version of the "alert at head, pay at depth" pattern. If the head-fired event is later orphaned by a reorg, the confirmed phase never comes. Pair it with on_reorg: if you need to compensate for what the unconfirmed phase already did.

throttle

Cap how often a noisy trigger may claim runs. After threshold.count fired runs inside threshold.window, further matches are suppressed (skipped with a log line, the cursor still advances) until cooldown has passed:

throttle:
  threshold: { count: 5, window: 1h }
  cooldown: 10m

The window counter and the cooldown fence are persisted in Postgres (rflow.trigger_throttle), never in memory, so a restart cannot reset a tripped cooldown. Only events that actually claimed a run count against the window, so duplicate deliveries never double-count.

Saturation: if the window has not rolled over when the cooldown expires, the next fire runs and immediately re-trips the cooldown, so at most one run per cooldown period until the window resets.

(For limiting a workflow rather than a trigger, see rate_limit; note its window is in-process, unlike throttle.)

Backfill and cursors

start_block / end_block give you history and live tailing in one config:

  • start_block: earliest — backfill from the contract's first block, then go live
  • start_block: latest (default) — start from now
  • start_block: 19000000 — backfill from a specific block
  • end_block: live (default) — tail forever
  • end_block: 19100000 — bounded job: process the range, exit cleanly

Each (workflow, network) pair keeps a cursor (last processed block) in Postgres, visible in rflow ls, so restarts resume exactly where they left off and a crash never skips or re-processes a block range. Combined with the exactly-once claim, replaying an overlap is harmless: duplicate events are skipped.

To rehearse a workflow against a historical range with separate workflow claims, use rflow replay. Its dry-run mode suppresses native sends and HTTP/notification effects, but commands execute by default. Historical triggers do not pin later RPC reads or simulations to their block; see the historical-state limits.

Dev-chain caveat: a trigger with confirmations: N live-tailing from the head of a fresh local chain (no history, e.g. a just-started anvil) can panic the embedded indexer's progress tracker (an upstream start == target edge). On a fresh anvil give the trigger an explicit start_block (even 1) so it has a real backfill range.

Factory-scoped triggers

Point the trigger at a factory contract and it fires for every child ever deployed:

rflow_version: 1
name: pool-watch
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
contracts:
  UniV3Factory:
    abi: ./abis/univ3-factory.json
    network: ethereum
    address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
  AnyPool:
    abi: ./abis/univ3-pool.json
    factory: { contract: UniV3Factory, event: PoolCreated, network: ethereum }
 
lists:
  watched_traders:
    seed:
      - "0x28C6c06298d514Db089934071355E5743bf21d60"
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
workflows:
  watched-swaps:
    trigger: 
      event: 
        contract: AnyPool        # factory: { contract: UniV3Factory, event: PoolCreated } 
        name: Swap
        network: ethereum
        where: "${{ trigger.args.sender in lists.watched_traders }}"
    steps:
      - id: alert
        notify:
          channel: ops
          message: "watched trader swapped in pool ${{ trigger.address }} - tx ${{ trigger.tx_hash }}"

trigger.address tells you which child emitted.

Reacting to reorgs

If a reorg orphans an event this trigger already fired on, the workflow's on_reorg: steps run with a reorg.* context: notify, compensate, whatever the situation needs.