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

relayers

Named wallets your workflows send transactions from. The names are your labels; rflow manages the name↔key mapping.

rflow_version: 1
name: payout-service
 
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:
  aws_kms:
    region: eu-west-1
 
relayers: 
  payout: 
    networks: [ethereum, base]     # same address on all listed chains 
    speed: FAST                    # SLOW | MEDIUM | FAST | SUPER 
    policy: 
      max_gas_price: 100 gwei      # abort sends when gas is above this 
      whitelist_receivers:         # the only addresses this relayer may send to 
        - "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"
  treasury: 
    networks: [ethereum] 
    import: { id: "b47c..." }      # adopt an existing relayer (see below) 
 
workflows:
  weekly-sweep:
    trigger:
      cron: { expression: "0 8 * * 1" }
    steps:
      - id: send
        send_transaction:
          network: ethereum
          relayer: payout
          to: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"
          value: "0.1 ether"
          wait_for: confirmed
FieldRequiredDescription
networksβœ…Networks the relayer exists on: created on the first, cloned to the rest, same address everywhere
speedrrelayer transaction speed. Gas bumping/escalation is rrelayer's job
policyPer-relayer guard rails, checked by rflow on every send from this relayer (see below)
importAdopt an existing relayer by rrelayer uuid

policy β€” per-relayer guard rails

policy.max_gas_price (a wei literal, validated statically) and policy.whitelist_receivers apply to every send from this relayer, whatever workflow issues it.

Lifecycle

Wallet creation is rflow's job:

  1. Declare a signing provider (the key material) and named relayers (pure labels).

  2. At first rflow start (or standalone via rflow relayers sync), each unmapped name is created through the embedded rrelayer admin client (rrelayer name rflow/<name>) on its first listed network, then cloned to the rest. Clones reuse the wallet index, so the address is identical cross-chain.

  3. The mapping (name β†’ relayer id per chain, address) is persisted in the rflow schema and stable forever after.

  4. First boot prints a funding table:

    payout β†’ 0xAbc... on ethereum, base β€” fund me

    rflow relayers ls / rflow relayers balance show it any time; see funding operations below. Automate ongoing funding with the automatic_top_up passthrough.

Renames are handled honestly

The YAML key is the identity: renaming it would create a new wallet. rflow detects the pattern (a mapped name gone plus a new unmapped name) and warns before creating anything:

'payout' is unmapped; orphaned relayer 'hot' still holds funds at 0xAbc...
run `rflow relayers rename hot payout` to keep the wallet, or continue to create a fresh one.

rflow relayers rename <old> <new> re-labels the mapping without touching keys. rflow relayers ls shows every mapping including orphans; removing a name from the YAML never deletes a relayer. rflow validate warns about orphans.

Importing existing rrelayer relayers

If you share an rrelayer database with other services (a standalone rrelayer, or another rflow project on the same Postgres), adopt one of its relayers instead of creating a fresh wallet: discover β†’ import β†’ verify.

rflow relayers discover                # list every relayer in the shared rrelayer db
rflow relayers import treasury --id b47c1e2a-... --network ethereum
rflow relayers import --interactive    # pick from discover, prompt for the name
rflow relayers verify-imports          # re-check every import (CI-friendly)

discover lists each relayer's uuid, address, network and a signer-compatibility check: whether your configured signing provider derives that relayer's address. Only compatible relayers can be imported.

import writes the block into rflow.yaml through the same comment-preserving editor as rflow add, refusing to clobber an existing relayer of the same name:

rflow_version: 1
name: shared-relayer-project
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
# must derive the imported relayer's address (checked before writing)
signer:
  aws_kms:
    region: eu-west-1
 
relayers:
  treasury: 
    networks: [ethereum] 
    import: 
      id: "b47c1e2a-..."
 
workflows:
  weekly-sweep:
    trigger:
      cron: { expression: "0 8 * * 1" }
    steps:
      - id: send
        send_transaction:
          network: ethereum
          relayer: treasury
          to: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"
          value: "0.1 ether"

The derived-address check runs before anything is written: a signer mismatch refuses the import, otherwise simulations would run as the old wallet while the engine signs with a different key. The same check runs at every boot (a mismatch is a boot error). rflow relayers verify-imports runs it standalone: a table of ok / MISMATCH / NOT FOUND per import, exiting non-zero on any failure so CI can gate on it. rflow doctor also checks each import's adoption state.

Funding operations

rflow relayers balance          # balances per chain with low-balance flags (--json)
rflow relayers funding-plan     # balance vs estimated need per relayer/network (--json)
rflow relayers qr payout        # the address + a terminal QR code to fund from a wallet
rflow relayers topup-plan       # which relayers are short, and by how much
rflow relayers topup --from treasury --to "*" --network ethereum --to-target --yes
rflow relayers history --since 30d   # recent gas spend + top-up actions

funding-plan estimates how much native each relayer wants: targeting money workflows Γ— sends-of-headroom Γ— safety factor Γ— live gas price Γ— typical gas (defaults 50 sends, 3Γ—, 150k gas, overridable with --sends-per-workflow / --safety-factor / --typical-gas). It is a gas-headroom heuristic, not a value forecast: transfer values are unknowable statically, so the safety factor is the knob covering them. A relayer no money workflow targets is never flagged.

