simulate
Rehearse arbitrary calldata and report the outcome, without a relayer, keys,
or any intent to broadcast. A simulate: step runs the call through the
network's read provider and journals the result as structured step output.
It is the same eth_call-semantics simulation as
send_transaction's pre-flight gate:
eth_simulateV1 where the node supports it (adds gas + logs), plain
eth_call + eth_estimateGas otherwise. No relayer handle is ever in scope,
so a simulate: step cannot send money and is fully valid in keyless,
monitor-only projects.
rflow_version: 1
name: governance-rehearsals
config:
port: 3940
db_connection: ${DATABASE_URL}
# keyless: no signer:, no relayers: — the relayer engine never boots
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
contracts:
Governor:
abi: ./abis/governor.json
network: ethereum
address: "0xGov..."
constants:
timelock: "0xTim..."
queued_proposals:
- proposal_id: 42
workflows:
rehearse-proposals:
trigger:
cron:
expression: "0 */6 * * *"
steps:
- id: rehearse
foreach: "${{ constants.queued_proposals }}"
simulate:
network: ethereum
from: "${{ constants.timelock }}" # optional — default zero address
contract: Governor
function: "execute(uint256)"
args: ["${{ item.proposal_id }}"]
value: "0"Fields
| Field | Required | Description |
|---|---|---|
network | ✅ | A declared network; the call runs on its read provider |
contract | ◐ | A contract registry name, used with function/args |
function | ◐ | Solidity signature, e.g. execute(uint256) |
args | Arguments; each may be an expression | |
to | ◐ | Raw target address, used with calldata (third-party payloads) |
calldata | ◐ | Raw 0x-hex calldata: the exact bytes to rehearse, untouched |
from | The simulated sender (msg.sender); default the zero address. Set it to the address that would really execute (a timelock, a bridge executor) so balance/allowance/role checks see the truth | |
value | Native value to attach, e.g. "0" (the default) |
contract/function/args and raw to/calldata are mutually
exclusive: validation enforces exactly one target form and mirrors
send_transaction's argument checks (arity, types, address shapes).
Output
The structured outcome becomes steps.<id>.output (and lands in the run
journal, so rflow runs show and the history explorer carry it):
{
"success": true,
"revert_reason": null,
"gas_used": 54321,
"return_data": "0x0000000000000000000000000000000000000000000000000000000000000001",
"logs": []
}| Field | Description |
|---|---|
success | Whether the call succeeds against current chain state |
revert_reason | The decoded revert reason when it reverts (the standard Error(string) payload, the same decoding a send's simulation gate reports); null on success |
gas_used | The gas the simulated execution used (eth_simulateV1; eth_estimateGas on the fallback path); null when neither enrichment call could produce a figure (the key is always present) |
return_data | The raw 0x-hex return data |
logs | The events the call would emit, decoded when the ABI is known (the same decoding the event trigger uses) |
Only eth_simulateV1 can surface a call's logs, and not every RPC provider
serves it (geth ≥ 1.14, recent reth, anvil do). Without it the step falls
back to plain eth_call + eth_estimateGas: the report is just as valid,
but logs stays empty.
The verdict eth_call and the gas/logs enrichment are separate
round-trips, and state can move between them. Enrichment never fails a
rehearsal the eth_call already proved successful: if the fallback
eth_estimateGas reverts or errors, the successful report stands with
gas_used: null (logs: []); if eth_simulateV1 instead reports the call
reverting, the step re-runs the authoritative eth_call once and reports
that fresh outcome: success, or success: false with the decoded reason.
Monitor-only by construction
simulate: needs a network and (for the registry form) a contract, nothing
else. In a project without signer: or relayers: the relayer engine never
boots. rflow explain
classifies simulate: steps as read-only, and a workflow whose only chain
interaction is a simulate: step keeps the monitor_only risk label.
The governance seatbelt pattern
The canonical use: every few hours, read the queued proposals, rehearse each proposal's calldata as the timelock, and post the outcome for human review before the timelock executes. A keyless project runs the whole loop:
rflow_version: 1
name: governance-seatbelt
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
contracts:
Governor:
abi: ./abis/governor.json
network: ethereum
address: "0xGov..."
constants:
timelock: "0xTim..."
notifications:
channels:
gov-slack:
slack:
webhook_url: ${SLACK_WEBHOOK_URL}
workflows:
governance-seatbelt:
trigger:
cron:
expression: "0 */6 * * *" # every 6 hours
steps:
- id: proposals
http_call:
url: "https://your-indexer.example/governor/queued"
- id: rehearse
foreach: "${{ from_json(steps.proposals.output.body).proposals }}"
simulate:
network: ethereum
from: "${{ constants.timelock }}"
contract: Governor
function: "execute(uint256)"
args: ["${{ item.id }}"]
- id: report
foreach: "${{ from_json(steps.proposals.output.body).proposals }}"
notify:
channel: gov-slack
message: >-
proposal ${{ item.id }}:
${{ 'SIMULATES OK (gas ' ~ steps.rehearse.output[item_index].gas_used ~ ')'
if steps.rehearse.output[item_index].success
else 'REVERTS — ' ~ steps.rehearse.output[item_index].revert_reason }}A foreach: parent settles with
output = the array of iteration outputs, so the report step indexes the
rehearsal results by item_index. The same shape covers pre-flight checks on
any third-party calldata: incoming bridge messages, queued timelock
operations, partner-submitted payloads. Rehearse the exact bytes with the
raw form:
rflow_version: 1
name: bridge-payload-check
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
contracts:
Bridge:
abi: ./abis/bridge.json
network: ethereum
address: "0xBri..."
constants:
executor: "0xExe..."
workflows:
rehearse-bridge-messages:
trigger:
event: { contract: Bridge, name: MessageReceived, network: ethereum }
steps:
- id: rehearse
simulate:
network: ethereum
from: "${{ constants.executor }}"
to: "${{ trigger.args.target }}"
calldata: "${{ trigger.args.payload }}"Non-goals (v1)
- No fork environments or state overrides — the call runs against the node's current state (Tenderly-Virtual-TestNet territory is out of scope).
- No storage-diff output — success / revert reason / gas / logs is the deliberate 80%.
- No bundle / multi-transaction simulation — one call per step; sequence
steps (or
foreach:) for several independent rehearsals.
A simulation is evidence about current state, not a guarantee about
execution-time state. For sends, that gap is what
recheck: and
the send's own pre-flight gate exist for.