# πŸ¦€ rflow πŸ¦€ > rflow is a blazing-fast onchain workflow engine written in Rust β€” one rflow.yaml maps triggers to chains of actions with exactly-once execution ## Approvals β€” human-in-the-loop sends Some transactions should not fire without a human saying so β€” big treasury moves, emergency responses, anything above a threshold. `approval:` on a [`send_transaction`](/workflows/steps/send-transaction) step parks the run with the transaction **fully prepared and simulated**, notifies the approvers, and only broadcasts after an explicit yes. ```yaml workflows: treasury-sweep: trigger: cron: { expression: "0 9 * * 1" } steps: - id: sweep send_transaction: network: ethereum relayer: treasury contract: USDC function: "transfer(address,uint256)" args: ["${{ constants.cold_storage }}", "${{ steps.balance.output }}"] approval: via: [telegram: ops, cli] timeout: 4h on_timeout: fail # fail (default) | proceed (warned) message: "sweeping ${{ format_units(steps.balance.output, 6) }} USDC to cold storage" recheck: "${{ steps.balance.output > wei('1000', 6) }}" ``` ### The approval block | Field | Default | Description | | ------------ | ---------- | ----------------------------------------------------------------------------------------------------------------- | | `via` | βœ… required | Approval routes, non-empty: `telegram: ` (a [notifications channel](/config/notifications)) and/or `cli` | | `timeout` | | How long to wait for a decision, e.g. `1h` | | `on_timeout` | `fail` | What an undecided timeout does: `fail` \| `proceed` | | `message` | | Template shown to the approver β€” the decoded call + simulation result are always included | ### Approval policies β€” N-of-M quorums For two-person review, name the people and the quorum instead of a route list. **Approvers are identity objects**: each route maps a channel to the member's address, and the address doubles as the attribution key. Channels keep holding credentials (`via_channel` points at a [notifications channel](/config/notifications)); approvers hold addresses. ```yaml approvers: alice: cli: { token: ops-alice } # named api token (rflow token create) telegram: { chat_id: "12345678", via_channel: ops } # her PRIVATE chat with the bot bob: sms: { to: "+447700900123", via_channel: ops-sms } # over the twilio channel's account carol: cli: { token: ops-carol } approver_groups: ops: [alice, bob, carol] approval_policies: two-of-ops: group: ops required: 2 # validated: 1 <= required <= group size (groups max 25) timeout: 4h # hard 30-day ceiling; absent = wait the full ceiling remind_every: 1h # re-notify undecided members (journaled) on_timeout: fail # NEVER manufactures a decision; `proceed` is warned on_reject: fail # first rejection VETOES (default); `wait` = fail only # once `required` approvals become impossible escalate: ops # notify-only when the quorum expires undecided ``` Reference it from the send β€” a bare policy, or **amount tiers** (ordered, first `when:` match wins, a trailing no-`when` tier is the default, no match \= no gate; `value` is the prepared send's native value in wei): ```yaml approval: policy: two-of-ops # or approval: - when: "${{ value > wei('50', 18) }}" policy: two-of-ops - policy: one-of-ops ``` Semantics that make the quorum safe: * **The snapshot is pinned at park time.** Members, `required`, timeouts and routes are frozen onto the approval row β€” a later config edit never changes a pending quorum. * **One member counts once.** Decisions are journaled per member with a unique key; a duplicate command, replayed link, or the same person on two channels cannot double-count. * **Members with a `telegram:`/`sms:` route get their own private notice** (CLI-only members decide from `rflow approvals ls` with their token) β€” a DM / SMS over the channel's credential, carrying a **signed one-time approval link** (when `config.server.public_url` is set) plus the CLI one-liners. The link is a bearer credential bound to one member of one approval: single-use, expires with the quorum, GET only renders the confirm page (scanners cannot spend it), the decision is a POST. * **CLI decisions must prove membership**: `rflow approve --token ` (or `RFLOW_API_TOKEN`) β€” the token's *name* maps to `approvers..cli.token`. `cli:$USER` is never accepted for quorums. * **Everything is journaled**: per-member decisions (member, route, reason, time) in `rflow.approval_decisions`, an `operator_audit` row per decision, and the full trail in `rflow runs show` / `GET /api/runs/{id}`. Retention keeps everything by default; when an operator-configured window prunes a decided approval (or its whole run), the decision/link rows cascade away with it β€” the `operator_audit` trail survives pruning by design. ### Safe proposal mode `send_transaction.propose_to_safe:` PROPOSES the prepared tx to a [Safe](https://safe.global) via the Safe Transaction Service instead of broadcasting β€” the Safe's own owner threshold then governs execution in Safe\{Wallet}. An integration, not custody: rflow holds a revocable **delegate** key that can only propose, never execute. ```yaml send_transaction: network: ethereum relayer: treasury # unused in this mode (no broadcast) contract: USDC function: "transfer(address,uint256)" args: ["${{ constants.cold_storage }}", "${{ steps.balance.output }}"] approval: policy: two-of-ops # rflow's gate = proposal hygiene propose_to_safe: safe: "0xYourSafe..." delegate_key: ${SAFE_DELEGATE_KEY} service_url: https://api.safe.global/tx-service/eth api_key: ${SAFE_API_KEY} # optional; anonymous is rate-limited ``` Every gate still runs first (permissions, simulation, gas caps, recheck, rflow approval); the step then signs the `safeTxHash` (v1.3.0+ domain) with the delegate key and posts the proposal β€” output carries `safe_tx_hash` and the nonce. Register the delegate once with the service (an owner signs the Delegate message). Know the edges: a proposal **cannot be un-proposed** (an rflow approval timeout upstream simply means nothing is proposed), and the Safe queue is nonce-ordered β€” concurrent proposers race for the next nonce. ### The lifecycle 1. **Prior steps have already run.** The gate is on *this transaction*, not the workflow β€” reads, HTTP enrichment and guard steps complete first, so the approver sees final values. 2. **The transaction is prepared and simulated.** Simulation, [`assert_sim`](/workflows/steps/send-transaction#assert_sim--gates-over-the-simulation) and gas caps all run *before* anyone is asked β€” a send that would revert never bothers a human. 3. **The run parks durably** (`waiting_approval`): the approval row (`rflow.approvals`), its expiry and the prepared tx summary are journaled in the same transaction that parks the step. A crash or restart while parked loses nothing; a recovering executor adopts the pending row. 4. **Approvers are notified** through every `via:` route β€” the notice carries the workflow, run and step ids, your `message`, the decoded call summary and the exact `rflow approve`/`reject` one-liners. Notification failures are logged, never fatal: the gate is the database row, so the CLI always works even when every channel is down. 5. **A decision settles it** β€” decisions are single-shot (`WHERE status = 'pending'`), so a double-approve or a race against the expiry poll changes nothing and reports what actually happened. | Decision | Effect | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **approved** | The send **re-runs [`recheck:`](/workflows/steps/send-transaction#recheck--dont-fire-stale) and re-simulates** against current state, then broadcasts under the same idempotency key. Conditions moved while the human decided β†’ the send is dropped, not fired stale | | **rejected** | The step fails (kind `dropped`, the reason journaled); [`on_failure`](/workflows#failure-handling) applies | | **expired** | `on_timeout` decides: `fail` β€” the step fails with kind `expired`, nothing broadcasts. `proceed` β€” see below | ### Deciding: CLI ```bash rflow approvals ls # pending approvals (--all includes decided/expired) rflow approve [--yes] # is the approval id or the run id rflow reject --reason "gas too high today" ``` `rflow approve` prints the prepared transaction and asks for confirmation (`--yes` skips it). Since steps are strictly sequential, a run has at most one pending approval β€” the run id works as the ``. Pending approvals are also visible in `rflow runs show ` and the [run-trace viewer](/observability). An AI agent operating the project via [MCP](/mcp) can *see* pending approvals in the journal but has no approve/reject tool β€” deciding a money gate stays with humans and the CLI, deliberately. ### on\_timeout: proceed β€” the sharp edge :::warning `on_timeout: proceed` treats an unanswered gate as **consent**: when the timeout elapses undecided, the send rechecks, re-simulates and **broadcasts with nobody having approved it**. `rflow validate` warns about every step that sets it. Use it only where an unanswered gate must never block (and pair it with a tight `recheck:`); everywhere else the default `fail` is the honest choice. ::: ### Semantics worth knowing * **Webhook callers see 429** while their workflow's run is parked at a gate β€” the caller's retry loop redelivers naturally once the run settles. * **Dry-run sessions auto-proceed**: in [`rflow replay` / `rflow test`](/backtesting) the gate does not park β€” the journal carries an `approval.required` marker so you still see *where* a human would have been asked. * **`on_reorg:` steps may not contain approval gates** (validated) β€” reorg responses must stay fast and unattended. * The pagerduty/opsgenie channels classify approval requests as `info`/`P5` β€” a request for a decision, not an incident. ## Backtesting β€” replay, test & dry-run Would your workflow have done the right thing? `rflow replay` answers with **real historical blocks**: it re-indexes a range, fires your trigger exactly as live would, runs every step in dry-run mode β€” simulation, gas caps, policy checks, `assert_sim`, `recheck` all included β€” and reports what it *would have sent*. `rflow test` is the single-shot sibling for one fixture or one real transaction β€” for **every** trigger kind. Nothing broadcasts unless you explicitly go `--live`. No hosted automation platform lets you do this. Your YAML is testable against the chain's actual history before a single wei moves. At a glance: | Command | What it does | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `rflow replay --from-block A [--to-block B]` | Replay a range through an **event- or block-triggered** workflow (dry-run) | | `rflow replay … --with-waits waits.json` | Resolve `wait_for:` saga steps deterministically for the rehearsal | | `rflow replay … --diff OTHER_DIR` | Diff the same range against another project version β€” did behaviour change? | | `rflow replay … --output json\|junit` | Emit a CI-friendly report (exit non-zero on failure) | | `rflow replay ls` | List every replay/test session | | `rflow replay prune --older-than 7d \| --session ID \| --all` | Delete sessions (rows, indexer schemas, runtime dirs) | | `rflow test --fixture f.json` (or `--event`/`--from-tx`/`--cron-at`/`--read-output`) | One dry-run from a fixture, for any trigger kind | ### rflow replay β€” a range of history ```bash rflow replay --from-block [--to-block ] [--timeout ] [--live] [--yes] ``` Replay drives **event** triggers (via a bounded re-index of the range) and **block** triggers (one dry-run per block where `block % every == 0`, deduped by block number). Cron/webhook/read/query workflows have no block-bounded history to replay from β€” use [`rflow test`](#rflow-test--one-run-from-a-fixture) with a fixture for those. Every output below is a real session from the repo's [`token-transfer-relay` example](/getting-started/examples): three historical deposits landed on the local chain (250, 25 and 500 RFT β€” the workflow's `where:` only matches deposits β‰₯ 100), then: ```bash rflow replay echo-deposit --from-block 40 --to-block 46 ``` ``` dry-run replay of 'echo-deposit' from block 40 to 46 - nothing will be sent replay 20260724034558fe323d - 'echo-deposit' blocks 40..=46 on local_anvil (dry-run) block | tx | run | steps | would send 43 | 0xfe24bb08da25fb6… | succeeded | 2 ok | transfer(address,uint256) -> 0x5FbDB231567…, gas 51710 45 | 0x75a03bb563b6a7c… | succeeded | 2 ok | transfer(address,uint256) -> 0x5FbDB231567…, gas 51710 2 event(s) matched, 2 run(s), 2 would-be send(s) session journal: rflow runs list (workflow 'replay_20260724034558fe323d__echo-deposit') - live history untouched ``` The 25 RFT deposit at block 44 is correctly absent β€” the `where:` filtered it, exactly as live would. Each row is one claimed run: the triggering event, the per-step outcomes (`2 ok` / `failed at `), and the decoded would-be send with its simulated gas. #### What dry-run means, precisely Every check that can run without side effects **still runs**: templates, permissions, relayer policy caps, simulation, `assert_sim`, gas caps, `recheck`. But nothing leaves the process: | Action | Dry-run behaviour | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send_transaction` | Stops right before the relayer hand-off; reports the `would_send` summary + simulation result | | `http_call` / `notify` | The prepared request is logged, never fired | | [`command`](/workflows/steps/command) | **Executes for real by default** (`dry_run: execute`) β€” a command may be needed to produce the would-be tx params a later step rehearses. rflow cannot know what arbitrary local code does (files, external APIs), so set `dry_run: skip` on any command with side effects to short-circuit it with `{ "skipped": true }` instead | | `delay` | Shrinks to zero (logged) | | `approval:` | Auto-proceeds; the journal carries an `approval.required` marker where a human would have been asked | | `state_set` / `list_add` / `list_remove` | Writes are skipped; `would_set` / `would_mutate` journaled instead | | `wait_for` | Settles immediately with a `would_wait_for` output β€” unless you pin the outcome with [`--with-waits`](#saga-rehearsals----with-waits) | #### The isolation model A replay boots an **isolated session**: a bounded indexer manifest for just this workflow and range, registered under a namespaced name (`replay___`) with a `replay::` trigger-key prefix. Consequences, all deliberate: * **live dedupe is untouched** β€” a live claim and a replay claim of the same event coexist * a live `rflow start` never picks up replay runs, and the replay executor never picks up live runs * the workflow's own run history stays clean β€” replay runs journal under the namespaced name * `trigger.throttle` is dropped for the session (a rehearsal must not consume the live throttle window β€” replay shows *every* match), the trigger is forced to confirmed-at-depth-0 (historical blocks are final), and decoded fires are never offered to live parked [`wait_for` sagas](/workflows/steps/wait-for) * `paused:` is ignored β€” replaying a paused workflow is an explicit operator action #### Block-trigger replay A [block trigger](/workflows/triggers/block) (`block: { every: N }`) has no event stream β€” replay instead fires **one dry-run per block** in `[A, B]` where `block % every == 0`, deduped by block number, building `trigger.args = { block_number, network }` for each. It needs no chain access when you pass an explicit `--to-block` (the block numbers are computed, not fetched): ```bash rflow replay every-fifth-block --from-block 100 --to-block 120 ``` ``` replay 20260802213528773ba1 - 'every-fifth-block' blocks 100..=120 on local (dry-run) block | tx | run | steps | would send 100 | - | succeeded | 1 ok | ... 105 | - | succeeded | 1 ok | ... 110 | - | succeeded | 1 ok | ... 115 | - | succeeded | 1 ok | ... 120 | - | succeeded | 1 ok | ... 5 event(s) matched, 5 run(s), 0 would-be send(s) ``` #### Listing & pruning sessions β€” `rflow replay ls` / `prune` Replay and test sessions **leave their journal behind** β€” namespaced rows in `rflow.workflows` / `rflow.workflow_runs`, a per-session rindexer schema (event replays), and a `.rflow/runtime/replay-/` directory. `rflow replay ls` shows them all: ```bash rflow replay ls ``` ``` session | workflow | kind | created | runs | runtime dir 20260802213528773ba1 | every-fifth-block | replay | 2026-08-02 21:35:28 | 5 | yes ac03575f | settle-saga | test | 2026-08-02 21:34:45 | 3 | - ``` `rflow replay prune` deletes them β€” namespaced journal rows (one guarded transaction), the rindexer schema (`DROP SCHEMA … CASCADE`) and the runtime directory. It requires a selector so a bare `prune` can never nuke everything, and it **only ever touches namespaced sessions** β€” a live workflow, its runs and its analytics schema are never deleted: ```bash rflow replay prune --older-than 7d # sessions created > 7d ago rflow replay prune --session # one session by id rflow replay prune --all # every replay/test session ``` ``` pruned 6 session(s) - live workflows and their runs were not touched ``` The `replay___` / `test___` naming pattern is **reserved for sessions**: `rflow validate` rejects a live workflow named into it, and β€” belt-and-braces for journals created before that rule β€” `prune` cross-checks every discovered session against the workflows declared in `rflow.yaml` and refuses to touch a name that is a live config workflow. #### Two requirements to know about :::warning * **Event & block triggers only.** Cron/webhook/read/query workflows have no historical block-bounded stream to replay β€” use `rflow test` for those (the error message says the same). * **Relayer references need persisted mappings.** Dry-run resolves `relayers..address` from the `rflow.relayers` mappings, so a project that never booted live errors on steps referencing them. One `rflow relayers sync` (no funds needed) creates the mappings. ::: #### --live β€” replay with real money `--live` boots the relayer engine and **sends real transactions** for every historical match that passes its checks. It takes the project lock (a running `rflow start` shares wallets and nonces) and makes you type `send` β€” not `y` β€” at a red prompt: ``` !!! LIVE REPLAY !!! every historical 'echo-deposit' event in blocks 40..=46 that passes its checks will SEND A REAL TRANSACTION through your relayers. this is NOT a rehearsal - money moves. type 'send' to proceed (anything else aborts): ``` Use it for deliberate historical catch-up ("process everything I missed last week, for real"), not for testing. ### rflow test β€” one run from a fixture ```bash rflow test --fixture # trigger payload for the workflow's OWN kind rflow test --event # event fixture (event workflows) rflow test --from-tx # trigger decoded from a real receipt rflow test --cron-at # cron workflows rflow test --read-output # read/query workflows ``` No indexer at all: one run is claimed under a namespaced name and driven to a terminal state in dry-run mode. `--from-tx` fetches the receipt and decodes the log that matches your trigger β€” the fastest way to ask *"what would this workflow have done with that transaction?"*: ```bash rflow test echo-deposit --from-tx 0xfe24bb08da25fb6403d9d2348a30703686d25a015e64c0bfa5763dd3ac98aa1c ``` ``` dry-run of 'echo-deposit' - nothing will be sent test ea233669f4c445d88fefa2978fe5ec12 - 'echo-deposit' (dry-run, trigger tx 0xfe24bb08da25fb6…) step | action | outcome | detail gate (#1) | read | succeeded | "1000000000000000000000000" payout (#1) | send_transaction | succeeded | would send transfer(address,uint256) -> 0x5FbDB2315678afecb367f032d93F642f6… test run succeeded (2 step rows journaled under 'test_ea233669__echo-deposit') ``` #### A fixture for every trigger kind β€” `--fixture` `--fixture ` interprets the file against the **workflow's own trigger kind** and builds the exact `trigger.*` roots a live fire would have journaled. A fixture whose shape does not match the kind (a webhook body against an event workflow) errors clearly. Each kind's shape: | Trigger | Fixture shape | Builds | | --------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `event` | `{ "args": {…}, "tx_hash"?, "block_number"?, "network"?, … }` | `trigger.args` + the event envelope (defaults filled from the trigger) | | `cron` | `{ "scheduled_for": "2026-08-02T09:00:00Z" }` | `trigger.scheduled_for`, `trigger.workflow` | | `webhook` | `{ "body": {…}, "headers"?: {…} }` | `trigger.args` = body, `trigger.webhook.{path, received_at, headers?}` | | `read` | `{ "output": }` | `trigger.args.{output,value}`, `trigger.read.{function, network, observed_at}` | | `query` | `{ "output": }` | `trigger.args.output`, `trigger.query.observed_at` | | `block` | `{ "block_number": 19000000, "network"?: "…" }` | `trigger.args.{block_number, network}` | ```bash rflow test deposit-hook --fixture webhook.json # { "body": { "amount": "500" } } rflow test every-fifth-block --fixture block.json # { "block_number": 19000005 } ``` ``` test 35d0890757294e008352ee15469b87bf - 'deposit-hook' (webhook dry-run) step | action | outcome | detail log_amount (#1) | state_set | succeeded | would set state.webhook_amount = 500 ``` Two convenience flags are sugar over `--fixture`: ```bash rflow test nightly-report --cron-at 2026-08-02T09:00:00Z # cron workflows rflow test utilization-guard --read-output util.json # read/query workflows: { "output": "…" } ``` `--event` is the event-only alias (`{"args": {...}}` plus optional envelope fields) kept for backwards compatibility; `--fixture` supersedes it for event workflows too. Reads and simulations run against the network's **current** state (an `eth_call` cannot time-travel on a normal RPC) β€” for state-at-height fidelity, point the network's `rpc:` at an archive fork (e.g. `anvil --fork-url $ETH_RPC --fork-block-number `) via a [profile](/config/profiles). Read/query replay uses the `--read-output` you supply, not reconstructed archive state β€” see [non-goals](#non-goals). ### Saga rehearsals β€” `--with-waits` A [`wait_for:` saga](/workflows/steps/wait-for) parks the run until a matching event or a timeout. In a rehearsal there is no live event to wait for, so by default the wait settles immediately (`would_wait_for`). `--with-waits ` pins each wait step's outcome **deterministically** so you can rehearse both branches β€” available on `rflow test` **and** `rflow replay`: ```json [waits.json] { "await_settle": { "event": { "args": { "id": "1", "payout": "990" } } } } ``` ```bash rflow test settle-saga --fixture deposit.json --with-waits waits.json ``` The matched event becomes the wait step's output, so downstream steps run and can read `steps.await_settle.output.*`: ``` step | action | outcome | detail kickoff (#1) | state_set | succeeded | would set state.saga_started = 1 await_settle (#1) | wait_for | succeeded | {"args":{"id":"1","payout":"990"}} finalize (#1) | state_set | succeeded | would set state.saga_settled = 1 ``` Swap the outcome to `{ "await_settle": { "timeout": true } }` and the wait takes its `on_timeout` path (`fail` | `continue` | `goto`) β€” here `on_timeout: fail` dead-letters the run (and the process exits non-zero), exercising the timeout branch: ``` await_settle (#1) | wait_for | failed | timeout: wait_for timed out (fixture) before any condition matched ``` Wait steps not named in the file fall back to the default immediate settle. Fixtures key on the **config** step id: waits inside a `foreach` (`fan[0]`, `fan[1]`, ...) or a `finally:` block all resolve through their base id, so one `"fan": {...}` entry pins every item's outcome. ### Version diff β€” `--diff` Did your change alter behaviour over real history? `--diff OTHER_PROJECT_DIR` replays the **same range/fixtures** against both the current project and another project's same-named workflow (both dry-run, isolated sessions), then prints the per-trigger delta β€” added / removed / changed would-sends and status changes: ```bash rflow replay every-fifth-block --from-block 100 --to-block 120 --diff ../old-version ``` ``` diff 'every-fifth-block' blocks 100..=120 - this project vs the other project trigger | change | before (this project) | after (other project) block:local:100 | unchanged | succeeded [no sends] | succeeded [no sends] block:local:105 | removed | succeeded [no sends] | (did not fire) block:local:110 | unchanged | succeeded [no sends] | succeeded [no sends] 5 matched, 0 changed, 0 added, 2 removed, 3 unchanged - behaviour DIFFERS over this history ``` The command **exits non-zero** when behaviour differs, so a diff can gate a PR. If the other project is missing the workflow or declares a different trigger kind, it errors. The diff compares dry-run *decisions* (status + would-send params, including the exact calldata β€” an args-only or multicall inner-call change counts as changed), not live outcomes. ### CI output β€” `--output json | junit` Both `rflow test` and `rflow replay` take `--output human` (default), `json` or `junit`. `rflow test` exits non-zero when any run failed, so a fixture doubles as a CI check; `json`/`junit` still print. `--output json` emits one structured object (`{ session, workflow, trigger_kind, runs: [ { trigger_key, status, steps } ], summary }`). `--output junit` emits a `` with one `` per run (failed / dead-lettered runs carry a ``) that GitHub Actions and GitLab render natively: ```yaml [.github/workflows/backtest.yml] name: backtest on: [pull_request] jobs: fixtures: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: { POSTGRES_PASSWORD: rflow } ports: ["5432:5432"] env: DATABASE_URL: postgresql://postgres:rflow@localhost:5432/postgres steps: - uses: actions/checkout@v4 - run: cargo install --git https://github.com/joshstevens19/rflow rflow_cli - name: rehearse the settle saga (both branches) run: | rflow test settle-saga --fixture fixtures/deposit.json \ --with-waits fixtures/waits-event.json --output junit > settle.xml - name: publish results if: always() uses: mikepenz/action-junit-report@v4 with: report_paths: "*.xml" ``` ### Non-goals * **No perfect historical off-chain reconstruction.** Read/query replay uses the `--read-output` value you supply (or, for real fork state, point `rpc:` at an archive fork via [`rflow dev --fork`](/config/profiles)); rflow does not reconstruct historical off-chain API responses. * **No hosted simulation cluster.** Replay runs locally against your Postgres. * **Not a Foundry replacement.** rflow rehearses *workflow decisions*, not contract internals β€” keep your Solidity tests. ### Where this fits | I want to... | Use | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Check the YAML parses and references resolve | `rflow validate` (+ [`--preflight`](/observability#preflight-checks--rflow-validate---preflight) for connectivity) | | See what one event/transaction would do | `rflow test --from-tx` / `--event` | | Rehearse a cron / webhook / read / block workflow | `rflow test --fixture` / `--cron-at` / `--read-output` | | Rehearse a saga's matched **and** timeout branch | `rflow test … --with-waits` | | Backtest a filter or strategy over real history | `rflow replay --from-block ... --to-block ...` | | Check my change did not alter behaviour over history | `rflow replay … --diff OTHER_DIR` | | Gate a PR on fixtures in CI | `rflow test … --output junit` (non-zero exit on failure) | | Clean up replay/test sessions | `rflow replay ls` then `rflow replay prune` | | Process missed history **for real** | `rflow replay --live` (typed confirmation) | | Watch it work end-to-end locally | the [runnable examples](/getting-started/examples) | ## Benchmarks rflow's performance claim is architectural: event decode β†’ expression eval β†’ relayer hand-off are **in-process function calls** β€” no HTTP hop, no serialization on the hot path. This page puts numbers on that, and is precise about what was measured and what was not. ### Headline numbers The engine-side hot path β€” **event claimed β†’ transaction queued in the embedded relayer** β€” measured over 30 event-triggered sends: | Metric | p50 | p95 | max | | ------------------------------------------------------------------------------ | ----------- | ------- | ------- | | Claim β†’ tx queued (simulate + estimate gas + policy checks + relayer hand-off) | **25.1 ms** | 34.3 ms | 39.2 ms | | Claim β†’ run settled (full run, single send step, `wait_for: none`) | 28.1 ms | 37.3 ms | β€” | | Event's block timestamp β†’ claim (detection, polling a 1s-block chain) | 0.83 s | 0.91 s | 0.92 s | Those \~25 ms include real work, all of it journaled: the exactly-once claim transaction in Postgres, template evaluation, a pre-flight `eth_call` simulation, an `eth_estimateGas`, policy/gas-cap checks, the pre-send journal write with the idempotency key, and the in-process relayer queue insert. ### Methodology β€” reproduce it yourself * **Setup**: a release build of `rflow` (v0.1.0, `cargo build --release`), a local `anvil` chain (`--block-time 1`, chain id 31337) and the repo's compose Postgres, all on one machine. * **Workflow**: an ERC20 `Transfer` event trigger with a `where:` filter β†’ one `send_transaction` step (`wait_for: none`, simulation on β€” the default). Essentially the [`token-transfer-relay` example](/getting-started/examples) minus the balance-gate read. * **Load**: 30 qualifying deposits fired via `cast send`; every one produced exactly one run (n=30, all succeeded, zero duplicates). * **Measurement**: timestamps are the engine's own journal β€” `workflow_runs.created_at` (the claim) to `step_runs.finished_at` of the send step, which with `wait_for: none` settles at the relayer queue ack. Percentiles computed in SQL over the journal; detection latency compares the event's block timestamp to the claim time. * **Hardware**: Apple M5 Max, 128 GB RAM (a development laptop, not a tuned server). * **In-repo harness**: `cargo run -p rflow_e2e_tests -- --bench` runs a self-contained variant (20 sequential deposits, instant-mining anvil, unoptimized dev build) and writes `bench-results.json` with every sample, the full methodology, and on-chain receipt verification for each tx. Its numbers (\~32 ms p50 claim β†’ send settled on a dev build) are consistent with the release-build table above. ### Honest caveats * **Local RPC.** anvil answers `eth_call`/`eth_estimateGas` in microseconds; a real provider adds its network round-trips to the \~25 ms (two RPC calls sit inside the measured window). The number isolates *rflow's* overhead β€” it is the part of the stack rflow can promise. * **Detection is poll-bound in this setup.** The 0.83 s p50 from block timestamp to claim reflects the indexer's block polling against a 1-second chain, and block timestamps have 1-second granularity. Tightening `block_poll_frequency` ([networks config](/config/networks)) shrinks it; on real chains the block interval (12 s on mainnet) dwarfs this component entirely. (`ws:` is parsed but not wired to the engines yet, so it does not help here.) * **Queued, not confirmed.** The relayer owns everything after the queue β€” broadcast, gas bidding, inclusion. End-to-end "event β†’ CONFIRMED" is chain-time dominated (block interval Γ— your [`confirmations`](/config/networks#core-fields) depth) and would say nothing about the engine. * **One machine, one process, modest n.** n=30 on a dev laptop is a smoke-level benchmark for the hot path, not a load test. Concurrency limits (`max_concurrent_runs`, group lanes) were not stressed here. ### Why no competitor comparison table? The obvious candidates (Defender, Tenderly Web3 Actions, Gelato) are hosted services in a different category: their eventβ†’action latency includes their detection infrastructure, queueing and multi-tenant scheduling, none of which is publicly benchmarkable in a controlled way β€” numbers we could publish would be unfair in one direction or the other. The architectural difference stands on its own: rflow's trigger-to-relayer path is a function call inside one process on your hardware, plus two RPC round-trips to *your* provider. ### Where throughput actually goes For capacity planning, the bottlenecks in practice, in order: 1. **Your RPC provider** β€” simulation + gas estimation are two calls per send; backfills are `eth_getLogs`-bound (`max_block_range`, CU budgets). 2. **Postgres** β€” every claim, step and settle is a journaled write. Give it real storage; it is the durability you are paying for. 3. **The chain itself** β€” inclusion and confirmation depth; rflow just waits well (durably parked, not spinning). See [Self-hosting β†’ Sizing](/self-hosting#sizing). ## CLI The `rflow` binary is a pure interface over the core engine. Every command accepts `--path ` (short `-p`) to point at a project directory; the default is the current directory. ```bash rflow --help ``` ``` Blazing fast EVM workflow engine built in rust Usage: rflow Commands: new Create a new rflow project (alias: init) templates Browse, inspect, check and author templates (builtin + community registry) add Add a workflow (from a template), network, contract, relayer or notification channel to rflow.yaml doctor Check local dependencies (Docker, Foundry, Postgres, Node) and project readiness explain Plain-English breakdown of what a workflow does (static, from rflow.yaml) abi Fetch, inspect and reverse-lookup contract ABIs contract Contract helpers - `contract add` fetches the verified ABI when --abi is omitted import Convert another platform's config into an rflow project start Validate then boot the engines (lazy), reconcile relayers, recover and run validate Strict collect-all validation of rflow.yaml lint Opinionated safety lint over rflow.yaml (policy-driven, static, no DB) ci One-command CI gate: validate + lint + schema/template/plan/test checks schema Print the JSON Schema for rflow.yaml (editor autocomplete/validation) trigger Manually fire a workflow ls List workflows: state, trigger, cursor, runs today status Engine health, chains, relayer registry, recent runs tables List the queryable tables (indexed event tables + the rflow journal) runs Inspect the run journal history Explore workflow run history (a richer `runs list`); subcommands: txs, approvals, waiting, export approve Approve a parked send (by approval id or run id) reject Reject a parked send (by approval id or run id) approvals List send approvals state Durable-state dashboard + repair (inspect | cursors/waits/delays/circuit) cursors Inspect (ls) or move (set) trigger cursors waits Inspect (ls) or cancel parked saga waits delays Inspect (ls) or wake parked durable delays circuit Inspect (ls) or reset circuit breakers budgets Inspect durable spend budgets (consumed / reserved / remaining) spend Native spend history: gas + value by workflow / relayer / network; subcommand: export token Manage named API bearer tokens for config.server.auth secrets Inspect declared secrets and verify external-provider resolution (never prints values) relayers Manage relayer wallet mappings workflow Pause or resume a workflow list Inspect and mutate runtime watchlists (`lists:`) mcp Serve the Model Context Protocol over stdio (AI agent/editor integration) replay Replay a workflow over historical blocks in an isolated session (dry-run by default), or manage replay/test sessions (`ls` / `prune`) versions Inspect the deployed config version history (ls | show | diff ) plan Semantic diff of a config change + active runtime conflicts (the deploy gate) rollback Reconstruct a stored config version back to rflow.yaml (structure; secrets stay placeholders) retention Plan or run journal retention (history pruning driven by config.retention) archive Export a redacted JSONL archive of runs + steps + approvals + failures in a date range command Debug a single `command:` step in isolation (no signer, no db, no run) test Execute one workflow once in dry-run mode from a fixture or a real transaction help Print this message or the help of the given subcommand(s) Options: -h, --help Print help -V, --version Print version ``` ### rflow new ```bash rflow new [--output ] [--name ] [--template ] [--yes] [--answer key=value ...] [--answers-file answers.yaml] [--registry github:owner/repo[@branch]] ``` Scaffolds a project from the **template registry** (`rflow templates ls`): `rflow.yaml`, ABIs, `.env` + `.env.example`, `docker-compose.yml` (postgres on `localhost:5448`), a `.gitignore` and a `.rflow/template-lock.yaml` recording the install (template id, version, answers). Interactive on a TTY β€” a template picker (id + risk + summary), then one typed prompt per template input; `--yes` (or a non-TTY) fills every input from its default. `--answer key=value` and `--answers-file` drive it non-interactively (CI/agents); every answer is **type-checked before rendering** (addresses, cron expressions, durations, …). `--template community/` installs from the [community registry](/templates/community-registry) β€” fetched SHA-pinned and hash-verified from the rflow repo, gated by the community doctor profile and a provenance banner before anything renders (`--registry` points the same mechanism at a fork, loudly labeled UNTRUSTED). The interactive picker's final entry fetches the community index and lists its ids. Legacy scaffold ids keep working: `transfer-alert`, `cron-report` and `treasury-approval` resolve to their registry equivalents via aliases; `liquidation-keeper`, `webhook-relay` and `blank` still scaffold through the pre-registry renderer until converted. Templates with `requires.signer` get a DEV-ONLY generated mnemonic in `.env` (never in `.env.example`). Aliased as `rflow init`. Refuses to overwrite an existing `rflow.yaml`, leaves an existing `.env` untouched, and ends with `rflow validate` + next commands. See [Scaffolding](/getting-started/scaffolding) for the wizard walkthrough. ### rflow templates ```bash rflow templates ls [--category ] [--risk ] [--json] rflow templates ls --community [--registry github:owner/repo[@branch]] rflow templates search rflow templates show rflow templates inspect --json rflow templates doctor rflow templates index [--dir ] [--check [--against ]] rflow templates scaffold [--dir ] rflow templates snapshot [--dir ] ``` Browse, inspect, check and author templates. `ls` prints id/category/risk/summary (categories: treasury, monitoring, keeper, intents, bridge-ops, governance, security, relayers, offchain, examples; risk labels: `monitor_only`, `prepares_tx`, `money_moving`, `admin`, `experimental`); `ls --community` fetches the [community registry](/templates/community-registry) index instead β€” pinned to the branch head commit, `--registry` points at a fork. `search` matches id/title/summary. `show` prints the human detail: generated files, typed inputs with defaults, required signer/relayers/ABIs/secrets, declared interfaces/endpoints, the safety profile and the docs page (deprecated templates are labeled). `inspect` prints the raw manifest as JSON for agents/tooling. `doctor` proves a bundled template is shippable β€” manifest parses strictly, declared outputs match the packaged files, interface surfaces verify (recomputed selectors/topic0s), inputs are well-formed, the sample answers render a project that passes `rflow validate`, the committed snapshot matches, and the docs page exists (skipped outside the repo). Exit 1 on any failure. The last three are the community-contribution tools: `index` regenerates `templates/community/index.json` (per-file sha256 pins; `--check` is the CI gate β€” byte-exact index + immutable versions, `--against` enforces the same immutability vs a baseline index from the base branch). `scaffold` writes a compliant community-package skeleton that passes the community doctor profile as soon as `snapshot` generates its `tests/expected-rflow.yaml`. ``` Browse, inspect, check and author templates (builtin + community registry) Usage: rflow templates Commands: ls List templates: id, category, risk, summary search Keyword search over template id/title/summary show Human-readable detail for one template inspect Machine-readable manifest for agents/tooling (always JSON) doctor Validate bundled template(s): manifest, files, inputs, sample render, snapshot, docs page index Regenerate templates/community/index.json (per-file sha256 pins over every community package) scaffold Write a compliant community-package skeleton (the submission starting point - see templates/community/SUBMITTING.md) snapshot Render an on-disk package with its answers.sample.yaml and write tests/expected-rflow.yaml (the community snapshot regenerator) help Print this message or the help of the given subcommand(s) Options: -h, --help Print help -V, --version Print version ``` See the [templates guide](/templates), the [community registry](/templates/community-registry) and the [use-case recipes](/use-cases). ### rflow add ```bash rflow add [--path ] workflow [--name ] [--answer k=v ...] [--answers-file ] [--dry-run] [--yes] [--registry ] rflow add [--path ] network [--name ] [--chain-id ] [--rpc ] [--ws ] [--confirmations ] rflow add [--path ] contract [--name ] [--abi ] (--network --address <0x..> | --addresses net=0x,net=0x) rflow add [--path ] relayer [--name ] [--networks a,b] [--speed SLOW|MEDIUM|FAST|SUPER] rflow add [--path ] notification [--name ] (--telegram | --slack | --discord | --pagerduty | --opsgenie | --twilio) [env flags] ``` `add workflow` composes a **template** into an existing project: it renders the template with your answers and merges its contracts, relayers, constants, secrets, lists, notification channels and workflow into `rflow.yaml` (comment-preserving), copying packaged files (ABIs) alongside. Inputs already satisfied by the project (its name, its single network) are not re-asked; on a TTY the remaining inputs are prompted. Collisions follow the registry rules β€” an identical entry is **reused** silently, a same-ABI/subset-addresses contract is reused with a warning, a conflicting entry is a hard error, and a taken workflow name demands `--name `. `--dry-run` prints the plan without writing; a successful add appends to `.rflow/template-lock.yaml`, refuses any change that would introduce a validation error, and prints which env vars to set. A multi-workflow template (e.g. `safe-monitor`) splices **every** workflow it declares, in authored order; `--name` only applies to single-workflow templates. `community/` composes a [community template](/templates/community-registry) the same way β€” fetched SHA-pinned + hash-verified, gated by the community doctor profile first. ``` Add a workflow from a template into the existing rflow.yaml (see `rflow templates ls`) Usage: rflow add workflow [OPTIONS]