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 mainnet | Cardano preprod | |
|---|---|---|
| Withdraw script hash | 4949403eece2eea554df601a883c50d0ef005fa66e7a7300bad5ebc2 | b13fcd25149dd1caacd53525f4e650aefa8dcd3ead7a8cef6c6b7ae7 |
| State address | addr1w9kskr8xl3pndssgxa72sk5e5lsc455r5wcervgfhmae4vgpyhzl4 | addr_test1wpz2mpc7etnqx9n37g0qrds7ays4ta5dpgaltfn97a8exps0v759t |
| Trusted signer expiry | 2027-05-04 | 2036-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 propertyThe 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 }): DeviationResultAll 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:
| Outcome | Meaning |
|---|---|
within | The observed price is inside the threshold. |
exceeded | The quote must be refused. |
no-reference | No 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): stringAn 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
PythPriceneeds 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.