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| Field | Required | Description |
|---|---|---|
networks | β | Networks the relayer exists on: created on the first, cloned to the rest, same address everywhere |
speed | rrelayer transaction speed. Gas bumping/escalation is rrelayer's job | |
policy | Per-relayer guard rails, checked by rflow on every send from this relayer (see below) | |
import | Adopt 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:
-
Declare a signing provider (the key material) and named relayers (pure labels).
-
At first
rflow start(or standalone viarflow relayers sync), each unmapped name is created through the embedded rrelayer admin client (rrelayer namerflow/<name>) on its first listed network, then cloned to the rest. Clones reuse the wallet index, so the address is identical cross-chain. -
The mapping
(name β relayer id per chain, address)is persisted in therflowschema and stable forever after. -
First boot prints a funding table:
payout β 0xAbc... on ethereum, base β fund merflow relayers ls/rflow relayers balanceshow it any time; see funding operations below. Automate ongoing funding with theautomatic_top_uppassthrough.
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 actionsfunding-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 bidpending 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: 10sThe 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 (valuebudgets:charge onlysend_transactionsteps, so the cap you have here is call count, durable across restarts), andrflow replay/rflow testrehearsal. - Your relayer owns nonces, gas pricing and escalation, and receipts.
rflow's simulation gate, gas ceilings, relayer
policy:andwait_for: confirmednever see transactions it does not send, andapproval:gates attach only tosend_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 await_for:on the event it should emit, restores an on-chain confirmation gate.