Consuming Price Feeds
Both Pyth and Switchboard on Sui are pull oracles: the consumer pulls the price update it needs into its own transaction. Your client fetches a fresh signed update offchain, then a single transaction applies it onchain and reads it, in that order. This walkthrough reads a Pyth feed end to end on Testnet, then shows the Switchboard on-demand variant.
The Pyth Sui SDK talks JSON-RPC, but the Mysten Testnet full node serves gRPC, so the default getFullnodeUrl('testnet') returns 404 for every SDK call. Point rpcUrl at a JSON-RPC provider or your own node. Every Testnet Pyth consumer hits this, and the failure looks like an unrelated 404 rather than an RPC-protocol mismatch. For the same reason, these samples use a JSON-RPC client rather than the SuiGrpcClient that the rest of this documentation uses.
The TypeScript samples live in the examples/oracle-adapter/ts package and type-check with tsc --noEmit:
cd examples/oracle-adapter/ts
tsc --noEmit
They pin the network's package and object IDs in a config module rather than inline, so a rotated ID is a one-line change:
// Testnet oracle configuration. Pin the network's package and object IDs, the
// Hermes endpoint, and the feed IDs here rather than inline in call sites, and
// re-read them from the Pyth docs when the provider rotates them. Price feed IDs
// are the same across chains; the Pyth and Wormhole state objects are per
// network. See https://docs.pyth.network/price-feeds/contract-addresses/sui and
// https://pyth.network/developers/price-feed-ids for the canonical values.
export const TESTNET = {
// A JSON-RPC endpoint. The Pyth SDK needs JSON-RPC; the Mysten Testnet
// fullnode serves gRPC, so point this at a JSON-RPC provider or your own node.
rpcUrl: 'https://rpc-testnet.suiscan.xyz',
// Pyth and Wormhole shared state objects on Sui Testnet.
pythStateId: '0x243759059f4c3111179da5878c12f68d612c21a8d54d85edc86164bb18be1c7c',
wormholeStateId: '0x31358d198147da50db32eda2562951d53973a0c0ad5ed738e9b17d88b213d790',
// Testnet reads the beta Hermes deployment.
hermesEndpoint: 'https://hermes-beta.pyth.network',
// Keeper push interval. Each push is a transaction that costs gas plus the
// Pyth base fee, so trade freshness against cost.
keeperIntervalMs: 15_000,
// Price feed IDs (chain-agnostic). Add the feeds your app consumes.
feeds: {
'SUI/USD': '0x50c67b3fd225db8912a424dd4baed60ffdde625ed2feaaf283724f9608fea266',
},
} as const;
The config's Pyth and Wormhole state object IDs and the Hermes endpoint come from the Pyth Sui addresses; its feed IDs come from the Pyth feed list. If a call fails because an ID is wrong or has rotated, check it against those pages and update the config module.
How do I set up the clients?
Three clients: a Sui RPC client, the Pyth onchain client (which builds the Wormhole-verify and price-update commands), and a Hermes connection (which serves the signed offchain updates).
import { SuiClient } from '@mysten/sui/client';
import { SuiPythClient, SuiPriceServiceConnection } from '@pythnetwork/pyth-sui-js';
import { TESTNET } from './config.js';
// A Sui RPC client, the Pyth on-chain client (which builds the Wormhole verify
// plus price-update commands), and a Hermes connection (which serves the signed
// off-chain price updates). Pyth on Sui is a pull oracle: you fetch an update
// from Hermes and apply it on-chain in the same transaction that reads it.
export function suiClient(): SuiClient {
return new SuiClient({ url: TESTNET.rpcUrl });
}
export function pythClient(sui: SuiClient): SuiPythClient {
return new SuiPythClient(sui, TESTNET.pythStateId, TESTNET.wormholeStateId);
}
export function hermes(): SuiPriceServiceConnection {
return new SuiPriceServiceConnection(TESTNET.hermesEndpoint);
}
How do I update and read a Pyth price in one transaction?
To update and read a Pyth price in one transaction:
- Fetch the latest update for your feed from Hermes.
- Apply it with
updatePriceFeeds. It appends the verify and update commands and returns the object IDs to read. - Read the returned
PriceInfoObjectin the same transaction.
The price comes back as an integer magnitude and a signed exponent, and the exponent is typically negative. The price is magnitude × 10^exponent. For example, a magnitude of 68005477 at exponent -8 is 68005477 × 10^-8 = 0.68005477, about 0.68 USD. Judge the confidence interval relative to the price.
// Pull model: fetch the signed update from Hermes, apply it on-chain with the
// Pyth client, then read the freshly updated PriceInfoObject in the SAME
// transaction. `updatePriceFeeds` appends the Wormhole-verify and price-update
// commands and returns the PriceInfoObject IDs; pass the matching one to your
// consumer. Reading through the adapter's `read_and_emit` applies the staleness
// bound, so an update that somehow did not land aborts rather than reading old.
export async function buildUpdateAndRead(
pyth: SuiPythClient,
hermes: SuiPriceServiceConnection,
feedId: string,
consumerPackageId: string,
maxAgeSecs: number,
): Promise<Transaction> {
const updates = await hermes.getPriceFeedsUpdateData([feedId]);
const tx = new Transaction();
const [priceInfoObjectId] = await pyth.updatePriceFeeds(tx, updates, [feedId]);
tx.moveCall({
target: `${consumerPackageId}::demo::read_and_emit`,
arguments: [tx.object(priceInfoObjectId), tx.object.clock(), tx.pure.u64(maxAgeSecs)],
});
return tx;
}
Observe: the transaction succeeds and the read returns a current price. Running this against Testnet for SUI/USD returns a price near the live market rate, with a confidence interval around 0.1% of the price. The 68005477 magnitude in the example is illustrative only, because the market price changes between runs. The read goes through the adapter's staleness bound, so a price the update failed to refresh aborts rather than reading old.
What happens when a price feed is stale?
A price that nobody has updated recently is stale. Reading it with a tight maximum age aborts with the provider's staleness error rather than returning an old value. This is dev-inspect-able, so you can confirm the rejection without spending gas or updating the feed:
// A price that has not been updated recently is stale. Reading it with a tight
// `max_age_secs` aborts with the provider's staleness error rather than
// returning an old value. High-stakes consumers must force this check, not skip
// it: this is `dev-inspect`-able, so you can confirm the rejection without
// spending gas or updating the feed.
export async function checkStale(
sui: SuiClient,
pyth: SuiPythClient,
pythStateId: string,
feedId: string,
sender: string,
maxAgeSecs: number,
): Promise<{ status: string; error?: string }> {
const pythPackageId = await pyth.getPackageId(pythStateId);
const priceInfoObjectId = await pyth.getPriceFeedObjectId(feedId);
if (!priceInfoObjectId) throw new Error(`no PriceInfoObject for feed ${feedId}`);
const tx = new Transaction();
tx.moveCall({
target: `${pythPackageId}::pyth::get_price_no_older_than`,
arguments: [tx.object(priceInfoObjectId), tx.object.clock(), tx.pure.u64(maxAgeSecs)],
});
const r = await sui.devInspectTransactionBlock({ sender, transactionBlock: tx });
return { status: r.effects.status.status, error: r.effects.status.error };
}
Observe: with a one-second bound on a feed nobody just updated, the call fails with a MoveAbort in pyth::check_price_is_fresh (error code 3, stale price). This is the single most important behavior for a high-stakes consumer to rely on. Oracle safety covers it in depth.
Switchboard's current_result does not gate on age, so there is no equivalent staleness abort to dev-inspect; your consumer checks the timestamps itself, as the Switchboard section of this page describes.
How do I read a Switchboard feed on Sui?
Switchboard is also pull, with a different shape. Instead of a shared price object you update, you hold an Aggregator feed and pull a fresh oracle response into your transaction with fetchUpdateTx, which must be the first action in the programmable transaction block (PTB). A PTB runs its commands in order within one transaction, so the fresh response lands before your consumer reads it. Your Move consumer then reads switchboard::aggregator::current_result.
import { Transaction } from '@mysten/sui/transactions';
import type { SuiClient } from '@mysten/sui/client';
import { SwitchboardClient, Aggregator } from '@switchboard-xyz/sui-sdk';
// Switchboard is on-demand: instead of a shared price object you update, you
// hold an Aggregator feed and pull a fresh oracle response into your
// transaction. `fetchUpdateTx` appends the update commands, and it MUST be the
// first action in the PTB so the feed is fresh before your consumer reads it.
//
// Your Move consumer then reads `switchboard::aggregator::current_result(agg)`,
// which returns a `CurrentResult` exposing `result()` (a `Decimal`) plus
// `min_timestamp_ms()` / `max_timestamp_ms()`. Unlike Pyth's
// `get_price_no_older_than`, `current_result` does not gate on age, so the
// consumer must check the timestamps against the clock itself.
export async function buildSwitchboardUpdateAndRead(
sui: SuiClient,
aggregatorId: string,
consumerTarget: string,
): Promise<Transaction> {
const sb = new SwitchboardClient(sui);
const aggregator = new Aggregator(sb, aggregatorId);
const tx = new Transaction();
await aggregator.fetchUpdateTx(tx);
tx.moveCall({
target: consumerTarget,
arguments: [tx.object(aggregatorId), tx.object.clock()],
});
return tx;
}
current_result returns a CurrentResult exposing the median result() (a Decimal) along with min_timestamp_ms() and max_timestamp_ms(). Unlike Pyth's get_price_no_older_than, it does not gate on age, so the consumer checks the timestamps against the clock itself. See the Switchboard Sui docs for feed creation and the on-demand flow.
Next, wrap a provider read behind a single internal interface with the safety checks built in: The Move adapter pattern.