Small container that calls permissionless maintenance functions on Bread contracts when they come due.
| Job | Contract | Schedule |
|---|---|---|
distribute-yield |
Yield distributor on Gnosis, 0xeE95A62b749d8a2520E0128D9b3aCa241269024b |
Polled every 30 min |
saving-circles gnosis |
AutomaticSavingCircles on Gnosis, 0xdbe5e71950614A9508655Ca157EeD0B3F4167d78 |
Polled hourly |
saving-circles celo |
AutomaticSavingCircles on Celo, 0x8c6e6849F488419cd2e9DBBb79A8e73B10c9Dd51 |
Polled hourly |
The contract tracks its own cycle (cycleLength, 518400 blocks, about 30 days) and exposes
resolveYieldDistribution(). The job sends distributeYield() only when the resolver says the
cycle is ready.
Because of this, a missed or failed run is just retried on the next tick, and the contract rejects a second call in the same cycle. Prompt execution still matters: the next cycle is counted from the block of the actual call, so every delay pushes all later distributions back by the same amount.
Each run:
- picks the first reachable RPC from
RPC_URLS - alerts if the keeper balance is below
MIN_BALANCE_XDAI - alerts if the cycle ended more than ~6h ago but the resolver still says not ready
- sends with a gas limit of 130% of the estimate
- treats "another caller distributed first" as success
Saving Circles v1.1.0 automates member deposits and claims through
AutomaticSavingCircles. Its depositChecker() and claimChecker() return whether a batch is due
and the exact calldata to send. The job sends that calldata as-is, deposits first, then claims.
Unlike distribute-yield, the batch functions are not permissionless: only the contract's
automationExecutor may call them. That role can only trigger automation that is already due; it
cannot move funds anywhere else. Still, use a dedicated wallet, not KEEPER_PRIVATE_KEY, and wire it
from the contract owner:
cast send <AutomaticSavingCircles> 'setAutomationExecutor(address)' <executor-address> \
--rpc-url <rpc> --private-key <owner-key>Each run:
- fails (and alerts) if the executor wallet is not
automationExecutor - alerts if the executor balance is below the per-network minimum (0.2 xDAI, 1 CELO)
- sends with a gas limit of 200% of the estimate (
SAVING_CIRCLES_GAS_LIMIT_MULTIPLIER_PCT), and refuses anything aboveSAVING_CIRCLES_MAX_GAS_LIMIT(3M); batch cost grows with circle size - reports members whose deposit or claim failed inside an otherwise successful batch (
AutomatedDepositFailed/AutomatedClaimFailed), and alerts separately when the reason is empty - still runs claims when the deposit batch fails, then fails the run if either did
The headroom is large on purpose. Each member runs inside a try/catch, so eth_estimateGas returns a
limit at which the batch succeeds but a member's call runs out of gas and is only logged as failed;
on Gnosis a one-member deposit estimated ~300k but needed ~400k. Unused gas is not charged.
A missed or failed run is retried on the next tick: the checkers keep returning the work until it is done.
See .env.example.
| Variable | Required | Purpose |
|---|---|---|
KEEPER_PRIVATE_KEY |
yes | Dedicated wallet that pays gas. It needs no role on any contract, so it should only ever hold gas money (about 1 xDAI lasts years). |
RPC_URLS |
no | Comma-separated RPC endpoints, tried in order. |
DISCORD_WEBHOOK_URL |
no | Gets a message for each successful distribution and for alerts (at most one per alert every 6h). |
HEALTHCHECK_URL |
no | Pinged on every run, with /fail on errors. Use a healthchecks.io check with a 30 min period and about 1h grace, so you hear about it if the container dies. |
MIN_BALANCE_XDAI |
no | Threshold for the low-balance alert. Default 0.2. |
DRY_RUN |
no | 1 does everything except send the transaction. |
SAVING_CIRCLES_EXECUTOR_PRIVATE_KEY |
yes | Wallet set as automationExecutor on each AutomaticSavingCircles contract. Holds only gas money. |
SAVING_CIRCLES_GNOSIS_RPC_URLS, SAVING_CIRCLES_CELO_RPC_URLS |
no | Comma-separated RPC endpoints per network, tried in order. Default to public RPCs. |
SAVING_CIRCLES_GNOSIS_HEALTHCHECK_URL, SAVING_CIRCLES_CELO_HEALTHCHECK_URL |
no | One check per network (period 1h, about 1h grace). No fallback to HEALTHCHECK_URL, so distribute-yield pings can't mask a dead job. |
SAVING_CIRCLES_GNOSIS_ADDRESS, SAVING_CIRCLES_CELO_ADDRESS |
no | Override the AutomaticSavingCircles contract per network. |
SAVING_CIRCLES_GNOSIS_MIN_BALANCE, SAVING_CIRCLES_CELO_MIN_BALANCE |
no | Low-balance alert threshold per network. Default 0.2 xDAI, 1 CELO. |
SAVING_CIRCLES_GAS_LIMIT_MULTIPLIER_PCT |
no | Gas limit as a percentage of the estimate. Default 200. |
SAVING_CIRCLES_MAX_GAS_LIMIT |
no | Refuse to send a batch whose gas limit exceeds this. Default 3000000. |
Deploy it as a Docker Compose resource built from this repository, with the variables above set as secrets in the platform. It needs no ports, volumes or domain.
Keep it out of any deployment that runs untrusted code or LLM agents, so the key never shares an environment with them.
Dry run against mainnet (read-only):
KEEPER_PRIVATE_KEY=0x... DRY_RUN=1 bash jobs/distribute-yield.shSaving Circles dry run:
SAVING_CIRCLES_EXECUTOR_PRIVATE_KEY=0x... DRY_RUN=1 bash jobs/saving-circles.sh gnosisReal send against a local fork:
anvil --fork-url https://rpc.gnosischain.com --port 8547 &
cast rpc anvil_setBalance <keeper-address> 0xDE0B6B3A7640000 --rpc-url http://127.0.0.1:8547
KEEPER_PRIVATE_KEY=0x... RPC_URLS=http://127.0.0.1:8547 bash jobs/distribute-yield.sh- Add
jobs/<name>.sh. - Set
JOB=<name>and sourcelib/common.sh, which providespick_rpc,alert,notify,ping_healthandfail. - Add a line to
crontab.
Prefer polling the contract's own readiness check over counting time in cron.