Composite triggers — all / any
A composite trigger fires a workflow from several on-chain events instead of one.
trigger.all— fire one run when every listed event has matched within a sliding correlation window (within).trigger.any— fire when any listed event matches (first match).
Each matched event is exposed to the workflow steps as events.<id>.* (its decoded
args, tx_hash, block_number, address, …).
all — correlate two related events
rflow_version: 1
name: deposit-then-transfer
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
contracts:
Vault:
abi: ./abis/vault.json
network: ethereum
address: "0x0000000000000000000000000000000000000000" # your vault
USDC:
abi: ./abis/erc20.json
network: ethereum
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
notifications:
channels:
ops:
telegram:
bot_token: ${TG_BOT_TOKEN}
chat_id: ${TG_CHAT_ID}
workflows:
deposit-then-transfer:
trigger:
all:
within: 10m # sliding correlation window (REQUIRED for `all`)
events:
- id: deposit
event:
contract: Vault
name: Deposit
network: ethereum
- id: transfer
event:
contract: USDC
name: Transfer
network: ethereum
# cross-event correlation: a LATER event's where may reference #
# events.<earlier-id>.args
where: "${{ event.args.to == events.deposit.args.user }}"
steps:
- notify:
channel: ops
message: "deposit ${{ events.deposit.args.amount }} then transfer ${{ events.transfer.tx_hash }}"Semantics:
- The first listed event opens a correlation; later events attach to the
correlation they satisfy. In a
where:,${{ event.args.* }}is this event;events.<earlier-id>.argsare the already-matched earlier events. - A later event is consumed by at most one correlation: the oldest open one
it satisfies. One on-chain event never fans out into multiple runs. If a
cross-event
where:is not fully discriminating (two deposits by the same user), the oldest window wins and the others keep waiting. - When a correlation holds every listed event, the run is claimed exactly once: a durable dedup key over the matched set, so a re-delivered completing event never fires a second run.
- Partial matches accumulate in a durable table (
rflow.composite_matches) and are swept once theirwithinwindow passes. An incomplete set never fires.
within is required for all and must be a positive duration. A composite
where: may reference only event.* and events.<earlier-id>.* (events
earlier in the list); any other root (constants, secrets, trigger,
state, …) is rejected by validation.
Ordering assumption: only the first-listed event opens a correlation, so all
assumes the starter is observed no later than the events that correlate to it.
Events on different contracts (or networks) arrive via independent head callbacks, so
a same-block event at a lower log index can arrive before its starter; with no
correlation open it is dropped and that set never fires. List the event most likely to
be seen first as the starter, or keep correlated events on the same contract
(same-contract logs are delivered in on-chain order). Buffering out-of-order arrivals
is intentionally out of scope.
A single-event all is just an event: trigger. rflow validate advises you to
model a "react to a follow-up event" flow as an event: trigger plus a
wait_for: step (the run then correlates with the full
run context).
any — fire on the first of several events
rflow_version: 1
name: emergency-pager
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
contracts:
Vault:
abi: ./abis/vault.json
network: ethereum
address: "0x0000000000000000000000000000000000000000" # your vault
Guardian:
abi: ./abis/guardian.json
network: ethereum
address: "0x0000000000000000000000000000000000000000" # your guardian
notifications:
channels:
pager:
pagerduty:
routing_key: ${PAGERDUTY_ROUTING_KEY}
workflows:
emergency-stop:
trigger:
any:
events:
- id: paused
event: { contract: Vault, name: Paused, network: ethereum }
- id: guardian
event: { contract: Guardian, name: EmergencyStop, network: ethereum }
steps:
- notify: { channel: pager, message: "emergency: ${{ events.paused.tx_hash or events.guardian.tx_hash }}" } any fires immediately on the first matching event (deduped by that event's log
identity). within is accepted but does not gate any.
Match-time scope + reorgs
Composite events match at chain head (the same default as an event: trigger
with no confirmations:). Exactly-once completion does not depend on indexing
depth: it rides the durable rflow.composite_matches accumulator, the
composite:{workflow}:{hash} dedup key, and the sliding within window. The reorg
caveat matches a head-firing event: trigger: a reorg can revert a matched event, so
a matched-but-not-completed correlation may linger until its window expires.
Addresses are case-normalized, so event.args.to == events.deposit.args.user
compares correctly regardless of casing.
Testing composites
rflow test <wf> --fixture <file> rehearses a composite path by supplying the
matched events directly:
{ "events": {
"deposit": { "args": { "user": "0xabc", "amount": "1000000" } },
"transfer": { "args": { "to": "0xabc", "value": "500000" } }
} }Replay limit: the fixture asserts the correlated set directly; the sliding
within window and cross-event where: matching are not re-run in rflow test.
The correlation state machine itself is covered by rflow's DB-gated tests.