topup funds shortfalls from a funding relayer as normal relayer sends: the plan prints first, --yes is required to broadcast, and the command refuses when the funding relayer is itself underfunded or cannot cover the total. Every top-up lands in the native spend ledger plus an rflow.operator_audit row, so rflow relayers history shows who topped up whom.

For automated funding, the automatic_top_up network passthrough lets rrelayer itself top relayers up from a funding relayer or Safe. The relayer-underfunded lint rule nudges money-moving projects toward it, and rflow doctor warns live when a relayer's balance drops below the funding-plan estimate. The explorer's Relayers view (backed by GET /api/relayers) shows the same address book: balances, low-balance flags, funding estimates and recent spend.

Stuck transactions

When a nonce wedges (an out-of-band send created a gap or reuse, or a bid the market ran away from), every downstream send on that relayer queues behind the stuck slot. These commands keep the fix inside rflow's journal and audit trail:

rflow relayers pending <name> [--network <net>] [--json]
# the in-flight queue: nonce, tx id, hash, current gas bid, age, status
 
rflow relayers cancel <name> --network <net> --nonce <N> --yes
# clear the slot: a value-0 self-send at the SAME nonce with a competitive fee
 
rflow relayers replace <name> --network <net> --nonce <N> --gas-multiplier <X> --yes
# rebroadcast the SAME payload at a higher bid

pending is the read-only queue view over the embedded relayer's in-flight transactions (queued and in-mempool, oldest nonce first): the first stop when sends stop confirming. cancel frees a wedged nonce with a value-0 self-send that outbids the current holder, so the original payload can never mine. replace keeps the payload and only raises the bid. A nonce held by the engine's own cancel no-op refuses replace: the upstream cancel machinery carries no gas ceiling, so --gas-multiplier could not be honored. Use cancel to re-bump a stuck no-op (a cancel always outbids the current holder).

Both mutating commands are plan-first and --yes-gated: without --yes they print exactly what would be broadcast (nonce, replaced hash, new bid) and send nothing. Every applied remediation is journaled like a top-up: an rflow.operator_audit row (who cleared what, and why) is written the moment the broadcast succeeds and completed with the terminal outcome (a CLI killed while waiting for the receipt still leaves the trail), plus a rflow.native_spend row for the remediation gas once a receipt lands, attributed to the relayers.remediate workflow. rflow relayers history and rflow spend show break-glass actions alongside normal traffic.

This is a human-invoked break-glass tool, not an unstick daemon: routine gas bumping/escalation stays rrelayer's job (speed:), and there is no MEV-style rescue (private orderflow, bundles); a cancel/replace is an ordinary public transaction that must outbid the holder. rflow doctor flags queues that look stuck (the relayer.<name>.queue.<network> check) and points at these commands.

Using relayers

  • Send steps reference them by name: send_transaction: { relayer: payout, ... }
  • Expressions can read the address: ${{ relayers.payout.address }}
  • Workflows can be restricted to specific relayers via permissions

Bring your own relayer

Teams with an in-house relayer service (own nonce, gas and receipt pipeline) can keep it and still use rflow for triggers, the journal and replay. Declare no signer: and no relayers: blocks (the embedded relayer engine never boots) and post each intent to your relayer's API with an http_call: step:

rflow_version: 1
name: forward-fill
 
# no signer: and no relayers: - the embedded relayer engine never boots
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
contracts:
  Exchange:
    abi: ./abis/exchange.json
    network: ethereum
    address: "0xDef1C0ded9bec7F1a1670819833240f027b25EfF"
 
secrets:
  RELAYER_TOKEN: ${RELAYER_TOKEN}
  RELAYER_HMAC_KEY: ${RELAYER_HMAC_KEY}
 
workflows:
  forward-fill:
    trigger:
      event:
        contract: Exchange
        name: OrderMatched
        network: ethereum
        confirmations: 3
    steps:
      - id: submit
        http_call: 
          url: ${RELAYER_URL}/v1/transactions
          headers: 
            authorization: "Bearer ${{ secrets.RELAYER_TOKEN }}"
          hmac: "${{ secrets.RELAYER_HMAC_KEY }}"   # x-rflow-signature over the body 
          body: 
            idempotency_key: "rflow-${{ run.id }}"
            order_id: "${{ trigger.args.orderId }}"
            matched_in: "${{ trigger.tx_hash }}"
        retry: 
          max_attempts: 3
          backoff: 10s

The idempotency_key derived from ${{ run.id }} makes retries safe: the run id is stable across retry: attempts, crash recovery and a dead-letter rflow runs retry, so every re-post carries the same key and your relayer can dedupe. retry: only re-fires retryable failures (5xx, 429, timeouts); a 4xx is terminal and dead-letters the run.

The split:

  • rflow keeps trigger dedupe and confirmations, the run journal and history, rate_limit: as the blast-radius cap (value budgets: charge only send_transaction steps, so the cap you have here is call count, durable across restarts), and rflow replay / rflow test rehearsal.
  • Your relayer owns nonces, gas pricing and escalation, and receipts. rflow's simulation gate, gas ceilings, relayer policy: and wait_for: confirmed never see transactions it does not send, and approval: gates attach only to send_transaction: steps, so human sign-off on posted intents has to live on your relayer's side.
  • If your API returns a transaction hash, close the loop anyway: a follow-up read: asserting the expected on-chain state, or a wait_for: on the event it should emit, restores an on-chain confirmation gate.