send_transaction
Queue a transaction through the embedded relayer.
- id: mirror
send_transaction:
network: base
relayer: payout
contract: USDC
function: "transfer(address,uint256)"
args: ["${{ contracts.Treasury.address }}", "${{ trigger.args.value }}"]
valid_for: 5m
wait_for: confirmed
gas: { max_price: 100 gwei, max_cost: 0.01 ether }
assert_sim: ["${{ sim.gas_used < 500000 }}"]
retry:
max_attempts: 3
retry_if: "${{ error.kind in ['rpc_timeout', 'rate_limited', 'gas_cap_exceeded'] }}"Fields
| Field | Required | Description |
|---|---|---|
network | ✅ | A declared network — this is how cross-chain works: trigger on one, send on another |
relayer | ✅ | A named relayer to send from |
contract | one of | Target: a contract registry name... |
to | one of | ...or a raw 0x address (no registry entry needed) |
function | Solidity signature, e.g. transfer(address,uint256) | |
args | Arguments — each may be an expression | |
value | Native value to attach — unit literals work: 0.1 ether, or "${{ wei('0.1', 18) }}" | |
data | Raw calldata alternative to function/args | |
simulate | true | Pre-flight simulation — see below |
on_simulation_fail | abort | abort | skip | continue | notify |
assert_sim | Assertions over the simulation result (sim.*) — see below | |
gas | Client-side gas guardrails — see below | |
recheck | Expression re-evaluated immediately before broadcast — see below | |
valid_for | Expiry for queued sends, e.g. 5m — a stale send never fires late | |
wait_for | confirmed | none | submitted | included | confirmed | confirmed(N) | finalized — see below |
speed | relayer's | Override the relayer's speed for this send: SLOW | MEDIUM | FAST | SUPER |
approval | Human gate on the prepared transaction: a single via: gate, a named N-of-M policy:, or ordered when: tiers (value = the send's native value in wei) — see Approvals | |
propose_to_safe | PROPOSE to a Safe (delegate-signed, via the Transaction Service) instead of broadcasting — see Safe proposal mode | |
multicall | N calls in one transaction via Multicall3 — see below |
Simulation is the default
Every send is pre-flight simulated (eth_call with the exact calldata) before it is
queued. A revert is decoded — custom errors included — into steps.<id>.error and
the step aborts. You only write simulate: false or on_simulation_fail: to change
that behavior:
abort(default) — the step fails with kindsimulation_failedskip— the step is skipped, the run continuescontinue— send anyway (you probably don't want this)notify— intended to skip-and-surface; today it still aborts the step with a warning (the notify path lands in a later phase — documented so you are not surprised)
assert_sim — gates over the simulation
assert_sim: expressions run after the simulation and see a sim root:
assert_sim:
- "${{ sim.ok }}"
- "${{ sim.gas_used < 500000 }}"| Path | Description |
|---|---|
sim.ok | The call succeeded |
sim.gas_used | From eth_estimateGas |
sim.return_data | The raw return bytes (0x-hex) |
Any false assertion aborts the step before broadcast with kind
assert_failed — a deliberate gate, never retried.
gas — client-side guardrails
gas:
max_price: 100 gwei # abort if eth_gasPrice is above this
max_cost: 0.01 ether # abort if projected limit × price is above this
limit: 500000 # the limit used by the max_cost projection
limit_from_simulation: true # ...or derive it from eth_estimateGas
multiplier: 1.2 # applied to the estimated limit (default 1.2)A tripped cap fails the step with kind gas_cap_exceeded — retryable, since
gas prices fall: a retry: with backoff: legitimately waits a spike out.
recheck — don't fire stale
recheck: is re-evaluated immediately before broadcast — after approval
waits, queue delays, retries — against the current context. False means the send
is dropped rather than fired stale:
recheck: "${{ now() - trigger.block_number * 12 < duration('10m') }}"Pair it with approval: — conditions can change while a human
decides.
multicall — N calls, one transaction
- id: harvest-all
send_transaction:
network: ethereum
relayer: keeper
multicall:
- { contract: VaultA, function: "harvest()" }
- { contract: VaultB, function: "harvest()" }
- { contract: VaultC, function: "compound(uint256)", args: ["${{ steps.plan.output }}"] }Batches every call into a single Multicall3 aggregate3 transaction. Mutually
exclusive with contract/to/function/data (validated).
wait_for
What the step waits for before the next step runs:
| Target | Meaning |
|---|---|
none | Fire and forget — the step succeeds once queued. Consecutive wait_for: none sends are nonce-ordered by the relayer and land back-to-back |
submitted | In the mempool |
included (alias mined) | In a block |
confirmed (default) | At the network's confirmations depth (default 12) — later steps can rely on steps.<id>.receipt |
confirmed(N) | An explicit depth. N at or below the network's relayer depth settles at the relayer's CONFIRMED (never shallower); N above it makes rflow verify the chain itself — polling head until the receipt is N blocks deep |
finalized | Real finality: rflow polls eth_getBlockByNumber('finalized') until the finalized block reaches the receipt block — not a confirmation-count proxy |
While waiting, the run parks durably (waiting_tx) — a restart resumes the wait, it
never re-sends.
(For waiting on someone else's event — cross-chain sagas — see the
wait_for: step, a different construct.)
What later steps see
| Path | Description |
|---|---|
steps.<id>.tx.hash | Transaction hash |
steps.<id>.tx_id | Stable relayer id — survives gas-bump hash changes; prefer it for bookkeeping |
steps.<id>.receipt | The receipt, once mined (with wait_for: included+) |
steps.<id>.output.value | The native value the send attached (wei, as a decimal string) — journaled, so spend reports can aggregate output->>'value' straight from rflow.step_runs |
steps.<id>.status / steps.<id>.error | Step outcome |
Failure semantics — who owns what
rflow owns everything before the queue: simulation, assert_sim, gas caps,
relayer policy checks, encoding, recheck, expiry (valid_for), approval gates,
and retries of pre-queue failures (rpc_timeout, rate_limited,
gas_cap_exceeded).
The relayer owns everything after: nonce management, gas pricing, gas bumping,
rebroadcast, stuck-tx replacement. rflow never re-implements these and never
blind-retries a transaction that made it onchain and reverted — a reverted tx is a
step failure (kind reverted) routed to on_failure.
Every send carries a client-side idempotency key journaled before the request, so a crash between "sent" and "recorded" is resolved by lookup, not by re-sending. The full story: Reliability.