Oracle Integration: Pyth Push vs. Pull#
Design rationale: Why Pyth is the exclusive oracle, and why the protocol was architected around its push/pull duality, is covered in Design: Price Oracle.
Seesaw uses Pyth Network as its exclusive oracle. Every market is created in one of two delivery modes — push or pull — and this page is the deep dive on how the two modes work, how prices are sampled and validated, and how the protocol survives the 2026-07-31 Pyth Core program cutover without a redeploy.
What users are trading#
Seesaw markets resolve against the protocol's Pyth snapshot rule, not an informal social definition of whether an asset "went up." A market compares the first accepted Pyth price update at or after the start boundary with the first accepted Pyth price update at or after the end boundary:
P_end >= P_start → UP (YES wins; equality counts as UP)
P_end < P_start → DOWN (NO wins)
There is no dispute window and no human judgment. Users can inspect the feed id, oracle mode, timestamps, confidence guard, and jump guard before trading.
The two oracle modes#
Each market picks its mode at creation via CreateMarketArgs.oracle_mode
(0 = Push, 1 = Pull). The two modes coexist; push remains available as
a fallback that needs no off-chain price-fetch infrastructure.
| Dimension | Push (oracle_mode = 0, legacy-compatible) | Pull (oracle_mode = 1, launch default) |
|---|---|---|
| Oracle account | Persistent Pyth-sponsored feed account, updated continuously | Ephemeral PriceUpdateV2 posted by the caller via the Pyth Solana Receiver |
| What the market stores | The feed account address in market.pyth_feed (immutable) | market.pyth_feed = [0u8; 32] (the pull indicator — no address binding) |
| Identity check at each read | Address binding: account must equal market.pyth_feed | Feed-id binding: account's feed_id must equal market.pyth_feed_id (non-zero) |
| Owner check | Owner must be config.pyth_receiver_program_id (sponsored push feeds are receiver-owned PDAs) | Owner must be config.pyth_receiver_program_id (default rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp) |
| Verification level | Full required (enforced when parsing the account) | Full required — Wormhole-guardian-signed |
| Rule A "firstness" | Best effort: the persistent account only holds the latest print; the first post-boundary print is often already gone by the time a crank lands | Deterministic: the poster fetches exactly the first post-boundary update from Hermes and posts it |
| Who does the work | Pyth's own push infrastructure updates the account | The cranker fetches from Hermes and bundles post_update + the Seesaw instruction |
| Feed availability | Any supported Pyth push feed | Any feed reachable through the operator's configured Pyth/Hermes API access |
Cross-mode substitution is impossible by construction: a Receiver-owned
pull update can never resolve a push market (the address binding rejects
it first), while pull markets accept only Receiver-owned PriceUpdateV2
accounts whose embedded feed_id equals market.pyth_feed_id and whose
sample passes freshness, firstness, and confidence checks.
Account naming#
Three similarly named values show up in SDKs, CLI output, and API payloads:
| Name | Meaning |
|---|---|
pythFeedId / pyth_feed_id | The 32-byte Pyth feed id. This is the asset identity and a Seesaw market PDA seed. It is not a Solana account. |
pythFeed / pyth_feed for Push | The Receiver-owned Solana push-feed account address. Push markets bind this exact address. |
pythFeed / pyth_feed for Pull | The zero-address sentinel stored on the market. Pull lifecycle calls pass a Receiver-owned PriceUpdateV2 account whose internal feed_id equals pyth_feed_id. |
The early client upgrade exposes Pull creation for sponsored Solana feeds whose
Receiver accounts are already available at create time. Full arbitrary-feed
Pull still requires the keeper/relay path that fetches the update from Hermes,
posts it through the Receiver, and passes that PriceUpdateV2 account into the
Seesaw lifecycle instruction.
Why pull mode exists#
Sampling Rule A wants the first price published at or after a boundary.
Under push, the on-chain account always carries the latest print — so if a
crank arrives even a few seconds late, the first post-boundary print has
already been overwritten and the firstness proof fails. Under pull, the
poster asks Hermes for exactly the first post-boundary update
(getPriceUpdatesAtTimestamp), posts it through the Receiver, and the
firstness condition is satisfied deterministically.
Sampling Rule A and the firstness proof#
For a market spanning [t_start, t_end):
P_start = FIRST Pyth update with publish_time >= t_start
P_end = FIRST Pyth update with publish_time >= t_end
"First" is proven on-chain using the update's own prev_publish_time
field: if the predecessor of the supplied update was published before
the boundary, then the supplied update is the first one at-or-after it.
accepted ⟺ publish_time >= boundary
AND (prev_publish_time == 0 // genesis/unknown sentinel
OR prev_publish_time < boundary)
The start price is captured atomically inside create_market (0x03) —
a market account cannot exist without its start snapshot. The end price is
captured by snapshot_end (0x04) and the outcome computed by
resolve_market (0x05). Both snapshots are write-once: once a non-zero
price is stored it can never be overwritten, so resolution is
deterministic and auditable.
Push mode sequence#
Pull mode sequence#
Permissionless Pull keeper#
Seesaw runs a default Pull keeper, but that keeper has no special authority. It
is a normal lifecycle caller that scans Pull markets, fetches signed Pyth updates
through the relay, posts them through the Pyth Receiver, and submits
snapshot_end, resolve_market, or expire_market when each market's state and
timestamp allow it.
Any operator can run the same open-source implementation in
services/pyth-keeper. Multiple keepers
can safely run at once because lifecycle instructions are idempotent: the first
successful caller earns the reward and moves the market forward; later callers
observe the updated state or hit a harmless no-op/failure.
For Pull expiry, the keeper calls expire_market only after:
market.outcome == 0
market.end_price == 0
current_unix_time >= market.t_end + expiration_window
The keeper still passes a Receiver-owned PriceUpdateV2 account, created from
guardian-signed bytes supplied by the relay. The Pyth Receiver verifies the
signatures, and Seesaw verifies the feed id, owner, verification level, boundary,
and firstness fields. See Run A Pyth Pull Keeper
for the public operator runbook and monitoring checks.
How the program reads a price#
Seesaw has no Pyth SDK dependency. The program parses the raw
PriceUpdateV2 account at fixed byte offsets, after checking the 8-byte
account discriminator and requiring verification_level == Full.
The exact byte layout is in the Appendix below.
Accepted prices must be strictly positive (0 is the "not captured"
sentinel). Non-Full updates are rejected before any field is interpreted.
Validity guards#
Staleness — deliberately asymmetric#
| Snapshot | Where it happens | Max age rule |
|---|---|---|
| Start | create_market (0x03) | Hardcoded 60 seconds (MAX_PRICE_AGE_SECONDS) — not configurable |
| End | snapshot_end (0x04) / late capture | Configurable: config.max_price_staleness_seconds (0 = use the 60s default; hard cap 3600s), admin-tunable via UpdateOperationalParams (0x2F) |
The start boundary is tight because a market should never begin against old data. The end boundary is operator-tunable so a late crank during congestion or a brief oracle gap can still capture a valid first post-boundary print instead of pushing the market into the Expired fallback.
create_market fails closed once the canonical epoch start is more than 60
seconds old. That mirrors the fixed start freshness rule instead of allowing a
late creation attempt to reach the oracle parser and then fail through a less
obvious firstness/freshness mismatch.
Confidence guard (per-market, optional)#
A market may set max_confidence_ratio_bps. A snapshot is rejected when
the update's confidence interval is too wide relative to the price
(ConfidenceTooWide, 0x2004). This prevents resolving against a price the
oracle itself is unsure about during extreme volatility.
Set max_confidence_ratio_bps = 0 to disable the confidence guard entirely;
nonzero values are interpreted as basis-point thresholds.
Jump guard (per-market circuit breaker)#
Each market carries max_oracle_jump_bps (default 5000 bps = 50%). If the
start→end move exceeds the guard, resolution rejects with
OraclePriceJumpTooLarge (0x2007) rather than settling against a
potentially glitched print.
Error reference#
Oracle errors occupy the 0x2001–0x2007 range. For user-friendly messages and retry logic, see Error Codes. The oracle-specific codes are: OracleMismatch (0x2001), StaleOracle (0x2002), InvalidPrice (0x2003), ConfidenceTooWide (0x2004), FeedIdMismatch (0x2005), InvalidOracleData (0x2006), and OraclePriceJumpTooLarge (0x2007).
When the oracle doesn't show up: the Expired fallback#
If no acceptable end snapshot is captured by
t_end + market_expiration_window_seconds (protocol default: 7 days;
admin-tunable via 0x2F up to 30 days), the permissionless
expire_market (0x06) becomes callable. It is not an immediate
50/50 switch — it tries hard to resolve properly first:
- If both snapshots already exist → compute UP/DOWN normally.
- If the end snapshot is missing and the supplied oracle account carries an acceptable sample (boundary + firstness + staleness checks all enforced) → late-capture it and resolve UP/DOWN.
- If a push account has no acceptable sample, or a pull account supplies the canonical first post-boundary update but it is only too old because the keeper missed the window, fall back to Expired, where every share (YES and NO alike) redeems at 50% of face value. Callers cannot choose this path by supplying a wrong, pre-boundary, non-first, future-dated, or low-confidence account — those are rejected, not treated as "unavailable."
For per-position cleanup of an expired-but-unresolved market,
ForceClose (0x1B) refunds locked order collateral to position owners
(see Settlement).
What this means for Pull markets#
Pull markets need a fresh Receiver-owned PriceUpdateV2 account for the
market feed before they can capture the start or end snapshot. The signed
update may be unavailable because Hermes or a relay is down, the feed is not
covered by the configured Pull API plan, the submitted feed id is wrong, the
keeper wallet is out of SOL, or the update is too stale for the on-chain
freshness window.
When that happens, resolution is blocked; the market does not become UP or
DOWN just because the UI or indexer sees the likely result off-chain. Keepers
and other callers can keep retrying with a valid Pull update. After the
default 7-day expiration window, expire_market can move the market to
Expired if no acceptable sample is available.
Expired is a safety fallback, not the same economic outcome as timely resolution. YES and NO both redeem at 50/50, so the would-be winning side receives a haircut relative to a normal $1 winning payout. Users should treat extended Pyth, Hermes, relay, RPC, or keeper availability failures as an oracle liveness risk, not as an indexer or app display issue.
Pyth Core program IDs#
Pyth's Core upgrade changed the on-chain Receiver owner and the push-feed PDA
derivation program. Seesaw now hard-pins the two upgraded IDs below. The former
Pyth program-id governance instructions remain in the ABI for wire stability,
but both reject on-chain with OracleProgramIdFrozen.
| Role | Historical pre-upgrade | Current source default |
|---|---|---|
| Push feed PDA derivation ID | pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT | pyt2F414BA6dPttK6RddPZUdHfapoBN24GL5wbrPCou |
| Receiver owner | rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ | rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp |
Pyth's Wormhole receiver is an external Pyth dependency, not a Seesaw config target. Any future owner-program change requires a program upgrade and audit review, not an on-chain governance re-point.
Early upgrade and Hermes keys#
Pyth's upgraded Hermes endpoint requires a bearer API key. Seesaw's rule is:
- Server-side only: set
HERMES_ENDPOINT=https://pyth.dourolabs.app/hermes/andPYTH_API_KEYon server/relay infrastructure. - Never in clients: browser and native clients must use Seesaw's Pyth proxy/relay. They must not embed or persist the bearer key.
- Upgrade as a bundle: when early-upgrading, use the upgraded Hermes endpoint together with the upgraded Solana Receiver and push-feed derivation program ids. Mixing old Hermes/program assumptions with new feed accounts produces confusing feed discovery and snapshot failures.
- No governance re-point: operators can run old and upgraded Hermes paths side by side only if the hard-pinned on-chain program IDs still validate the supplied accounts. The retired
ProposePythProgramId/ApplyPythProgramIdpath cannot change those IDs.
Security model#
In pull mode the only degree of freedom an adversarial poster has is which real, guardian-signed Pyth update to post — and the timing guards constrain that choice to the legitimate first post-boundary print.
Operational notes#
- Feed allowlist: which assets get markets is an operations decision; pull-mode feeds additionally depend on the operator's configured Pyth API access. Any Pyth-supported pair (crypto, FX, equities, commodities) is technically supported by the program via its 32-byte feed id.
- Crank incentives:
create_marketprepays 5 lifecycle crank rewards (default 200,000 lamports each) so lifecycle callers are compensated, keeping permissionless resolution economically viable.
Related docs#
- How It Works — lifecycle overview
- Settlement — what happens after resolution, incl. the Expired 50/50 case
- Flow of Funds — where money sits at each lifecycle stage
- Error Codes — oracle error codes 0x2001–0x2007
- Run A Pyth Pull Keeper — open-source Pull keeper setup
- Design: Price Oracle — why Pyth, and the rationale for push/pull duality
spec/ORACLE.mdin the repository — the authoritative oracle specification
Appendix: PriceUpdateV2 account layout (reference)#
This table documents the raw byte offsets Seesaw reads from Pyth's
PriceUpdateV2 account. It is not available in the general account
reference because it describes a Pyth-owned account structure, not a
Seesaw PDA.
| Field | Type | Byte offset | Meaning |
|---|---|---|---|
feed_id | [u8; 32] | 41 | Pyth feed identifier |
price | i64 | 73 | Price in base units |
conf | u64 | 81 | Confidence interval (±) |
expo | i32 | 89 | Exponent (price × 10^expo) |
publish_time | i64 | 93 | Unix timestamp |
prev_publish_time | i64 | 101 | Predecessor's timestamp — the firstness witness |
The discriminator (bytes 0–7) and verification level are checked before
any of these fields are read. The expo value is required to be within
[-18, 18]; values outside that range trigger InvalidOracleData.