Oracle Safety for High-Stakes Consumers
Reading a price is a few lines. Reading it safely means naming the ways a price can be wrong and guarding each one, because a lending pool or a settlement pays for the ones you skip. The Move adapter centralizes these guards; the Failure modes and What each consumer additionally needs sections explain when and why to apply each.
Two consumer models appear throughout: a pull consumer updates the price and reads it in its own transaction, while a push consumer reads a stored price that a separate service, a keeper, keeps current (see Keep push feeds fresh with a keeper).
Failure modes
- Stale price. The onchain price has not been refreshed and no longer reflects the market. This is the most common and most dangerous failure, and a staleness bound guards it:
get_price_no_older_thanaborts rather than returning an old value. Observed: reading a Testnet feed nobody just updated with a one-second bound aborts withpyth::check_price_is_fresherror code3. - Confidence blowout. Pyth reports a price with a confidence interval, Pyth's own measure of how uncertain that price is. When sources disagree, the interval widens. Acting on the midpoint of a wide interval is acting on a guess. Sourced to provider behavior: the confidence is part of every Pyth price; the adapter's
assert_confidencerejects a price whose interval exceeds a fraction of the price you choose. - Depeg or deviation. These are two different events: a single feed can spike away from the market, or a pegged asset can depeg from its peg. A deviation check against a second source or a time-averaged reference catches the spike before it drives an action. The adapter's
assert_within_deviationdoes this. - Manipulation window. Design consideration: a price read in a single transaction reflects a single instant, which a well-capitalized actor can move for one block. Averaging over recent values, requiring two independent sources to agree, or using a confidence and deviation bound together raises the cost of moving the price you act on.
- Provider downtime. Design consideration: an oracle can stop updating. A fallback to a second feed, and a defined behavior when both are stale (pause, not guess), keeps a consumer safe rather than stuck on the last value. The adapter's
price_or_fallbackreads the primary and only falls through to the secondary if the primary is stale.
What each consumer additionally needs
All consumers share the guards, but the emphasis differs by what the price drives.
- Liquidations. The tightest freshness bound, because a liquidation acts on the price now. Act only on the checked price, never on
get_price_unsafe, and reject on a wide confidence interval rather than liquidate on a guess. DeepBook Margin is a worked consumer: it values positions through Pyth with a maximum price age and treats a stale price as a hard failure. - Collateral valuation. Value conservatively. When you value collateral to decide how much a user can borrow, use the low end of the confidence interval (the reported price minus the reported confidence), which is the conservative end for collateral, so a wide interval lends less rather than over-lends. A generous max-age is acceptable for a display, but the borrowing decision uses the same tight, checked read a liquidation does.
- Expiry settlement. Freeze exactly one price at or after a defined expiry, and never re-settle. The mechanics are the subject of Resolution patterns; the safety point is that settlement reads through the same staleness bound, so a market cannot settle against a price the transaction did not refresh.
Keep push feeds fresh with a keeper
A pull consumer updates and reads in its own transaction, so freshness is automatic. A push consumer reads a stored price that a separate service, a keeper, keeps current. The keeper.ts example implements that keeper: it fetches from Hermes and applies the update on an interval.
import { Transaction } from '@mysten/sui/transactions';
import type { SuiClient } from '@mysten/sui/client';
import type { Signer } from '@mysten/sui/cryptography';
import type { SuiPythClient, SuiPriceServiceConnection } from '@pythnetwork/pyth-sui-js';
// One push cycle: fetch the latest signed update from Hermes and apply it
// onchain, refreshing the feed's PriceInfoObject. A pull consumer updates and
// reads in its own transaction; a push consumer instead relies on a keeper
// running this loop so the stored price is recent when the consumer reads it.
//
// Each cycle is a transaction that costs gas plus the Pyth base update fee, so
// choose an interval that balances freshness against cost. A push consumer must
// still check the stored price's age, because the keeper can fall behind.
export async function pushOnce(
sui: SuiClient,
pyth: SuiPythClient,
hermes: SuiPriceServiceConnection,
signer: Signer,
feedId: string,
): Promise<string> {
const updates = await hermes.getPriceFeedsUpdateData([feedId]);
const tx = new Transaction();
await pyth.updatePriceFeeds(tx, updates, [feedId]);
tx.setGasBudget(150_000_000n);
const r = await sui.signAndExecuteTransaction({
transaction: tx,
signer,
options: { showEffects: true },
});
return r.digest;
}
// Keeper loop: push on an interval, and back off exponentially on failure so a
// transient RPC or Hermes error does not hammer the network or burn gas. A
// production keeper also caps total spend, alerts on repeated failures, and
// keeps its signing key off the hot path.
export async function runKeeper(
push: () => Promise<string>,
intervalMs: number,
maxBackoffMs: number,
): Promise<void> {
let backoff = intervalMs;
for (;;) {
try {
const digest = await push();
console.log('pushed', digest);
backoff = intervalMs;
} catch (e) {
console.error('push failed, backing off:', String(e).slice(0, 120));
backoff = Math.min(backoff * 2, maxBackoffMs);
}
await new Promise((r) => setTimeout(r, backoff));
}
}
Each push is a transaction that costs gas plus the Pyth base update fee, so the interval trades freshness against cost, and a push consumer still checks the stored price's age because the keeper can fall behind. Running one cycle on Testnet updates the feed and returns a transaction digest; a production keeper runs the loop with backoff, a spend cap, and its signing key off the hot path.
Troubleshooting
MoveAbortincheck_price_is_fresh(code 3): observed. The feed is older than your bound. In a pull flow, confirm the update command runs before the read in the same transaction. For a push feed, the keeper has fallen behind.getFullnodeUrl('testnet')calls return 404: observed. The Mysten Testnet full node serves gRPC; the Pyth SDK needs JSON-RPC. Point the client at a JSON-RPC endpoint.- A price passes the staleness check but looks wrong: hypothesized. An actor can manipulate a single feed, or the feed can briefly diverge. Add a deviation check against a second source, and reject rather than act when they disagree.