Skip to main content
The keeper in 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

KeeperFxOracle trusts whoever holds a keeper key. There is no attestation, no aggregation across sources and no on-chain validation beyond three sanity checks in the contract: the rate must be non-zero and fit in a uint128, the timestamp may not be in the future, and a single post may not move a rate by more than maxMoveBps (15% as deployed). A compromised or careless keeper can still push wrong prices within that band, and the oracle owner can push any price at all with forcePost.
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.
For currencies covered by Pyth, production should point the registry at 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, then packages/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

  1. Reads registry.allCodes() and registry.oracle(). If the registry’s oracle is not the keeperOracle from 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.
  2. Fetches rates from FX_API_URL, falling back to FX_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.
  3. Chooses publishTime = now - 5s, where now is the smaller of the local clock and the latest block timestamp (the contract rejects timestamps in the future).
  4. For every registry code except USD: decodes the bytes32 to 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-chain publishTime is older than HEARTBEAT_SEC, so a quiet run costs no gas until the heartbeat is due.
  5. Splits the rest into batches of MAX_BATCH and simulates each post(codes, rates, publishTime). When the simulation reverts with MoveTooLarge (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, FuturePublishTime and transport errors abort the run.
  6. 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 after publishTime 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:
Only the oracle owner can resolve it, either with the printed 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.

Layout