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

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 budget

Config surface

Top-level budgets: is a map of name → budget. Each budget:

keyrequiredmeaning
windowyesThe rolling window (24h, 1h, 30m, …). Consumption is summed over sends whose reservation is younger than the window.
max_native_valueone cap requiredCap on cumulative native value. A wei amount or a unit literal (10 ether, 500 gwei).
max_token_valueone cap requiredMap of SYMBOL → raw amount. Raw units in the token's own decimals — there is no fiat oracle.
max_gas_spendCircuit-breaker cap on cumulative recorded gas spend (a wei amount or unit literal). See below.
scope.relayersOnly sends from these relayers charge the budget (absent = every relayer).
scope.networksOnly 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:

  1. Native value first. The send's native value charges max_native_value.
  2. 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.USDCmax_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_value symbol with no matching contracts.<symbol> is accepted but can never be charged — rflow validate warns 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     24h
  • reserved — in-flight sends not yet settled.
  • consumedreserved + spent (settled), i.e. what counts against the cap now.
  • remainingcap − 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.networks entry names a declared relayer / network;
  • window, max_native_value and each max_token_value amount parse;
  • a max_token_value symbol with no matching contract warns (free-form symbols are allowed but never charged).

How budgets compose

  • permissions.max_value_per_tx caps one send; a budget caps cumulative spend across many. Use both — a per-tx ceiling and a rolling blast-radius cap.
  • rate_limit caps run starts; a budget caps value. A workflow can have both. Set rate_limit.durable: true for a window that, like budgets, survives a restart.