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, default) | Pull (oracle_mode = 1) |
|---|---|---|
| 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), and a push feed account can never resolve a pull market (the owner check fails).
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#
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.
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.
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.
- Only if the bound oracle account has no acceptable sample → 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 account — wrong accounts 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).
The 2026-07-31 Pyth Core cutover#
Pyth's Core upgrade changes the on-chain Receiver owner and the push-feed PDA derivation program. Seesaw re-points to the new programs through governance — no program redeploy:
| Role | Today | Post-cutover |
|---|---|---|
| Push feed PDA derivation ID | pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT | pyt2F414BA6dPttK6RddPZUdHfapoBN24GL5wbrPCou |
| Receiver owner | rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ | rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp |
| Wormhole receiver | HDwcJBJXjL9FpJ7UBsYBtaDjsBUhuLCUYoz3zr8SWWaQ | HDw2E7P8X1SkCyjvoGsfBGAVUutKcj874bXjHrpVYrVL |
The re-point is a two-step timelock:
ProposePythProgramId(0x30) — the protocol authority proposes a new program id with atargetselector:target = 0updatesconfig.pyth_program_id(push PDA derivation program),target = 1updatesconfig.pyth_receiver_program_id.- 48-hour timelock (
ORACLE_TIMELOCK_SECONDS) must fully elapse. ApplyPythProgramId(0x31) — the pending value becomes live.
The 48-hour window gives integrators and watchers time to observe and react to any proposed oracle re-point before it takes effect.
Early upgrade and Hermes keys#
Pyth's upgraded Hermes endpoint requires a bearer API key. Seesaw's rule is:
- Server-side only: set
PYTH_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.
- Dual endpoint during cutover: operators can run old and upgraded Hermes paths side by side while markets created before the governance apply continue to settle against the configured on-chain program 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
- 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.