Oracle Integration: Pyth Pull#
Launch contract: Seesaw uses the Pull-only Pyth Receiver flow. Every market, client, keeper, and release artifact follows this one oracle path.
Design rationale: Why Pyth is the exclusive oracle is covered in Design: Price Oracle.
Seesaw uses Pyth Network as its exclusive oracle. Every market uses the Pyth Pull delivery path. This page explains how prices are sampled and validated under the current source-pinned Pyth Receiver contract and release evidence.
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, timestamps, confidence guard, and jump guard before trading. Pull is fixed protocol behavior, not a selectable market setting.
The fixed launch oracle#
Each market uses the Pull CreateMarketArgs shape. The supplied ephemeral
PriceUpdateV2 account is posted through the Pyth Solana Receiver, and every
read validates the nonzero market feed ID, Receiver owner, Full verification,
freshness, confidence, and firstness boundary.
| Dimension | Pull-only launch contract |
|---|---|
| Oracle account | Ephemeral PriceUpdateV2 posted by the caller through the Pyth Receiver |
| Market identity | Nonzero market.pyth_feed_id, matched to the embedded update feed ID |
| Owner check | Pinned Receiver owner rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp |
| Verification level | Full, guardian-attested |
| Rule A firstness | Hermes supplies the first post-boundary update and prev_publish_time is revalidated |
| Who does the work | The relay/keeper fetches and posts the update, then calls the Seesaw lifecycle instruction |
| Feed availability | Any feed reachable through the operator's configured Hermes/Pull API plan |
Account naming#
The feed ID and transient account 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. |
priceUpdateAccount | The transient Receiver-owned PriceUpdateV2 account posted for the lifecycle call. |
Why Pull delivery exists#
Sampling Rule A wants the first price published at or after a boundary.
The poster asks Hermes for exactly that 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.
Pull 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 moving 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 the 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#
The Pyth Receiver owner and Wormhole program are hard-pinned below. Wire tags
0x30/0x31 are reserved wire holes; current dispatch rejects both, with any
payload shape, as InvalidInstructionData.
| Role | Current source default |
|---|---|
| Receiver owner | rec2HHDDnjLfj4kE7VyEtFA1HPGQLK33259532cRyHp |
| Wormhole program | HDw2E7P8X1SkCyjvoGsfBGAVUutKcj874bXjHrpVYrVL |
Pyth's Wormhole receiver is an external Pyth dependency, not a Seesaw config target. The release manifest binds both IDs to the deployed program and target cluster.
Hermes and release configuration#
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.
- Pull release bundle: use the reviewed Hermes endpoint together with the source-pinned Solana Receiver and Wormhole IDs. Mixing endpoint and program assumptions produces confusing feed discovery and snapshot failures.
- Release evidence: bind the Hermes/Pull provider, Receiver owner, Wormhole program, cluster, commit, and deployed binary in an externally attested release manifest.
Security model#
With Pull delivery 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 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 Pull-only contract
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.