Skip to main content

The Move Adapter Pattern

Reading a provider feed directly scatters two concerns across every call site: the provider's data shape, and the safety checks. An adapter collapses both into one module. Consumers call the adapter, never the provider, so the price shape and every guard live in one place, and swapping or adding a provider never touches a consumer.

The reference module lives in examples/oracle-adapter/move. It builds and its tests pass against the real Pyth Testnet package with sui move test.

One normalized price type

The adapter exposes a single provider-neutral Price. A consumer that values collateral or settles a market never sees a PriceInfoObject or a provider-specific price struct, so a second provider added later returns the same type:

/// A normalized, provider-neutral price. The magnitude is non-negative; the
/// exponent is base-10 and carried separately because provider feeds report
/// prices as `magnitude * 10^expo` with a per-feed exponent. `conf` is the
/// confidence interval in the same units as `mag`. `timestamp` is the publish
/// time in seconds.
public struct Price has copy, drop, store {
mag: u64,
expo_magnitude: u64,
expo_negative: bool,
conf: u64,
timestamp: u64,
}

Read with a staleness bound

The primary reader delegates the staleness gate to Pyth's own get_price_no_older_than, which aborts if the feed is older than the bound, then normalizes the result. A consumer picks the maximum age that suits it: tight for a liquidation, looser for a display.

/// Read a Pyth price, rejecting it if it is older than `max_age_secs`, and
/// normalize it. This delegates the staleness gate to Pyth's own
/// `get_price_no_older_than`, which aborts on a stale feed.
public fun price_from_pyth(price_info: &PriceInfoObject, clock: &Clock, max_age_secs: u64): Price {
let p = pyth::get_price_no_older_than(price_info, clock, max_age_secs);
from_pyth_price(&p)
}

Guard the confidence and deviation

Pyth reports a price with a confidence interval. A wide interval means the sources disagree, which is a signal to pause rather than act on the midpoint. A deviation check against a second source or a time-averaged reference catches a single-feed spike before it drives a liquidation.

/// Abort if the confidence interval is wider than `max_conf_bps` of the price. A
/// blown-out confidence interval means the providers disagree, which is a signal
/// to pause rather than act on the midpoint.
public fun assert_confidence(p: &Price, max_conf_bps: u64) {
assert!(
(p.conf as u128) * 10000 <= (p.mag as u128) * (max_conf_bps as u128),
EConfidenceTooWide,
);
}
/// Abort if the price deviates from `ref_mag` by more than `max_dev_bps`. Use
/// against a second source or a time-averaged reference to catch a single-feed
/// spike before it drives a liquidation. Both values must share the exponent.
public fun assert_within_deviation(p: &Price, ref_mag: u64, max_dev_bps: u64) {
let diff = if (p.mag > ref_mag) p.mag - ref_mag else ref_mag - p.mag;
assert!((diff as u128) * 10000 <= (ref_mag as u128) * (max_dev_bps as u128), EPriceDeviates);
}

Fall back without aborting

Move cannot catch an abort, so a fallback cannot wrap the checked reader in a try. Instead, read the primary with the unchecked get_price_unsafe, test freshness without aborting, and only call the checked reader on the fallback if the primary is stale. A consumer gets a fresh price or a clear abort, never a silently stale one.

/// Read the primary feed if it is fresh, otherwise fall back to the secondary.
///
/// Move cannot catch an abort, so this reads the primary with the unchecked
/// `get_price_unsafe` and tests freshness without aborting. Only if the primary
/// is stale does it call the checked reader on the fallback, which aborts if the
/// fallback is also stale. A consumer gets a fresh price or a clear abort, never
/// a silently stale one.
public fun price_or_fallback(
primary: &PriceInfoObject,
fallback: &PriceInfoObject,
clock: &Clock,
max_age_secs: u64,
): Price {
let raw = pyth::get_price_unsafe(primary);
let p = from_pyth_price(&raw);
if (is_fresh(&p, clock, max_age_secs)) {
p
} else {
price_from_pyth(fallback, clock, max_age_secs)
}
}

Test the guard branches

A Move test cannot mint a PriceInfoObject, so Testnet execution verifies the reads that touch one. Unit tests cover the guard logic and normalization on constructed prices, one test per branch: fresh and stale, confidence within and over the bound, deviation within and over, and a rejected negative and zero price.

#[test]
#[expected_failure(abort_code = pa::EConfidenceTooWide)]
fun confidence_blowout_aborts() {
// conf 20 on price 1000 = 200 bps; bound 100 bps -> abort
let p = pa::new_for_testing(1000, 8, true, 20, 0);
pa::assert_confidence(&p, 100);
}

Run the full suite with sui move test in examples/oracle-adapter/move.

Keep it provider-neutral

The Price type and the guards name no provider, so a Switchboard reader is an additive price_from_switchboard that returns the same Price and reuses the same guards. Consumers never change. The one difference to absorb in the reader: Switchboard's current_result does not gate on age, so a Switchboard reader applies assert_fresh itself where the Pyth reader relies on get_price_no_older_than.

With the read behind a safe interface, the remaining work is deciding what each consumer additionally requires, and how to keep push feeds fresh: Oracle safety for high-stakes consumers.