Spend tracking — what rflow actually paid
Budgets cap what a send may spend; spend tracking records what rflow did
spend. It is a durable, receipt-derived ledger (rflow.native_spend) written
at every send's terminal settle: native gas burned (by workflow, relayer,
network), native value moved, and gas lost to reverted transactions.
Three surfaces over one ledger, so the numbers can never diverge:
- a CLI:
rflow spend(grouped summary) andrflow spend export(raw entries, CSV/JSON), - a read-only JSON API:
GET /api/spend(auth-gated like every/api/*route), - a Spend view in the history explorer
(
GET /→ Spend).
And one enforcement hook: a budget's
max_gas_spend
cap refuses further sends once the recorded gas spend in its window reaches
the cap.
What is captured
When a send reaches a terminal state where a transaction landed on-chain,
rflow reads the receipt (the relayer's journaled receipt when it carries gas
numbers, otherwise one eth_getTransactionReceipt over the read providers)
and records:
| column | meaning |
|---|---|
gas_used | The receipt's gasUsed. |
effective_gas_price | The receipt's effectiveGasPrice (falls back to the legacy gasPrice on pre-EIP-1559 chains). |
gas_spend_wei | gas_used × effective_gas_price, plus the receipt's l1Fee when present (the OP-stack L1 data fee Base/Optimism debit on top of the L2 execution fee); this is what the chain actually charged. |
value_wei | The native value the send moved (from the journaled send value). Zero for reverted sends: a revert burns gas but transfers nothing. |
reverted | Whether the transaction reverted on-chain (its gas is the reverted loss subtotal). |
Every row is attributed to the workflow, run, step, relayer, network and
chain id of the send, plus the transaction hash and a recorded_at
timestamp. The three query dimensions (workflow / relayer / network) are
indexed.
Capture limits:
- Best-effort, never blocking. A failed receipt fetch logs a warning and never fails the step: a gap in the ledger is possible; a broken send because of the ledger is not.
- Idempotent. Rows are keyed
UNIQUE (step_run_id, tx_hash)and inserted withON CONFLICT DO NOTHING, so a crash-rerun of the same terminal settle never double-counts. - Only landed transactions. Confirmed sends and reverted sends (status
FAILEDwith a receipt) are recorded; dropped / expired / replaced sends never landed. Sends withwait_for: none | submittedsettle before a receipt exists: their spend is recorded only if a receipt is already available at settle time, so fire-and-forget sends can be under-counted. - Native currency only. ERC20 flows are budget territory
(
max_token_value); there is no fiat conversion. - Top-ups are recorded too. A
rflow relayers topupsend lands under the synthetic workflow namerelayers.topup(attributed to the funding relayer), alongside itsrflow.operator_auditrow (rflow relayers history).
Retention
rflow.native_spend deliberately has no foreign key onto the run journal:
like the operator audit trail, it survives
retention pruning of the runs it
describes. Pruning old runs never touches recorded spend.
Budget integration — max_gas_spend
A budget may declare a gas circuit breaker:
rflow_version: 1
name: solver
config:
port: 3947
db_connection: ${DATABASE_URL}
networks:
- name: base
chain_id: 8453
rpc: ${BASE_RPC}
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
solver:
networks: [base]
budgets:
solver-gas:
window: 24h
max_gas_spend: 1 ether # wei literal, like max_native_value
scope:
relayers: [solver]
workflows:
rebalance:
trigger:
cron: { expression: "*/5 * * * *" }
# sends in this workflow charge the budget (scope must match too)
budgets: [solver-gas]
steps:
- id: rebalance
send_transaction:
network: base
relayer: solver
to: "0x000000000000000000000000000000000000dEaD"
value: "${{ wei('0.1', 18) }}"Before every in-scope send broadcasts, rflow sums the recorded gas spend
inside the budget's scope and window; once that total has reached the cap the
send fails budget_exceeded: same failure taxonomy, same
before-any-broadcast guarantee as the value caps.
Recorded-spend semantics, on purpose. Unlike max_native_value this is
not a reservation: actual gas is only known at the receipt, and charging
unreliable pre-send estimates would either falsely refuse sends or leave
phantom spend in the window. The cap is a circuit breaker over settled
truth, not a precise limiter: sends already in flight when the cap is
reached can overshoot it by their own gas before their receipts land. Size
the cap with that headroom in mind.
CLI
rflow spend # grouped summary: by workflow / relayer / network + totals
rflow spend --workflow treasury-sweep --since 30d # filter any dimension + time range
rflow spend --relayer solver --network base --json # exact wei, machine-readable
rflow spend export --format csv # raw entries (stable columns), stdout
rflow spend export --format json --since 7d # { "entries": [...] }Human tables format amounts as the native unit; --json and export carry
exact wei (decimal strings; uint256 does not fit JSON numbers). The CSV
columns are a stable contract:
recorded_at,workflow,relayer,network,chain_id,tx_hash,gas_used,effective_gas_price,gas_spend_wei,value_wei,reverted,run_id,step_run_id.
API
GET /api/spend accepts workflow, relayer, network, since (24h,
30d or RFC3339) and limit (entries page size, default 50, max 500) as
query parameters and returns:
{
"summary": {
"by_workflow": [ { "key": "treasury-sweep", "tx_count": 12, "gas_spend_wei": "…", "value_wei": "…", "reverted_count": 0, "reverted_gas_wei": "0" } ],
"by_relayer": [ { "key": "solver", "…": "…" } ],
"by_network": [ { "key": "base", "…": "…" } ],
"total": { "tx_count": 12, "gas_spend_wei": "…", "value_wei": "…", "reverted_count": 0, "reverted_gas_wei": "0" }
},
"entries": [ { "recorded_at": "…", "workflow": "…", "relayer": "…", "network": "…", "chain_id": 8453, "tx_hash": "0x…", "gas_spend_wei": "…", "value_wei": "…", "reverted": false, "run_id": "…", "step_run_id": "…" } ]
}The summary always aggregates the full filtered ledger; limit only pages
the entries. The endpoint is gated by the same bearer auth as every /api/*
route and served Cache-Control: no-store.
Explorer
The Spend view in the embedded explorer shows the same summary: KPI tiles (gas spend, value moved, reverted loss, send count), one table per dimension (clicking a row filters to it) and the newest settled sends with explorer links. Filters (workflow / relayer / network / time range) live in the URL hash like every other view.