Native spend report
Template: native-spend-report ยท category: treasury ยท risk: monitor_only
A scheduled treasury report over rflow's own journal: every
send_transaction step journals the tx it queued โ including the native
value it attached โ so one cron plus one read-only
query: step aggregates the send history of the
last N hours into an HMAC-signed HTTP report and a chat summary. It reads
the journal; it never sends.
When to use it
- a daily "what did our automation actually spend" digest for the treasury channel
- feed a finance/ops dashboard from the same journal your workflows already write โ no extra indexing
- an independent sanity check on money-moving workflows: sends and totals, counted from the journal, not from the workflow's own logs
Generate it
# usually: add the report to the project that actually sends
rflow add workflow native-spend-report
# or standalone, pointed (via DATABASE_URL) at a sending project's journal
rflow new --template native-spend-reportNon-interactive (CI/agents):
rflow new --template native-spend-report --yes --output ./spend-report \
--answer report_cron="0 8 * * *" --answer report_window="24 hours"The generated YAML
# recipe: partial
workflows:
native-spend-report:
trigger:
cron:
expression: "0 8 * * *"
steps:
- id: spend
query:
sql: >-
WITH sends AS (
SELECT wr.workflow_name,
COALESCE((sr.output->>'value')::numeric, 0) AS value_wei
FROM rflow.step_runs sr
JOIN rflow.workflow_runs wr ON wr.id = sr.run_id
WHERE sr.tx_hash IS NOT NULL
AND sr.status = 'succeeded'
AND sr.finished_at >= now() - interval '24 hours'
)
SELECT COUNT(*) AS sends,
COUNT(DISTINCT workflow_name) AS workflows,
COALESCE(SUM(value_wei), 0) AS native_wei_out
FROM sends
- id: report
http_call:
url: https://ops.example.com/native-spend
hmac: "${{ secrets.report_hmac }}"
body:
report: native-spend
sends: "${{ steps.spend.output.sends }}"
native_out: "${{ format_units(steps.spend.output.native_wei_out, 18) }} ETH"
- id: summary
notify:
channel: ops
message: "native spend, last 24 hours: ${{ format_units(steps.spend.output.native_wei_out, 18) }} ETH across ${{ steps.spend.output.sends }} send(s) from ${{ steps.spend.output.workflows }} workflow(s)"
on_failure: dead_letterInputs
| key | type | default |
|---|---|---|
project_name | string | native-spend-report |
report_cron | cron | 0 8 * * * (daily, 08:00) |
report_window | string (N minutes|hours|days) | 24 hours |
report_url | string | https://ops.example.com/native-spend |
hmac_env | env_var | REPORT_HMAC_SECRET |
native_symbol | string | ETH |
channel | string | ops |
Required env vars
DATABASE_URL, the HMAC secret env var (default REPORT_HMAC_SECRET),
TG_BOT_TOKEN, TG_CHAT_ID โ all listed in the generated .env.example.
Safety notes
- Monitor-only: no networks, no signer โ neither embedded engine boots.
- The SQL is a single read-only statement executed inside a Postgres
READ ONLYtransaction;${{ }}insidesql:is a hard validation error by design (the window is baked in at scaffold time). - Numbers stay exact:
valueaggregates as PostgresNUMERICand crosses into expressions as an exact integer, soformat_unitsnever sees a float. - Sends journaled by rflow builds that predate value journaling have no
valueon their output and count as 0. - The report body is HMAC-SHA256-signed with
report_hmac, so the receiver can verify it โfixtures/report-body.jsonshows the exact shape.
Run it locally
docker compose up -d # postgres on localhost:5448
# fill .env (HMAC secret + telegram credentials)
rflow validate
rflow startAn empty journal reports zeros (the aggregate always returns one row). To see
real numbers, add it to a project that sends โ the query counts journaled
send_transaction steps with a tx hash โ and set report_cron to something
fast (e.g. */2 * * * *) while testing.
Production checklist
- the report reads the SAME database the sending project writes: add the
workflow to that project, or share its
DATABASE_URL - the receiving endpoint verifies the HMAC (same secret, SHA-256 over the body)
-
report_windowmatches the cron cadence (daily cron โ24 hours), or windows will overlap/gap - mind retention/pruning: the window must be shorter than how long journal rows are kept
- a missed schedule slot is skipped by default โ set
cron.catch_up: trueif a late report is better than none - pair with
workflow-error-pagerso a failing report run pages someone
Common modifications
- per-workflow breakdown:
SELECT workflow_name, COUNT(*), SUM(value_wei) ... GROUP BY workflow_nameโ N rows come through as an array of objects - track ERC20 spend instead: aggregate the indexed
transfertables (rflow_indexer_rflow_idx_<token>.transfer) โ see the query docs - alert instead of report: put the same SQL on a
query trigger with
condition: "${{ output > wei('10', 18) }}"to page when spend crosses a budget - drop the
http_call:(chat only) or thenotify:(dashboard only)