Spend tracking — what rflow actually paid
Budgets cap what a send may spend; operators also need to know what rflow
did spend. Spend tracking is a durable, receipt-derived ledger
(rflow.native_spend) written at every send's terminal settle, answering:
how much native gas did rflow burn — by workflow, by relayer, by network —
how much native value moved, and how much gas was lost to reverted
transactions?
It ships as 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) — 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 discipline — honest limits included:
- Best-effort, never blocking. The ledger is operator accounting, not part of the money path: 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, so there is nothing to record. 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 in the ledger under the synthetic workflow namerelayers.topup(attributed to the funding relayer), so relayer-funding gas shows up in the same summaries — 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, spend history is an accounting record that
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:
budgets:
solver-gas:
window: 24h
max_gas_spend: 1 ether # wei literal, like max_native_value
scope:
relayers: [solver]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 pre-send
estimates are unreliable (nodes over- and under-estimate, prices move between
estimate and inclusion, L2 fee components are invisible pre-send). Charging
estimates would either falsely refuse sends or leave phantom spend in the
window. The cap is therefore 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 in the ledger. 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.