Skip to main content

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.

caution

The examples/oracle-adapter samples pin version 2 of the Pyth Sui SDK (@pythnetwork/pyth-sui-js), which takes the legacy SuiClient from @mysten/sui version 1. Current Sui full nodes do not serve the API that client calls, so the samples fail against them. Version 4 of the Pyth Sui SDK accepts any @mysten/sui version 2 client that exposes the .core API, so pass a SuiGrpcClient to SuiPythClient instead. The SuiPythClient and SuiPriceServiceConnection methods the samples call keep the same signatures in version 4. The checkStale sample also calls devInspectTransactionBlock. On SuiGrpcClient, use simulateTransaction instead.

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:

  1. Fetch the latest update for your feed from Hermes.
  2. Apply it with updatePriceFeeds. It appends the verify and update commands and returns the object IDs to read.
  3. Read the returned PriceInfoObject in 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.

Paying for a Pyth price update​

updatePriceFeeds takes only the transaction, the update data from Hermes, and the feed IDs. The samples pass no separate fee coin to it. A transaction that applies an update costs gas plus the Pyth base update fee, so plan the sender's funds and gas budget for both. The keeper sample sets an explicit gas budget after it appends the update commands:

	await pyth.updatePriceFeeds(tx, updates, [feedId]);
tx.setGasBudget(150_000_000n);

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

The preceding sample is the exported checkStale function, a separate code path from the update-and-read flow. It never applies an update. It looks up the feed's existing PriceInfoObject, calls the Pyth staleness-checked reader with your maxAgeSecs, and runs the transaction as a read-only simulation. It returns an object with the simulation status and, when the read aborts, the error string. It throws no PriceInfoObject for feed followed by the feed ID if the feed has no onchain object.

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.