packages/keeper keeps the on-chain KeeperFxOracle supplied with FX rates for every currency in the CurrencyRegistry. It reads USD-based rates from a public FX API, converts them to the oracle’s 1e18 “units of currency per 1 USD” format and posts them in batches from an allow-listed keeper account. One keeper runs per chain, selected by CHAIN_ID.
Trust model
That is acceptable in two situations and no others:- Testnets, where nothing of value is priced off the oracle.
- Currencies with no decentralised feed (the 32 “keeper-oracle only” codes in
contracts/script/Currencies.sol), where the alternative is not listing the currency at all.
PythFxOracle. When that happens the keeper notices (registry.oracle() no longer equals the deployment file’s keeperOracle) and refuses to post. Robinhood Chain has neither Pyth nor Chainlink FX pairs today, so the keeper oracle is its only price source; see Oracles for the adapters.
The rate source is another trust assumption: the keeper reposts whatever open.er-api.com (or the fallback) returns. Those are free, daily-updated reference rates, not tradeable quotes.
Setup
1
Install
pnpm install from the repository root.2
Provide the deployment file
contracts/script/Deploy.s.sol writes contracts/deployments/<chainId>.json; the deploy scripts copy it to packages/abis/deployments/<chainId>.json, the default location. Point DEPLOYMENTS_FILE elsewhere if needed. The keeper fails with a clear message when the file is missing or is for another chain.3
Allow-list the keeper address
The deploy script allow-lists the
KEEPER environment address (default: the deployer). For another address the oracle owner runs:4
Fund it with the gas token
USDC on Arc, ETH on Robinhood Chain. A post of 25 codes costs on the order of 1 to 2 M gas. The keeper warns at startup when the balance is below 1 unit of the gas token.
5
Set the key
Copy
.env.example to packages/keeper/.env and set KEEPER_PRIVATE_KEY. On a box that runs several keepers, put each chain’s key in packages/keeper/.env.<chainId> with mode 600; it is loaded before .env whenever CHAIN_ID is set (pm2 sets it). Neither file is committed.Commands
Configuration
Values are read from the environment, thenpackages/keeper/.env.<chainId> (only when CHAIN_ID or LAUNCHPAD_CHAIN_ID is already set), then packages/keeper/.env, then a .env in the working directory. Earlier sources win.
The keeper README still names Arc testnet (5042002) as the default chain and a fixed 25 gwei fee. The code defaults to 46630 and pins the fee parameters from the registry entry of whichever chain is selected (Arc 25 gwei max and 1 gwei priority, Robinhood testnet 0.05 gwei, Robinhood Chain 0.5 gwei, both with zero priority); only a chain that is not in the registry falls back to viem’s estimation.
What a run does
- Reads
registry.allCodes()andregistry.oracle(). If the registry’s oracle is not thekeeperOraclefrom the deployment file, the run stops with an error (the registry was switched, or the file is stale). It also verifies that the keeper address is allow-listed, so a misconfigured key fails before any rate is fetched. - Fetches rates from
FX_API_URL, falling back toFX_FALLBACK_URL. Each rate is converted to an 18-decimal integer by parsing its decimal text and shifting digits; no floating point arithmetic touches the value. - Chooses
publishTime = now - 5s, wherenowis the smaller of the local clock and the latest block timestamp (the contract rejects timestamps in the future). - For every registry code except USD: decodes the
bytes32to ASCII, looks up the rate (missing codes are skipped with a warning) and reads the current on-chain entry. Codes that are not enabled on the oracle, or whose stored timestamp is newer than the one about to be posted, are left alone. Unchanged rates are re-posted only once their on-chainpublishTimeis older thanHEARTBEAT_SEC, so a quiet run costs no gas until the heartbeat is due. - Splits the rest into batches of
MAX_BATCHand simulates eachpost(codes, rates, publishTime). When the simulation reverts withMoveTooLarge(or another per-code error) the offending code is removed and the batch re-simulated; the code comes straight from the revert data when the RPC returns it, otherwise the batch is bisected.NotKeeper,FuturePublishTimeand transport errors abort the run. - Sends each surviving batch with the registry’s fee parameters, waits for the receipt and logs the transaction hash with an explorer link. Batches go out one after another, so a single instance never races its own nonce. Never run two instances with the same key.
Timestamps and freshness
The oracle counts a rate as fresh for 2 hours afterpublishTime and usable for 5 days. Older rates revert with StalePrice, the vaults stop minting and redeeming, and backers can only deposit or withdraw while the rate is fresh. That is why the keeper must stay up: pm2 logs windrose-keeper should show run complete: posted N every 10 minutes.
publishTime is the time the keeper observed and confirmed the rate (push-oracle semantics), not the source’s own update time. Free sources refresh about once a day, so using their timestamp would leave rates fresh for only two hours per day. Instead the keeper re-confirms every INTERVAL_SEC and re-posts unchanged rates once their on-chain publishTime is older than HEARTBEAT_SEC, so freshness means “the keeper is alive and checked recently”. If the keeper dies, rates go stale after 2 hours and unusable after 5 days, which is the intended failure mode.
When a rate is rejected
maxMoveBps bounds how far one post can move a rate. A currency that devalues faster than that between two posts, or a bad value from the source, makes the post revert for that code. The keeper skips it, keeps posting the others and prints a banner:
forcePost (which bypasses the move limit and stamps the current block time) or by raising maxMoveBps. Until then every run repeats the banner and pnpm once exits with code 3. Check the source value before forcing it: the same banner appears when the API is wrong.
On the production box
deploy/ecosystem.config.cjs runs one keeper per chain: windrose-keeper (4663), windrose-keeper-rh-testnet (46630) and windrose-keeper-arc (5042002), each with its own CHAIN_ID, RPC_URL and DEPLOYMENTS_FILE, a 30 s restart delay and a 300 MB memory cap. The mainnet hot key was generated on the box, only ever holds gas (about 0.01 ETH) and has nothing but the oracle’s keeper role; the deployer key that owns the contracts never leaves the workstation.