Skip to main content

Consuming Price Feeds

Both Pyth and Switchboard on Sui are pull oracles: a transaction fetches a fresh signed update offchain, applies it onchain, and reads it, in that order, atomically. This walkthrough reads a Pyth feed end to end on Testnet, then shows the Switchboard on-demand variant.

The TypeScript samples live in the examples/oracle-adapter/ts package and type-check with 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;
caution

The Pyth Sui SDK talks JSON-RPC, but the Mysten Testnet fullnode 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.

The Pyth and Wormhole state object IDs and the Hermes endpoint come from the Pyth Sui addresses; feed IDs come from the Pyth feed list.

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);
}

Pyth: update and read in one transaction

Fetch the latest update for your feed from Hermes, apply it with updatePriceFeeds, and read the returned PriceInfoObject in the same transaction. updatePriceFeeds appends the verify and update commands and returns the object IDs to read.

// 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 (for example a magnitude of 68005477 at exponent -8, or about 0.68 USD) with a confidence interval around 0.1% of the price. The read goes through the adapter's staleness bound, so a price the update failed to refresh aborts rather than reading old.

The stale-price rejection

A price that has not been 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, covered in depth in Oracle safety.

Switchboard: the on-demand variant

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). 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.