Budgets — durable spend caps
permissions.max_value_per_tx caps one send. A budget caps
cumulative spend inside a rolling window across every workflow, run and
send that attaches it: the blast-radius cap when something upstream misbehaves
(a bad price, a runaway loop, a compromised trigger). Budgets are durable and
reservation-based: racing runs cannot overshoot a cap, and a restart cannot
reset one.
rflow_version: 1
name: treasury-ops
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
treasury:
networks: [ethereum]
contracts:
USDC:
abi: ./abis/erc20.json
network: ethereum
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
constants:
vault: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"
budgets:
treasury-daily:
window: 24h
max_native_value: 10 ether # cap on cumulative native value in the window
max_token_value:
USDC: "1000000" # raw token units (matches contracts.USDC)
scope:
relayers: [treasury] # only sends from these relayers charge it
networks: [ethereum] # only sends on these networks charge it
workflows:
sweep:
budgets: [treasury-daily] # this workflow's sends charge the budget #
trigger:
cron:
expression: "0 * * * *"
steps:
- id: sweep
send_transaction:
network: ethereum
relayer: treasury
contract: USDC
function: "transfer(address,uint256)"
args: ["${{ constants.vault }}", "1000000"]Config surface
Top-level budgets: is a map of name → budget. Each budget:
| key | required | meaning |
|---|---|---|
window | yes | The rolling window (24h, 1h, 30m, …). Consumption is summed over sends whose reservation is younger than the window. |
max_native_value | one cap required | Cap on cumulative native value. A wei amount or a unit literal (10 ether, 500 gwei). |
max_token_value | one cap required | Map of SYMBOL → raw amount. Raw units in the token's own decimals; there is no fiat oracle. |
max_gas_spend | Circuit-breaker cap on cumulative recorded gas spend (a wei amount or unit literal). See below. | |
scope.relayers | Only sends from these relayers charge the budget (absent = every relayer). | |
scope.networks | Only sends on these networks charge the budget (absent = every network). |
A workflow opts in with workflows.<wf>.budgets: [name, ...]. A send charges a
budget only when the workflow attaches it and the send's relayer/network
match the budget's scope.
How a send charges a budget
Before a send broadcasts, after every other check (permissions, simulation, gas cap, recheck, approval) and right before the relayer call, it reserves its value against each attached, in-scope budget:
- Native value first. The send's native
valuechargesmax_native_value. - One ERC20 amount second. See the decoding rules below.
The reservation is checked atomically against
(live reservations + settled spend in the window) + amount ≤ cap. If it would
exceed the cap the send fails budget_exceeded,
the run dead-letters per its
on_failure, and nothing broadcasts: the relayer never sees it. The check
and insert happen under a per-(budget, asset) lock, so concurrent runs racing
the same budget can never collectively exceed the cap.
The reservation is a durable row keyed by the send's idempotency external_id,
so it is exactly-once and crash-safe. Its lifecycle is covered in
Reliability → Durable spend budgets:
reserve → spent on a successful hand-off, released (refunded) on a proven
failure or a terminal on-chain revert/drop/expire (an ambiguous failure is
left reserved for the reconcile, since the tx may have landed), and recovery
reconcile by external_id after a crash.
ERC20 charging is second-class
An ERC20 amount is decoded and charged only when a single-call send is an
unambiguous transfer / transferFrom / approve whose contract registry
name equals the token symbol in max_token_value (e.g. contracts.USDC ↔
max_token_value.USDC). Everything else is native-only:
- Multicall batches are native-only (the batch's aggregate value still
charges
max_native_value). - Any other function, or a
contract:whose name is not a budget symbol, is native-only. - A
max_token_valuesymbol with no matchingcontracts.<symbol>is accepted but can never be charged;rflow validatewarns so the gap is visible.
There is no fiat oracle and no cross-asset conversion in the first version.
max_gas_spend — the gas circuit breaker
Value caps guard what a send transfers; max_gas_spend guards what rflow
pays in gas. It is checked against the receipt-derived
spend ledger (rflow.native_spend,
gas_used × effective_gas_price of every settled send) before each in-scope
send: once the recorded gas spend inside the budget's scope and window has
reached the cap, further sends fail budget_exceeded before any broadcast.
rflow_version: 1
name: solver
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
solver:
networks: [ethereum]
budgets:
solver-gas:
window: 24h
max_gas_spend: 1 ether # wei literal, validated like max_native_value
scope:
relayers: [solver]
workflows:
fill-intent:
budgets: [solver-gas]
trigger:
webhook:
path: /hooks/fill
steps:
- id: fill
send_transaction:
network: ethereum
relayer: solver
to: "${{ trigger.args.to }}"
data: "${{ trigger.args.calldata }}"Unlike the value caps this is not a reservation: actual gas is only known
at the receipt, and pre-send estimates are deliberately excluded (nodes
mis-estimate, prices move, L2 fee components are invisible pre-send). It is a
circuit breaker over settled truth, not a precise limiter: sends already
in flight when the cap trips can overshoot it by their own gas, so size the
cap with headroom. Inspect recorded spend with
rflow spend.
Inspecting consumption — rflow budgets ls
rflow budgets ls
# budget asset cap consumed reserved remaining window
# treasury-daily native 10000000000000000000 3000... 1000... 6000... 24h
# treasury-daily USDC 1000000 250000 0 750000 24hreserved— in-flight sends not yet settled.consumed—reserved + spent(settled), i.e. what counts against the cap now.remaining—cap − consumed.
All amounts are raw units (wei for native, the token's own decimals for ERC20)
inside the budget's current rolling window. rflow runs show <id> and
GET /api/runs/{id} list the budgets each run's sends charged, with amounts and
status.
Validation
rflow validate (and rflow doctor) check budgets statically:
- every name in a workflow's
budgets: [...]resolves to a defined budget; - every
scope.relayers/scope.networksentry names a declared relayer / network; window,max_native_valueand eachmax_token_valueamount parse;- a
max_token_valuesymbol with no matching contract warns (free-form symbols are allowed but never charged).
How budgets compose
permissions.max_value_per_txcaps one send; a budget caps cumulative spend across many. Use both: a per-tx ceiling and a rolling blast-radius cap.rate_limitcaps run starts; a budget caps value. A workflow can have both. Setrate_limit.durable: truefor a window that, like budgets, survives a restart.