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.

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:

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) and 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. 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_value symbol with no matching contracts.<symbol> is accepted but can never be charged; rflow validate warns 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     24h
  • reserved — 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.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.