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.

What price type does the adapter return?​

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

How do I read a price with a staleness bound?​

Pyth offers two readers that this page uses: get_price_no_older_than, which aborts if the feed is older than the bound you pass, and the unchecked get_price_unsafe, which does not gate on age. 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: the tightest freshness bound for a liquidation, because a liquidation acts on the price now, while a generous max-age is acceptable for a display, and the borrowing decision uses the same tight, checked read a liquidation does.

price_from_pyth is that primary reader:

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

How do I guard 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. 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. 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 deviation check against a second source or a time-averaged reference catches a single-feed spike before it drives a liquidation.

assert_confidence enforces the confidence-interval bound:

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

assert_within_deviation enforces the deviation bound against a reference price:

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

How do I fall back to a second feed without aborting?​

Move cannot catch an abort, so a fallback cannot wrap the checked reader in a try. Instead, price_or_fallback works in three steps:

  1. Read the primary with the unchecked get_price_unsafe.
  2. Test the primary's freshness without aborting; if it is fresh, use it.
  3. If the primary is stale, call the checked reader on the fallback, which aborts if the fallback is stale too.

A consumer gets a fresh price or a clear abort, never a silently stale one, and the defined behavior when both feeds are stale is to pause, not guess.

price_or_fallback implements these three steps:

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

How do I unit-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.

The test below covers the branch where confidence is over the bound:

#[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 from the example directory:

cd examples/oracle-adapter/move
sui move test

How do I add a second provider?​

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.