> ## Documentation Index
> Fetch the complete documentation index at: https://docs.windrose.market/llms.txt
> Use this file to discover all available pages before exploring further.

# Keeper

> Run the FX keeper that posts rates to the KeeperFxOracle for every registered currency.

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

<Warning>
  `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`.
</Warning>

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](/protocol/contracts/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

<Steps>
  <Step title="Install">
    `pnpm install` from the repository root.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Allow-list the keeper address">
    The deploy script allow-lists the `KEEPER` environment address (default: the deployer). For another address the oracle owner runs:

    ```bash theme={"system"}
    cast send <keeperOracle> "setKeeper(address,bool)" <keeper> true --rpc-url <RPC_URL> --private-key <OWNER_KEY>
    ```
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Commands

| Command          | What it does                                                                                                                                                                            |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm start`     | Post now, then every `INTERVAL_SEC` seconds. `Ctrl+C` finishes the current run and exits (a second signal forces it). Also `pnpm keeper` from the repository root.                      |
| `pnpm once`      | One run, then exit. Exit code 0 when clean, 3 when a code was rejected (see below), 1 on error. Suitable for cron.                                                                      |
| `pnpm status`    | Every registry code with its on-chain rate, publish time, age and freshness. Needs no private key; with one set it also shows whether that address is allow-listed and its gas balance. |
| `pnpm test`      | Unit tests (vitest).                                                                                                                                                                    |
| `pnpm typecheck` | `tsc --noEmit`.                                                                                                                                                                         |

## 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.

| Variable                             | Default                                       | Meaning                                                                                                                                                                                             |
| ------------------------------------ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KEEPER_PRIVATE_KEY`                 | required for `start` and `once`               | Key of an allow-listed keeper. 32 bytes of hex, `0x` prefix optional.                                                                                                                               |
| `CHAIN_ID` (or `LAUNCHPAD_CHAIN_ID`) | `46630`                                       | Chain to run on, looked up in `packages/abis/chains.json`. Picks the default `RPC_URL`, `DEPLOYMENTS_FILE` and the gas parameters. When set explicitly, the deployment file must be for that chain. |
| `RPC_URL`                            | the registry `rpc` of `CHAIN_ID`              | JSON-RPC endpoint. Its chain id must match the deployment file.                                                                                                                                     |
| `DEPLOYMENTS_FILE`                   | `packages/abis/deployments/<CHAIN_ID>.json`   | Deployment JSON. A relative path resolves from the working directory (pnpm runs scripts from `packages/keeper`).                                                                                    |
| `INTERVAL_SEC`                       | `600`                                         | Seconds between runs in `start` mode.                                                                                                                                                               |
| `FX_API_URL`                         | `https://open.er-api.com/v6/latest/USD`       | Primary source, shaped `{ rates: { INR: 83.2, ... }, time_last_update_unix }`.                                                                                                                      |
| `FX_FALLBACK_URL`                    | `https://api.frankfurter.app/latest?from=USD` | Used only when the primary fails. Set it empty to disable.                                                                                                                                          |
| `MAX_BATCH`                          | `25`                                          | Codes per `post()` transaction.                                                                                                                                                                     |
| `HEARTBEAT_SEC`                      | `3600`                                        | Re-post an unchanged rate once its on-chain `publishTime` is older than this, so it never goes stale.                                                                                               |

<Note>
  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.
</Note>

## 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:

```text theme={"system"}
!!! MoveTooLarge: ARS was NOT posted (bytes32 0x4152530000...)
!!! on-chain 900 -> source 1100 (+22.22%) exceeds maxMoveBps. The keeper cannot fix this; ...
!!! cast send <oracle> "forcePost(bytes32,uint256)" 0x4152530000... 1100000000000000000000 --rpc-url ... --private-key <OWNER_KEY>
```

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

```text theme={"system"}
src/index.ts       CLI (start | once | status)
src/keeper.ts      one run: registry checks, candidate selection, simulate, isolate, send
src/isolate.ts     revert decoding and the drop-or-bisect logic around simulateContract
src/rates.ts       FX API clients and response parsing
src/decimal.ts     decimal text -> 1e18 bigint
src/codes.ts       bytes32 <-> ASCII currency codes
src/status.ts      the status table
src/contracts.ts   typed readers over the JSON ABIs from @launchpad/abis
src/chain.ts       viem chain definitions and gas parameters from the registry
src/config.ts      environment handling
src/deployments.ts deployment JSON loader
```
