Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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

FieldRequiredDescription
network✅A declared network. This is how cross-chain works: trigger on one, send on another
relayer✅A named relayer to send from
contractone ofTarget: a contract registry name...
toone of...or a raw 0x address (no registry entry needed)
functionSolidity signature, e.g. transfer(address,uint256)
argsArguments, each of which may be an expression
valueNative value to attach. Unit literals work: 0.1 ether, or "${{ wei('0.1', 18) }}"
dataRaw calldata alternative to function/args
simulatetruePre-flight simulation; see below
on_simulation_failabortabort | skip | continue | notify
assert_simAssertions over the simulation result (sim.*); see below
gasGas guardrails: pre-queue admission caps and the absolute hard_max_price ceiling; see below
recheckExpression re-evaluated before the relayer hand-off; see below
valid_forBounds 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_forconfirmednone | submitted | included | confirmed | confirmed(N) | finalized; see below
speedrelayer'sOverride the relayer's speed for this send: SLOW | MEDIUM | FAST | SUPER
approvalHuman 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_safePROPOSE to a Safe (delegate-signed, via the Transaction Service) instead of broadcasting; see Safe proposal mode
multicallN 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 kind simulation_failed
  • skip — the step is skipped, the run continues
  • continue — 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 }}"
PathDescription
sim.okThe call succeeded
sim.gas_usedFrom eth_estimateGas
sim.return_dataThe 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) | hold

Two 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_ceilingBehavior 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)
holdKeep 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:

TargetMeaning
noneFire and forget: the step succeeds once queued. Consecutive wait_for: none sends are nonce-ordered by the relayer and land back-to-back
submittedIn 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
finalizedReal 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

PathDescription
steps.<id>.tx.hashTransaction hash
steps.<id>.tx_idStable relayer id that survives gas-bump hash changes; prefer it for bookkeeping
steps.<id>.receiptThe receipt, once mined (with wait_for: included+)
steps.<id>.output.valueThe 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>.errorStep 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.