xOracle: Multi-Source Oracle Integration
Aggregate and validate prices from Pyth and Switchboard through Scallop's xOracle layer.
Version note — read this first. There are two generations of these contracts. The standalone sui-x-oracle repo is stale: in it, both the Pyth and Switchboard rules write
set_secondary_price, so its signatures cannot satisfy a primary policy. The production sources live in sui-lending-protocol/contracts/sui_x_oracle/ (packages:x_oracle,pyth_rule,switchboard_rule,switchboard_on_demand_rule,supra_rule, plus custom LST rules). Everything below is built from the production sources and cross-checked against the moveCalls that@scallop-io/sui-scallop-sdkv4.3.0 actually emits (src/txBuilders/oracles/index.ts) — treat the SDK builder as ground truth for the deployed packages.
Overview
sui-x-oracle is the on-chain resolver every price-sensitive Scallop op flows through. It:
- Holds the primary and secondary rules registered for each coin.
- Enforces a price-update policy — only price feeds produced by an authorized rule module can update a
PriceFeed. - Confirms a price update only after every required rule has written, so a single compromised feed cannot drive a borrow/liquidate.
The vast majority of assets resolve via Pyth alone — the basic flow is already covered in the oracle skill. This skill covers the cases where you need to see, configure or compose with the multi-source layer (Switchboard alongside Pyth).
Production contract layout: sui-lending-protocol/contracts/sui_x_oracle/
sui_x_oracle/
├── x_oracle/ core: price_feed, x_oracle, price_update_policy
├── pyth_rule/ Pyth adapter + registry (primary for most assets)
├── switchboard_on_demand_rule/ Switchboard On-Demand adapter + registry
├── switchboard_rule/ legacy Switchboard v2 adapter
├── supra_rule/ Supra adapter (deployed; check getAssetOracles per asset)
└── custom_afsui_rule/ ... LST-specific rules
On-Chain Flow
updateAssetPricesQuick(coin) # TS SDK helper
└─ request = x_oracle::price_update_request<Coin>(x_oracle)
├─ pyth_rule::rule::set_price_as_primary<Coin>(&mut request, ...) # per policy
└─ switchboard rule set_as_secondary_price<Coin>(&mut request, ...) # if configured
└─ x_oracle::confirm_price_update_request<Coin>(x_oracle, request, clock)
(consumes the request; reverts if a required rule didn't write)
price_update_request<T> returns a hot-potato XOraclePriceUpdateRequest<T> — it has no abilities, so every rule call must receive it by &mut reference and confirm_price_update_request<T> must consume it in the same PTB. The confirm_* step is what binds the multi-source guarantee: Scallop only accepts the price if every required rule produced one.
A
supra_rulepackage exists in the production contracts and the SDK can still emit calls for it, but in practice assets resolve with Pyth (primary) and, where configured, Switchboard (secondary). Don't assume a coin's rule set — check it per asset withgetAssetOracles().
Inspect the Resolver
SDK note:
@scallop-io/sui-scallop-sdkv4.3.0 exports the package root plus the./client,./query,./builder,./types,./errors, and./loggersubpaths. Internal deep paths are not exported — reach the resolver helpers throughscallop.client.query.*.
const scallopSDK = new Scallop({ networkType: 'mainnet' });
await scallopSDK.init();
const query = scallopSDK.client.query;
Per-Coin Oracle Rules
// Returns Record<coinName, { primary: SupportOracleType[], secondary: SupportOracleType[] }>
const oracles = await query.getAssetOracles();
// oracles.sui => { primary: ['pyth'], secondary: ['switchboard'] }
// oracles.usdc => { primary: ['pyth'], secondary: [] }
The wrapper delegates to the xOracle repository (src/repositories/xOracle/), which reads the primary and secondary price-update-policy tables and merges the result. For normal app code, the query.getAssetOracles() wrapper is the right entry point.
Price-Update Policy
// Returns { primary, secondary } — two SuiObjectResponse handles for the policy tables
const policies = await query.getPriceUpdatePolicies();
Switchboard On-Demand Aggregators
// Takes coin names, returns aggregator object IDs in the same order
const aggIds = await query.getSwitchboardOnDemandAggregatorObjectIds(['sui', 'usdc']);
// aggIds[0] -> SUI/USD aggregator object ID
// aggIds[1] -> USDC/USD aggregator object ID
Source: getOnDemandAggObjectIds in src/repositories/xOracle/, exposed via src/models/scallopQuery/index.ts.
Composing a Multi-Source PTB Manually
updateAssetPricesQuick handles the whole flow: SDK v4.3.0 reads each coin's { primary, secondary } rule set from the resolver and emits a rule call for every configured source — Pyth, Switchboard, and (if a policy required it) Supra — before confirming. This is implemented in updatePrice in src/txBuilders/oracles/index.ts, which iterates the rules per type and dispatches updatePythPrice / updateSwitchboardPrice / updateSupraPrice accordingly. Drop down to raw moveCalls only when:
- You're integrating Scallop oracle confirmation from outside the SDK.
- You're debugging why a
confirm_price_update_requestis reverting.
The production Move signatures (from sui-lending-protocol/contracts/sui_x_oracle/):
x_oracle::price_update_request<T>(self: &XOracle): XOraclePriceUpdateRequest<T>
pyth_rule::rule::set_price_as_primary<CoinType>( // set_price_as_secondary also exists
request: &mut XOraclePriceUpdateRequest<CoinType>,
pyth_state: &PythState,
pyth_price_info_object: &PriceInfoObject,
pyth_registry: &PythRegistry,
clock: &Clock,
)
// Switchboard On-Demand — SDK v4.3.0 emits `set_as_primary_price` / `set_as_secondary_price`
// against the deployed package (the repo snapshot shows the same params under a
// `set_price_as_*` naming; trust the SDK's target for mainnet):
<switchboard_on_demand_rule>::rule::set_as_secondary_price<CoinType>(
request: &mut XOraclePriceUpdateRequest<CoinType>,
aggregator: &Aggregator,
switchboard_registry: &SwitchboardRegistry,
clock: &Clock,
)
x_oracle::confirm_price_update_request<T>(
self: &mut XOracle,
request: XOraclePriceUpdateRequest<T>,
clock: &Clock,
)
Note the Pyth rule takes no VAA bytes and no fee coin — the Pyth PriceInfoObject must already be fresh. The SDK refreshes it first via the Pyth client (updatePythPriceFeeds, using Hermes VAAs) as a separate step in the same PTB, and only then calls the rule.
The request is a hot potato — capture the return value of step 1 and thread it through every rule call and the final confirm, or the PTB won't compile:
const tx = builder.createTxBlock();
// 0. Refresh the Pyth PriceInfoObject (Hermes VAA push) — the SDK does this
// via updatePythPriceFeeds; without it the rule reads a stale price.
// 1. Request — returns the hot-potato XOraclePriceUpdateRequest<T>
const request = tx.moveCall({
target: `${xOraclePkg}::x_oracle::price_update_request`,
typeArguments: [coinType],
arguments: [tx.object(xOracleId)],
});
// 2. Pyth rule (primary for this coin) — writes its price feed into the request
tx.moveCall({
target: `${pythRulePkg}::rule::set_price_as_primary`,
typeArguments: [coinType],
arguments: [
request,
tx.object(pythStateId),
tx.object(pythPriceInfoObjectId),
tx.object(pythRegistryId),
tx.object('0x6'), // clock
],
});
// 3. Switchboard secondary — only if registered for this coin
tx.moveCall({
target: `${switchboardRulePkg}::rule::set_as_secondary_price`,
typeArguments: [coinType],
arguments: [
request,
tx.object(switchboardAggregatorId),
tx.object(switchboardRegistryId),
tx.object('0x6'), // clock
],
});
// 4. Confirm — consumes the request; reverts if a required rule didn't write
tx.moveCall({
target: `${xOraclePkg}::x_oracle::confirm_price_update_request`,
typeArguments: [coinType],
arguments: [tx.object(xOracleId), request, tx.object('0x6')],
});
// 5. Continue with borrow / liquidate / withdraw_collateral
Package IDs and rule/registry/aggregator object IDs come from ScallopAddress (core.packages.xOracle.id, core.packages.pyth.id, core.oracles.pyth.*, core.packages.switchboard.id, core.oracles.switchboard.registry, core.coins.<coin>.oracle.*) — mirror the exact IDs the SDK builder uses rather than hard-coding.
Why Two Sources?
Scallop's xOracle does not average prices — it accepts the primary and uses the secondary as a sanity bound. The price is rejected when the two diverge beyond a configured threshold. This is the security property you're paying for when an asset is configured with a secondary feed: a single bad VAA or aggregator value cannot move collateral risk.
For risk-sensitive coins (highly volatile, newly listed, low Pyth confidence), expect two rules registered. For majors (SUI, USDC, ETH, BTC), Pyth alone is typically sufficient.
Diagnostics: confirm_price_update_request Revert
Most common causes, in order:
- Secondary rule missing — the resolver expects a Switchboard write that you didn't include. Inspect via
getAssetOracles. - Stale aggregator — Switchboard aggregator hasn't been updated within the protocol's freshness bound. Trigger an on-demand update first.
- Price deviation — primary and secondary diverged past the threshold. Wait for the next Hermes VAA / aggregator round, or temporarily route around the affected coin.
References
- oracle Skill - Pyth-only flow (covers most assets)
- oracle-integration.md - Protocol oracle reference
- sui-x-oracle README - On-chain resolver
- advanced-transactions Skill - Raw moveCall patterns