Stream Health Monitor & Auto-Top-Up Vault for the Drips protocol.
A full-stack (contracts + backend worker) system that watches Drips streams, detects when an account's effective balance is running low, and automatically tops it up from a sponsor-managed vault — without the sponsor paying gas on every execution.
- Smart contracts — a permissionless
TopUpVaultthat refills Drips accounts when their balance drops below a configured threshold, plus aDripsBalanceReaderthat exposes live, per-second-decayed balances on-chain. - Backend — an Express API + background monitor worker that checks account health, triggers top-ups via a keeper wallet (gasless for the sponsor), and alerts operators over email / Discord / Telegram.
- Frontend — planned (see Known limitations).
- How it works
- Repository layout
- Prerequisites
- Quickstart (local)
- Configuration
- Smart contracts
- Backend
- Testing
- Scripts
- Deploying to a real network
- Security notes
- Known limitations
┌─────────────────────────────────────────────────────┐
│ Drips Vault │
│ │
sponsor ───────►│ TopUpVault (contracts) DripsBalanceReader │
deposits │ │ deposit/withdraw (live balance oracle)│
reserve │ │ executeRule() │
│ │ topUp(amount) │
│ └─────────┬───────────────────────────────┬──────┘
│ │ pushes funds via │ reads
│ GiversRegistry.give(accountId, token) │ balanceAt
└──────────────┼───────────────────────────────┼──────┘
▼ ▼
┌─────────────────────────────────────────────┐
│ Drips protocol (v2 / mocks) │
│ stream receivers, splittable, collectable │
└─────────────────────────────────────────────┘
▲
│ watches & keeps healthy
┌────────┴──────────────────┐
│ Backend monitor worker │
│ • health checks │
│ • streams cache (events) │
│ • keeper relayer │
│ • alerts │
│ • REST API │
└───────────────────────────┘
- A sponsor deposits tokens into a
TopUpVaultand defines a rule: top uptopUpAmountwhenever the account's effective balance falls belowthreshold, with acooldownbetween executions. - The
DripsBalanceReaderkeeps an account's receiver list on-chain (verified against the DripsstreamsStatehash) so the live, decayed balance can be computed viaDrips.balanceAt(...)— no off-chain math needed. - The backend worker polls accounts on a timer, computes each account's effective balance (
streams + splittable + collectable), and:- runs
executeRule()permissionlessly through the keeper wallet when the balance is below the threshold (the keeper pays gas and is reimbursed in the vault token whenkeeperFeeis set), or - falls back to a forced
topUp(amount)if the rule is disabled/paused.
- runs
- If a balance can't be restored, the worker raises alerts (email / Discord / Telegram) with cooldown deduplication, and records a check history.
drips-vault/
├── package.json # npm workspaces root
├── packages/
│ ├── contracts/ # Solidity + Hardhat
│ │ ├── contracts/
│ │ │ ├── TopUpVault.sol
│ │ │ ├── DripsBalanceReader.sol
│ │ │ ├── interfaces/ # IDrips, IGiversRegistry, IDripsBalanceReader
│ │ │ └── mocks/ # MockDrips, MockERC20, MockGiversRegistry
│ │ ├── scripts/deploy.ts # local + real-network deploys
│ │ ├── abis/ # generated ABI JSON (committed)
│ │ ├── deployments/ # local.json (committed local dev addresses)
│ │ ├── test/ # Hardhat + Mocha/Chai tests
│ │ └── hardhat.config.ts
│ └── backend/ # Express API + monitor worker (TypeScript, ESM)
│ ├── src/
│ │ ├── index.ts # entrypoint: server + worker loop
│ │ ├── api.ts # Express routes + API-key middleware
│ │ ├── monitor.ts # health checks + auto top-up + alerts
│ │ ├── relayer.ts # vault rule execution (keeper)
│ │ ├── streams.ts # stream config, effective balance, runway
│ │ ├── streams-cache.ts # reconstruct receiver lists from events
│ │ ├── alerts.ts # SMTP / Discord / Telegram notifiers
│ │ ├── db.ts # node:sqlite database
│ │ ├── config.ts # env parsing
│ │ └── abi/generated.ts # generated contract ABIs
│ ├── scripts/ # update-abis, demo-setup, re-arm
│ ├── test/ # Vitest + Supertest
│ └── .env.example
- Node.js ≥ 20 (backend uses the built-in
node:sqlite) - npm (workspaces +
allowScriptstrust store) - Foundry
anvil— used as the local EVM chain vianpm run chain(ornpx anvil) - Git
From the repo root:
# 1. Install dependencies (one time)
npm install
# 2. Start a local chain (anvil, chain id 31337, 1s block time)
npm run chain
# 3. In a second terminal: compile + deploy the contracts locally
npm run deploy:local
# 4. In a third terminal: run the backend (Express + monitor worker)
npm run dev:backendThe API is then available at http://localhost:3000 (see API reference).
npm run demo:setupThis wires up a realistic demo against the local chain:
- a sponsor streams 5 USDC/s to a dependency account for 7 days,
- the reader is pointed at the account and the vault keeper is set,
- the vault is funded with a 100,000 USDC reserve,
- the account is registered with the backend API (alert threshold 2,500 USDC, runway 5 min),
- the worker starts checking and will execute a top-up as soon as the balance crosses the threshold.
Check the result: GET http://localhost:3000/api/accounts and the /api/accounts/:id/checks history.
Backend config lives in packages/backend/.env.example — copy it to .env:
cp packages/backend/.env.example packages/backend/.envFor local development you can leave everything at its default (protocol addresses are auto-loaded from packages/contracts/deployments/local.json). See Environment variables for the full reference.
The core auto-top-up vault. Sponsors deposit tokens and define a rule per account:
Rule = { threshold, topUpAmount, cooldown, lastTopUpAt, enabled }
Key functions:
| Function | Description |
|---|---|
deposit(amount) / depositFrom(account, amount) |
Sponsor funds the vault reserve. |
withdraw(amount) |
Sponsor pulls unused reserve back (protects _freeReserve()). |
updateRule(...) |
Owner sets/updates a rule for an account (threshold, top-up amount, cooldown, enable/disable). |
executeRule() |
Permissionless — anyone can trigger a top-up when an account's effective balance drops below its threshold; the caller (keeper) pays gas and is reimbursed keeperFee in the vault token. |
topUp(amount) |
Keeper/owner-only forced top-up (used when a rule is disabled/paused). |
setKeeper / setKeeperFee |
Owner configures the gas-reimbursed keeper. |
setBalanceReader |
Point the vault at a DripsBalanceReader. |
pause / emergencyWithdraw |
Emergency controls; emergencyWithdraw returns sponsor reserve. |
Executions push funds protocol-natively: tokens are transferred to the account's Giver and then give(accountId, token) is called, crediting the account's splittable/collectable balance. effectiveBalance() = reader's currentBalance() (or the raw streams balance fallback) + splittable + collectable.
An on-chain balance oracle: stores an account's current receiver list and verifies it against Drips.hashStreams(receivers) and the account's streamsState hash, so currentBalance() can return the live, per-second-decayed balance via Drips.balanceAt(...). Falls back to the last-known balance when the stored list is stale or absent.
interfaces/IDrips.sol— minimal Drips v2 surface (streamsState,balanceAt,hashStreams,splittable,collectable,give) +StreamReceiverpacking helpers.interfaces/IGiversRegistry.sol,interfaces/IDripsBalanceReader.sol.mocks/—MockDrips(self-contained Drips v2 emulation),MockERC20,MockGiversRegistry— used for local dev and tests only.
The mocks emulate the real protocol for local development. On real networks the real Drips + GiversRegistry addresses are used (see
networks.json).
| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness probe (always allowed). |
GET |
/api/accounts |
List registered accounts (with latest check). |
POST |
/api/accounts |
Register an account { accountId, token, autoTopUp?, alertThreshold?, runwayAlertSecs? }. |
GET |
/api/accounts/:id |
Account detail. |
PATCH |
/api/accounts/:id |
Partial update. |
DELETE |
/api/accounts/:id |
Remove an account. |
POST |
/api/accounts/:id/check |
Run a manual health check now. |
POST |
/api/accounts/:id/topup |
Trigger executeRule() (or forced topUp) via the keeper; body { amount? }. |
POST |
/api/accounts/:id/reader-refresh |
Refresh the on-chain reader's receiver list. |
GET |
/api/accounts/:id/checks?limit= |
Check history (max 200). |
GET |
/api/notifications?accountId=&limit= |
Alert/notification history. |
GET |
/api/settings/alert-channels |
Redacted channel config. |
PUT |
/api/settings/alert-channels |
Update runtime alert channels (email/discord/telegram). |
GET |
/api/status |
Chain block/chainId/rpc, account count, channels, last scanned block. |
When API_KEY is set, all routes except /health require the x-api-key header.
| Variable | Default | Description |
|---|---|---|
RPC_URL |
http://127.0.0.1:8545 |
EVM RPC endpoint. |
CHAIN_ID |
31337 |
Expected chain id. |
DRIPS_ADDRESS |
from deployments/local.json |
Drips protocol address. |
GIVERS_REGISTRY_ADDRESS |
from deployments/local.json |
GiversRegistry address. |
TOKEN_ADDRESS |
from deployments/local.json |
Token to monitor (e.g. USDC). |
VAULT_ADDRESS |
from deployments/local.json |
TopUpVault address. |
READER_ADDRESS |
from deployments/local.json |
DripsBalanceReader address. |
KEEPER_PRIVATE_KEY |
Anvil dev key #1 | Keeper wallet paying gas for top-ups (dev-only default!). |
CHECK_INTERVAL_SECS |
30 |
Monitor worker poll interval. |
ALERT_COOLDOWN_SECS |
300 |
Min. seconds between alerts per account. |
PORT |
3000 |
HTTP server port. |
CORS_ORIGIN |
http://localhost:5173 |
Comma-separated allowed origins. |
API_KEY |
(empty) | When set, requires x-api-key on all routes except /health. |
DB_PATH |
./data/monitor.db |
SQLite database file. |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM / ALERT_EMAIL_TO |
(empty) | Email alerts (disable by leaving SMTP_HOST empty). |
DISCORD_WEBHOOK_URL |
(empty) | Discord webhook alerts. |
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID |
(empty) | Telegram alerts. |
npm test # contracts (hardhat test) + backend (vitest run)
npm run test:contracts # only Solidity tests (14 tests: vault, reader, mocks)
npm run test:backend # only backend tests (api/repo/streams)
npm run lint # backend typecheck (tsc --noEmit)- Contracts: Mocha/Chai via
@nomicfoundation/hardhat-toolbox— admin access control, deposits/withdrawals, threshold/cooldown rule execution, keeper fees, emergency withdrawal, pause/disable paths. - Backend: Vitest + Supertest — API CRUD & validation, API-key auth, repo layer, stream packing/effective-balance/runway math.
| Command (from root) | What it does |
|---|---|
npm run chain |
Start anvil (chain id 31337, 1s block time). |
npm run deploy:local |
Compile + deploy mocks, reader and vault; writes deployments/local.json. |
npm run dev:backend |
Run the backend in watch mode (tsx watch). |
npm run demo:setup |
Seed the local demo (streams, vault reserve, account registration). |
npm run build:contracts / build:backend |
Compile contracts + ABIs / TypeScript build. |
npm run test |
Full test suite. |
Package-level helpers:
packages/contracts:npm run abi(regenerateabis/*.json),npm run deploy:hardhat.packages/backend:npm run check-once(run one monitor cycle then exit),npm run typecheck,tsx scripts/re-arm.mts(re-fund the demo sponsor's stream after depletion).
-
Configure
packages/contracts/hardhat.config.tsnetworks (sepolia,mainnet) withRPC_URL_SEPOLIA/RPC_URL_MAINNETandDEPLOYER_PRIVATE_KEY. -
Create/edit
packages/contracts/networks.jsonso the target network entry has the real Drips and GiversRegistry addresses (protocol reference addresses are pre-populated formainnet/sepolia/optimism; verify them before deploying). -
Deploy:
npm run deploy:local --workspace @drips-vault/contracts # local only npx hardhat run scripts/deploy.ts --network sepolia # testnet npx hardhat run scripts/deploy.ts --network mainnet # mainnet
The script deploys
TopUpVault+DripsBalanceReaderand writesdeployments/<network>.json. For real networks it readsTOKEN_ADDRESS(ornetCfg.usdc),ACCOUNT_ID(or derives it fromSPONSOR_ADDRESS) andVAULT_OWNER. -
Point the backend at the deployed addresses via env vars (or leave unset — it falls back to
deployments/local.json), set a realKEEPER_PRIVATE_KEY, and restart.
- Keeper key is a dev placeholder: the default
KEEPER_PRIVATE_KEYis Anvil's public dev key #1 — never use it outside local development. executeRule()is permissionless by design: anyone can trigger a top-up when the threshold is crossed, so the sponsor never pays gas — but the vault'stopUpand admin functions are owner/keeper-gated.- Emergency controls (
pause,emergencyWithdraw) protect the sponsor reserve. - The API supports an optional
API_KEYguard on all routes except/health. - Alert-channel settings can be updated at runtime via the API and are persisted in SQLite.
- Frontend is not yet implemented. The
packages/frontendworkspace is declared in the rootpackage.jsonbut does not exist yet, so the rootbuild,dev:frontend,build:frontendand the frontend leg oflintwill error until it is added. Usenpm run build:contracts/build:backend/testindividually. - No linter/formatter is configured for the backend (the
lintscript is a TypeScript typecheck). TopUpVault.solreferences adocs/ARCHITECTURE.mdthat does not exist yet — architecture notes live in this README for now.- The committed
deployments/local.jsonandpackages/backend/data/*.dbfiles are local-dev runtime artifacts; in production you may want to exclude them from version control.
No license is declared for the repository. TopUpVault.sol is MIT-licensed; the Drips protocol is covered by its own terms — check before redistributing.