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.
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 budgetConfig 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), 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. Because the check and insert happen
under a per-(budget, asset) lock, 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 — 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 — is covered in
Reliability → Durable spend budgets.
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.
Token amounts are raw units in the token's own decimals. 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.
budgets:
solver-gas:
window: 24h
max_gas_spend: 1 ether # wei literal, validated like max_native_value
scope:
relayers: [solver]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 over-
and under-estimate, prices move between estimate and inclusion, 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 before their receipts land in the ledger, so
size the cap with headroom. Recorded spend is inspectable 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.