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 sections below are when and why to apply each.
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 it is guarded by a staleness bound:
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. 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. A single feed can spike or a pegged asset can depeg. 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
The guards are shared, 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 (price minus confidence), 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 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 fullnode 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. A single feed can be manipulated or briefly diverge. Add a deviation check against a second source, and reject rather than act when they disagree.