Run A Pyth Pull Keeper#
Seesaw Pull markets need an off-chain cranker to fetch Pyth's signed update, post it through the Pyth Solana Receiver, and submit the Seesaw lifecycle instruction. The keeper that Seesaw runs is the default cranker for this path, but it is not privileged. It signs as a normal caller, earns the same lifecycle reward as any other caller, and can be replaced or duplicated by any operator.
The open-source implementation lives in services/pyth-keeper.
If you need to hold a Pyth/Hermes API key yourself, run the companion relay in
services/pyth-relay so the key stays
server-side.
What The Keeper Does#
The keeper watches on-chain markets and only acts on Pull markets, identified by
the all-zero pythFeed sentinel. Push markets do not need this service because
they read Pyth's persistent push-feed account directly.
| Market condition | Keeper action |
|---|---|
now >= t_end and end_price == 0 | Fetch signed update for t_end, post it, call snapshot_end |
end_price != 0 and market is unresolved | Call resolve_market |
now >= t_end + EXPIRATION_WINDOW_SECONDS, no end price | Fetch/post the signed update and call expire_market |
The on-chain instructions are permissionless and idempotent. If two keepers race, the first successful transaction moves the market forward; later attempts either no-op or fail harmlessly. User redemption is separate: the keeper resolves or expires markets, but it does not redeem trader positions.
The keeper is a liveness service, not a trust boundary. If it stalls, any operator can submit the same lifecycle instruction with the CLI or SDK. The program still enforces market timing, Pyth Receiver ownership, feed id, and firstness checks on-chain.
Redundancy Expectation#
Production Pull markets should have at least two independent keepers watching the same markets. The backup keeper should run on separate infrastructure, use a separate RPC provider or endpoint, sign with a separate funded keypair, and page a separate alert route where possible.
Running two keepers is safe because the lifecycle instructions are permissionless and idempotent. If both keepers submit at the same boundary, one transaction advances the market and the other observes or receives the already-advanced state. The backup keeper exists to keep markets moving when the primary process, RPC endpoint, relay, wallet balance, or alert route fails.
Expire Timing#
For Pull markets, the keeper classifies a market as expirable only when all of these are true:
market.outcome == 0
market.end_price == 0
current_unix_time >= market.t_end + EXPIRATION_WINDOW_SECONDS
EXPIRATION_WINDOW_SECONDS defaults to 604800 seconds (7 days). Operators
should set it to the protocol's configured market expiration window. Setting it
too low causes failed early attempts until the chain allows expiry; setting it
too high delays the 50/50 fallback.
Pull expire_market still needs a Receiver-owned PriceUpdateV2 account for the
market feed. The keeper gets guardian-signed update bytes from the relay, posts
them through the Pyth Receiver, and passes the resulting account to Seesaw. The
Pyth Receiver verifies the guardian signatures, and Seesaw verifies the owner,
verification level, feed id, boundary, and firstness fields before resolving or
expiring.
Failure handling is intentionally mode-specific:
- Missing or wrong oracle accounts reject; callers cannot choose the 50/50 path by withholding or substituting a feed.
- For Pull markets, a correctly-owned Receiver update for the correct feed that
is too old after the expiration window resolves the market as Expired
(
Outcome::Expired, the 50/50 fallback). - Other unusable Pull updates, such as the wrong feed id or an invalid Receiver account, reject and should page the operator because the keeper is submitting bad accounts.
- Push markets can fall back to Expired when the bound push account is unusable, but only after the exact creation-bound feed account is supplied.
Run It With Docker#
git clone https://github.com/palominito/seesaw-pyth-keeper.git
cd seesaw-pyth-keeper
docker build -f services/pyth-keeper/Dockerfile -t seesaw-pyth-keeper:local .
cp services/pyth-keeper/.env.example services/pyth-keeper/.env
Fill services/pyth-keeper/.env with your cluster values:
RPC_URL=https://your-solana-rpc.example
SEESAW_TARGET_CLUSTER=mainnet-beta
SEESAW_PROGRAM_ID=SEEsawgSrxRsgtKRbaThZFEKrVqX3Y64hDipTWyi8F8
RELAY_URL=https://your-pyth-relay.example
KEEPER_SECRET_KEY_FILE=/run/secrets/keeper_secret_key
SEESAW_RELEASE_COMMIT=0123456789abcdef0123456789abcdef01234567
SEESAW_PYTH_KEEPER_IMAGE_DIGEST=seesaw-pyth-keeper@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
DRY_RUN=true
POLL_INTERVAL_MS=5000
EXPIRATION_WINDOW_SECONDS=604800
METRICS_PORT=9091
METRICS_HOST=127.0.0.1
Start in dry-run mode first:
docker run --rm \
--mount type=bind,source=/opt/seesaw/secrets/keeper_secret_key,target=/run/secrets/keeper_secret_key,readonly \
--env-file services/pyth-keeper/.env \
-p 127.0.0.1:9091:9091 \
seesaw-pyth-keeper:local
When the logs and readiness output match the intended cluster, set
DRY_RUN=false and run it under a process supervisor:
docker run -d \
--name seesaw-pyth-keeper \
--restart unless-stopped \
--mount type=bind,source=/opt/seesaw/secrets/keeper_secret_key,target=/run/secrets/keeper_secret_key,readonly \
--env-file services/pyth-keeper/.env \
-p 127.0.0.1:9091:9091 \
seesaw-pyth-keeper:local
Keep enough SOL in the keeper wallet for transaction fees and temporary
Pyth-update rent. The recommended minimum is 0.05 SOL; alert and refill before
the balance drops below 0.01 SOL.
Manual Expire Fallback#
If the keeper repeatedly fails to expire one market but the operator can obtain or post the required Pyth update account, submit the permissionless fallback from the CLI. Use the same cluster, program id, market seed inputs, creator token account, and settlement mint that the market was created with:
seesaw market expire \
--cluster mainnet-beta \
--program-id "$SEESAW_PROGRAM_ID" \
--market-id "$MARKET_ID" \
--pyth-feed-id "$PYTH_FEED_ID_HEX" \
--creator "$CREATOR_ADDRESS" \
--creator-token-account "$CREATOR_TOKEN_ACCOUNT" \
--settlement-mint "$SETTLEMENT_MINT" \
--duration "$DURATION_SECONDS" \
--pyth-feed "$PRICE_UPDATE_ACCOUNT" \
--yes
For Pull markets, PRICE_UPDATE_ACCOUNT must be a Receiver-owned PriceUpdateV2
for PYTH_FEED_ID_HEX. If no fresh usable update exists after the expiration
window, a too-old but correctly-bound Receiver update lets the program take the
Expired fallback. If the command fails with OracleMismatch, InvalidPythFeed,
or a feed-id error, do not retry blindly; the posted update account is not the
right account for this market. If it fails with CannotExpire, the market has
not reached t_end + market_expiration_window_seconds on-chain.
Required Monitoring#
Do not treat the keeper as production-ready just because the container is running. A healthy Pull market setup needs both process monitoring and market state monitoring.
Probe the keeper:
curl -fsS http://127.0.0.1:9091/health
curl -fsS http://127.0.0.1:9091/readiness
curl -fsS http://127.0.0.1:9091/metrics
curl -fsS http://127.0.0.1:9091/metrics/prometheus
Alert on these keeper signals:
| Signal | Alert condition |
|---|---|
/health or /readiness unavailable | More than two poll intervals |
keeper.dryRun | true in a production deployment |
| Target cluster | Missing or not the intended cluster |
lastTickUnix | Older than max(60s, 3 × poll interval) |
consecutiveTickErrors | Non-zero for more than two poll intervals |
actionsFailed | Increasing across checks |
| Keeper SOL balance | Below 0.01 SOL |
Also alert from indexed market state:
| Market state | Alert condition |
|---|---|
Pull market still Trading | More than two minutes after t_end |
Pull market has no end_price | Within five minutes of t_end + expiration_window |
Pull market unresolved and no end_price | At or after t_end + expiration_window |
| Resolved or expired market has deferred creator fees | More than one crank interval |
A process-only monitor is not enough: a keeper can tick while every action fails because of RPC, relay, keypair, balance, or Pyth API issues. The market-state alerts prove that markets are actually moving.
Relay And Pyth API Keys#
The keeper should talk to a relay, not directly embed a Pyth/Hermes bearer token
in user-facing software. If you operate your own relay, set PYTH_API_KEY only
on the relay service. Browser and mobile clients must never carry the bearer key.
The relay cannot forge prices. It only re-serves guardian-signed bytes from Pyth; the Pyth Receiver and Seesaw program verify those bytes on-chain. The relay can delay availability, so operators should monitor relay readiness and market progress just like the keeper.
Related Docs#
- Automation - lifecycle cranks and automated trading
- Oracle Integration - Push vs. Pull oracle mechanics
- Cranking - operator-facing lifecycle runbook