Zerochord
On chain

Pyth oracle

How a signed Pyth Lazer price is verified off chain and proved on chain, and what the freshness and deviation guards check.

@cardano-swap/oracle subscribes to Pyth Lazer, verifies every signed update against the trusted signers held in the on-chain Pyth state, checks the price is fresh, compares a pool price against it, and builds the redeemer for the zero lovelace withdrawal that proves the price on chain.

Nothing in this package invents, interpolates or defaults a price. An update that cannot be parsed and verified is rejected, because a wrong price silently accepted is a wrong quote paid by a user.

The deployment

Pyth publishes the withdraw script itself. Zerochord references it and reads the verified price out of the withdrawal; it does not reimplement the signature check.

Cardano mainnetCardano preprod
Withdraw script hash4949403eece2eea554df601a883c50d0ef005fa66e7a7300bad5ebc2b13fcd25149dd1caacd53525f4e650aefa8dcd3ead7a8cef6c6b7ae7
State addressaddr1w9kskr8xl3pndssgxa72sk5e5lsc455r5wcervgfhmae4vgpyhzl4addr_test1wpz2mpc7etnqx9n37g0qrds7ays4ta5dpgaltfn97a8exps0v759t
Trusted signer expiry2027-05-042036-07-29

Both networks configure feed id 16, ADA/USD, a maximum price age of 120 000 ms and a deviation threshold of 200 basis points.

The signed update format

The solana format is little endian throughout, and the layout was read from the Pyth pyth-lazer-protocol Rust crate that produces the bytes, then confirmed against the signed test vector that ships in the Pyth Cardano validator source.

Envelope
  offset  0   u32   format magic, 0x821A01B9
  offset  4   [64]  Ed25519 signature
  offset 68   [32]  Ed25519 public key of the signer
  offset 100  u16   payload length in bytes
  offset 102  [n]   payload

Payload
  offset  0   u32   payload magic, 0x93C7D375
  offset  4   u64   publish timestamp in MICROseconds
  offset 12   u8    channel id
  offset 13   u8    number of feeds
  then per feed: u32 feed id, u8 property count, then id and value per property

The Ed25519 signature covers the payload bytes only, not the envelope. That was checked against the Pyth test vector: verifying over the payload passes and verifying over the envelope tail fails.

For the integer properties, the value 0 means absent rather than zero.

Trusted signers come from the chain

The trusted Ed25519 keys are not configuration and are not hard coded. Pyth rotates them, and the on-chain Pyth state is what the validator itself checks against, so it is the only correct source.

packages/oracle/src/state.ts reads that same state UTxO, identified by the state NFT whose asset name is Pyth State, and decodes the trusted_signers map with each key's validity range. The off-chain check and the on-chain check therefore agree by construction.

Freshness is the consumer's job

The Pyth Cardano validator checks the Ed25519 signature and that the signer is trusted, and nothing else. It does not enforce freshness. A correctly signed update from last week verifies exactly as well as one from this second.

So whatever consumes the price checks the publish time itself:

checkFreshness(price, nowMs, { maxPriceAgeMs, maxFutureSkewMs })
requireFresh(price, nowMs, options)

checkFreshness returns { fresh: true, ageMs }, or a refusal carrying reason of stale or future with the limit it broke.

The future skew allowance defaults to 2000 ms. Pyth's clock and this one are not the same clock, and the router publishes every 200 ms, so a small positive skew is normal. A large one means the local clock is wrong, and then the computed age is wrong too, so the price is refused rather than trusted.

now is always a parameter. Reading the clock inside these functions would make the result depend on when a test runs.

There is no variant that returns the last known good price. A quote built on a stale oracle is exactly the failure the guard exists to stop.

The deviation guard

checkDeviation({ oracle, poolImplied, thresholdBps }): DeviationResult

All arithmetic is on bigint. A basis point difference on a stablecoin pair is the fourth decimal place, which is where a float starts to lie. Both mantissas are scaled up to the smaller exponent so nothing is truncated, and the division rounds up so a value between two whole basis points is reported as the larger one. That makes the guard refuse rather than allow at the boundary, which is the safe direction for a user's funds.

