send_transaction
Queue a transaction through the embedded relayer.
rflow_version: 1
name: usdc-mirror
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
- name: base
chain_id: 8453
rpc: ${BASE_RPC}
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
payout:
networks: [base]
contracts:
USDC:
abi: ./abis/erc20.json
addresses:
ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
Treasury:
abi: ./abis/treasury.json
network: base
address: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"
workflows:
mirror-deposits:
# trigger on ethereum, send on base - cross-chain is just two fields
trigger:
event: { contract: USDC, name: Transfer, network: ethereum }
steps:
- 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 of which 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 | Gas guardrails: pre-queue admission caps and the absolute hard_max_price ceiling; see below | |
recheck | Expression re-evaluated before the relayer hand-off; see below | |
valid_for | Bounds the send end-to-end, e.g. 5m: checked pre-queue (a stale send never fires late), and the window's remainder rides the queued transaction as its relayer-side expiry; see below | |
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; this is 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 — admission caps and the absolute ceiling
gas:
max_price: 100 gwei # pre-queue admission: abort if eth_gasPrice is above this
max_cost: 0.01 ether # pre-queue admission: 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)
hard_max_price: 120 gwei # ABSOLUTE ceiling — bounds the relayer's bidding and bumping too
on_ceiling: expire # what happens at the ceiling: expire (default) | holdTwo different guarantees live under gas:. Know which one you are reaching
for.
max_price / max_cost — pre-queue admission checks
Client-side, checked once, before queueing: rflow compares the current
eth_gasPrice (and the projected limit × price for max_cost) and refuses
to queue if the market is already above the cap. After queueing these caps are
out of the picture: rrelayer owns gas estimation, bidding and bumping, bounded
by its own global multiplier, not by max_price, so a post-queue spike
can land an admission-checked send above max_price. The same pre-queue-only
scope applies to relayers.<name>.policy: max_gas_price
and whitelist_receivers are enforced by rflow before queueing.
A tripped admission cap fails the step with kind gas_cap_exceeded, which is
retryable since gas prices fall: a retry: with backoff: legitimately
waits a spike out.
hard_max_price — the absolute ceiling
The one gas bound that is enforced inside the relayer engine: it rides the
send request into rrelayer and caps the bidding and the bump path, so no
transaction for this send is ever bid above the ceiling. on_ceiling: chooses
what happens when the market crosses it:
on_ceiling | Behavior at the ceiling |
|---|---|
expire (default) | Stop bumping: the transaction is left to expire, and the step fails with the distinct kind gas_ceiling_exceeded (dead-letter / on_failure as usual, and journaled so refused sends are auditable) |
hold | Keep bidding at exactly the ceiling until the transaction lands or expires |
With expire, a send whose first bid would already exceed the ceiling is
refused at admission: nothing queues, nothing broadcasts, and the step
fails gas_ceiling_exceeded immediately.
What ends the wait. A frozen or held transaction expires per valid_for
when the send sets one (the window counts from run creation; its remainder
rides the queue request as the relayer's per-transaction expiry). A ceilinged
send without valid_for is bounded only by the relayer's operator-wide
expiry window (RRELAYER_TRANSACTION_EXPIRATION_SECONDS, default 12
hours), so rflow validate prints an advice when hard_max_price is set
without valid_for. A per-send valid_for can only tighten the operator
window, never extend past it.
Validation requires hard_max_price >= max_price when both are set. This is
an opt-in, per-send knob for non-urgent money movement (scheduled sweeps,
treasury ops); pair it with a valid_for you are happy to wait out. Urgent
keeper/liquidation sends should not set it: bump-to-land is the desired
behavior there.
recheck — don't fire stale
recheck: is re-evaluated before the relayer hand-off, including after an
approval wait or an executor retry. False means the send is dropped. For a
preceding quote command that returns a Unix-seconds valid_until:
recheck: "${{ now() < steps.quote.output.valid_until }}"now() is current, but earlier step outputs are their journaled results:
this expression does not re-run a preceding read: or quote command. A guard
over steps.balance.output alone does not fetch a fresh balance after approval.
When enabled (the default), or required by assert_sim, the send's simulation
runs again against the configured RPC state.
This check is not repeated while a transaction waits in the relayer queue. Use
valid_for to bound that queue lifetime, and encode deadlines or other conditions
in the target contract when they must hold at execution. Block numbers are not
Unix timestamps; do not estimate freshness by multiplying a height by a presumed
block interval.
multicall — N calls, one transaction
rflow_version: 1
name: vault-keeper
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
keeper:
networks: [ethereum]
contracts:
VaultA: { abi: ./abis/vault.json, network: ethereum, address: "0xVaultA..." }
VaultB: { abi: ./abis/vault.json, network: ethereum, address: "0xVaultB..." }
VaultC: { abi: ./abis/vault.json, network: ethereum, address: "0xVaultC..." }
workflows:
harvest:
trigger:
cron:
expression: "0 * * * *"
steps:
- id: plan
read:
contract: VaultC
network: ethereum
function: "pendingRewards()"
- 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, as in 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 that 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, staleness (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, bounded by
gas.hard_max_price when the send
carries one (a ceiling breach fails distinctly with gas_ceiling_exceeded
rather than overpaying). 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.