The result is one of three outcomes:

OutcomeMeaning
withinThe observed price is inside the threshold.
exceededThe quote must be refused.
no-referenceNo oracle feed covers this pair, so no comparison was made.

no-reference is never folded into within. The caller has to know the guard did not run rather than believe it passed.

What each pool shape gets

A pool with ADA on one side, for example USDM against ADA, implies an ADA/USD price if the stablecoin is taken to be worth one dollar. checkAdaUsdDeviation compares that implied price against Pyth ADA/USD.

A stale pool, a pool that has been pushed by a sandwich, and a stablecoin that has actually depegged all appear the same way here, as a deviation. All three are reasons to refuse a quote, so one guard covers them, and the guard cannot say which of the three happened.

A pool with a stablecoin on both sides has checkStablePairDeviation, which reports no-reference and names both assets in its reason. The full Lazer symbol list of 3615 feeds was searched: the feeds that look like a match are different assets on other chains. It exists so the caller handles the case explicitly instead of skipping the guard by omission.

packages/quote does not depend on @cardano-swap/oracle, so the guard runs wherever a caller wires it rather than implicitly inside the engine.

Proving the price on chain

The Pyth validator declares its withdraw handler as withdraw(updates: List<ByteArray>, credential, self), so the redeemer is a Plutus list of raw signed update byte strings with no wrapping constructor. The validator loops over the list and verifies each against the trusted signers in the state reference input.

buildPythWithdrawRedeemer(updates): PlutusData
encodePythWithdrawRedeemer(updates): string

An empty list throws: it proves no price. Each update is parsed before it goes in, because a malformed update would be rejected on chain only after the fee is paid and the script budget is spent.

The transaction carrying that redeemer must also withdraw exactly 0 lovelace from the withdraw script's reward account, carry the Pyth state UTxO as a reference input, and set a validity range, because the trusted signer expiry is checked against it. Those parts belong to the builder in packages/tx/src/oracle/withdraw.ts.

Zero withdrawal is the pattern that forces the withdrawal validator to run once per transaction.

Two ways to obtain updates

Subscribe directly. Signed solana updates come from the Lazer router stream at wss://pyth-lazer-{0,1,2}.dourolabs.app/v1/stream. Pyth publishes three redundant instances and asks clients to spread across them.

The router stream requires an access token, sent as Authorization: Bearer <token> from a server or in the websocket subprotocol list as pyth-lazer-auth, <token> from a browser, which cannot set headers. Both forms are supported and the header form is the default. The variable holding the token is named by oracle.pyth.apiKeyEnv, which is PYTH_LAZER_API_KEY.

Keys are free for Cardano projects under the Intersect and Pyth Pro offer.

Without a key the client throws. There is no substitute price source, no cached last value and no public feed fallback. A router that quoted from a guessed price would be worse than a router that refused to quote.

The api service stream at wss://pyth-{0,1,2}.dourolabs.app/v1/prices/stream serves parsed unsigned JSON only, so it does not serve the payloads the Cardano redeemer needs.

Relay from the chain. Every zero lovelace withdrawal from the Pyth withdraw script carries the signed update as its redeemer, and a transaction is public. packages/oracle/src/relay.ts reads one back out of transaction CBOR, and needs no Pyth credential.

Authenticity comes from the Ed25519 signature over the payload, not from the transport. The same bytes verify against the same trusted signer whichever way they arrived. The relay checks this on every read rather than assuming it: the trusted signer set is read from the on-chain Pyth state of the network the transaction will be submitted to, and an update signed by anything else is rejected.

Two properties of a relayed update, both reported rather than smoothed over:

  • Age. A relayed update is already 22 to 100 seconds old by the time it reaches a block and can be read back. Against a two minute window that leaves little room. The module reports the age and lets the caller refuse. It never relaxes the window to make an update usable.
  • Properties. A relayed update carries only the properties the original consumer subscribed to, in practice price and exponent. A PythPrice needs a confidence value and the module does not invent one, so a relayed update supports the withdrawal redeemer. The deviation guard's reference price comes from a direct subscription.

On this page