This tutorial walks you through integrating Switchboard oracle price feeds into your Sui Move contracts using the Quote Verifier pattern. You'll learn how to securely fetch, verify, and use real-time price data.
Store and emit - Updates state and emits a PriceUpdated event
Business Logic Examples
The contract includes example functions for common DeFi use cases:
The TypeScript Client
Here's the complete TypeScript client that creates a QuoteConsumer and updates prices:
Client Walkthrough
Step 1: Create QuoteConsumer
This creates a shared QuoteConsumer object tied to Switchboard's oracle queue.
Step 2: Fetch Oracle Quotes
Quote.fetchUpdateQuote() contacts Crossbar to get signed price data from multiple oracles. The quotes object is added to the transaction automatically.
Step 3: Update Price
This calls your contract's update_price function with the fetched quotes. The Move contract will verify signatures and update the stored price.
Project Structure
Available Scripts
Running the Example
1. Clone the Examples Repository
2. Install Dependencies
3. Smoke-Test Quote Fetching
If you want to validate the Switchboard quote path before deploying your Move package, run the quote-only example first:
module example::example;
use sui::clock::Clock;
use sui::event;
use switchboard::quote::{QuoteVerifier, Quotes};
use switchboard::decimal::Decimal;
// ========== Error Codes ==========
#[error]
const EInvalidQuote: vector<u8> = b"Invalid quote data";
#[error]
const EQuoteExpired: vector<u8> = b"Quote data is expired";
#[error]
const EPriceDeviationTooHigh: vector<u8> = b"Price deviation exceeds threshold";
// ========== Structs ==========
/// QuoteConsumer - Your Oracle Data Consumer
///
/// This struct manages oracle price data with built-in security features:
/// - `quote_verifier`: Verifies oracle signatures and manages quote storage
/// - `last_price`: The most recent verified price
/// - `last_update_time`: Timestamp of the last update
/// - `max_age_ms`: Maximum age for valid quotes
/// - `max_deviation_bps`: Maximum price deviation allowed (basis points)
public struct QuoteConsumer has key {
id: UID,
quote_verifier: QuoteVerifier,
last_price: Option<Decimal>,
last_update_time: u64,
max_age_ms: u64,
max_deviation_bps: u64,
}
/// Event emitted when price is updated
public struct PriceUpdated has copy, drop {
feed_hash: vector<u8>,
old_price: Option<u128>,
new_price: u128,
timestamp: u64,
num_oracles: u64,
}
// ========== Public Functions ==========
/// Initialize a QuoteConsumer with a Quote Verifier
public fun init_quote_consumer(
queue: ID,
max_age_ms: u64,
max_deviation_bps: u64,
ctx: &mut TxContext
): QuoteConsumer {
let verifier = switchboard::quote::new_verifier(ctx, queue);
QuoteConsumer {
id: object::new(ctx),
quote_verifier: verifier,
last_price: option::none(),
last_update_time: 0,
max_age_ms,
max_deviation_bps,
}
}
/// Create and share a QuoteConsumer
public fun create_quote_consumer(
queue: ID,
max_age_ms: u64,
max_deviation_bps: u64,
ctx: &mut TxContext
) {
let consumer = init_quote_consumer(queue, max_age_ms, max_deviation_bps, ctx);
transfer::share_object(consumer);
}
/// Update price using Switchboard oracle quotes
public fun update_price(
consumer: &mut QuoteConsumer,
quotes: Quotes,
feed_hash: vector<u8>,
clock: &Clock,
) {
// STEP 1: Verify oracle signatures and queue membership
consumer.quote_verifier.verify_quotes("es, clock);
// STEP 2: Check if the feed exists in the verified quotes
assert!(consumer.quote_verifier.quote_exists(*& feed_hash), EInvalidQuote);
// STEP 3: Get the verified quote
let quote = consumer.quote_verifier.get_quote(*& feed_hash);
// STEP 4: Ensure the quote is fresh (within 10 seconds)
assert!(quote.timestamp_ms() + 10000 > clock.timestamp_ms(), EQuoteExpired);
// STEP 5: Extract the price value
let new_price = quote.result();
// STEP 6: Validate price deviation (if we have a previous price)
if (consumer.last_price.is_some()) {
let last_price = *consumer.last_price.borrow();
validate_price_deviation(&last_price, &new_price, consumer.max_deviation_bps);
};
// Store the old price for the event
let old_price_value = if (consumer.last_price.is_some()) {
option::some(consumer.last_price.borrow().value())
} else {
option::none()
};
// STEP 7: Update the stored price and timestamp
consumer.last_price = option::some(new_price);
consumer.last_update_time = quote.timestamp_ms();
// STEP 8: Emit event for transparency
event::emit(PriceUpdated {
feed_hash,
old_price: old_price_value,
new_price: new_price.value(),
timestamp: quote.timestamp_ms(),
num_oracles: quotes.oracles().length(),
});
}
/// Get the current price (if available)
public fun get_current_price(consumer: &QuoteConsumer): Option<Decimal> {
consumer.last_price
}
/// Check if the current price is fresh (within max age)
public fun is_price_fresh(consumer: &QuoteConsumer, clock: &Clock): bool {
if (consumer.last_update_time == 0) {
return false
};
let current_time = clock.timestamp_ms();
current_time - consumer.last_update_time <= consumer.max_age_ms
}
// ========== Private Helper Functions ==========
/// Validate that price deviation is within acceptable bounds
fun validate_price_deviation(
old_price: &Decimal,
new_price: &Decimal,
max_deviation_bps: u64
) {
let old_value = old_price.value();
let new_value = new_price.value();
let change = if (new_value > old_value) {
((new_value - old_value) * 10000) / old_value
} else {
((old_value - new_value) * 10000) / old_value
};
assert!(change <= (max_deviation_bps as u128), EPriceDeviationTooHigh);
}
use switchboard::quote::{QuoteVerifier, Quotes};
use switchboard::decimal::Decimal;
/// Calculate collateral ratio using fresh price
public fun calculate_collateral_ratio(
consumer: &QuoteConsumer,
collateral_amount: u64,
debt_amount: u64,
clock: &Clock
): u64 {
assert!(is_price_fresh(consumer, clock), EQuoteExpired);
let price = consumer.last_price.borrow();
let collateral_value = (collateral_amount as u128) * price.value();
let debt_value = (debt_amount as u128) * 1_000_000_000;
((collateral_value * 100) / debt_value as u64)
}
/// Check if liquidation is needed
public fun should_liquidate(
consumer: &QuoteConsumer,
collateral_amount: u64,
debt_amount: u64,
liquidation_threshold: u64,
clock: &Clock
): bool {
if (!is_price_fresh(consumer, clock)) {
return false // Don't liquidate with stale data
};
let ratio = calculate_collateral_ratio(consumer, collateral_amount, debt_amount, clock);
ratio < liquidation_threshold
}
import { SuiClient, getFullnodeUrl } from "@mysten/sui/client";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import { fromBase64 as fromB64 } from "@mysten/sui/utils";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { SwitchboardClient, Quote } from "@switchboard-xyz/sui-sdk";
// Configuration
const config = {
network: (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet",
rpcUrl: process.env.SUI_RPC_URL || undefined,
keystoreIndex: parseInt(process.env.KEYSTORE_INDEX || "0"),
examplePackageId: process.env.EXAMPLE_PACKAGE_ID || "",
feedHash: process.env.FEED_HASH || "0x4cd1cad962425681af07b9254b7d804de3ca3446fbfd1371bb258d2c75059812",
numOracles: parseInt(process.env.NUM_ORACLES || "3"),
maxAgeMs: parseInt(process.env.MAX_AGE_MS || "300000"),
maxDeviationBps: parseInt(process.env.MAX_DEVIATION_BPS || "1000"),
};
// Load keypair from Sui keystore
function loadKeypair(): Ed25519Keypair {
const keystorePath = path.join(os.homedir(), ".sui", "sui_config", "sui.keystore");
const keystore = JSON.parse(fs.readFileSync(keystorePath, "utf-8"));
const secretKey = fromB64(keystore[config.keystoreIndex]);
return Ed25519Keypair.fromSecretKey(secretKey.slice(1));
}
async function main() {
console.log("Switchboard Oracle Quote Verifier Example\n");
const rpcUrl = config.rpcUrl || getFullnodeUrl(config.network);
console.log("Configuration:");
console.log(` Network: ${config.network}`);
console.log(` Package: ${config.examplePackageId}`);
console.log(` Feed: ${config.feedHash}`);
console.log(` Oracles: ${config.numOracles}\n`);
// Initialize clients
const client = new SuiClient({ url: rpcUrl });
const sb = new SwitchboardClient(client);
const state = await sb.fetchState();
console.log("Switchboard Connected:");
console.log(` Oracle Queue: ${state.oracleQueueId}`);
console.log(` Network: ${state.mainnet ? 'Mainnet' : 'Testnet'}\n`);
const keypair = loadKeypair();
const userAddress = keypair.getPublicKey().toSuiAddress();
console.log(`User Address: ${userAddress}\n`);
// Step 1: Create QuoteConsumer
console.log("Step 1: Creating QuoteConsumer...");
const createTx = new Transaction();
createTx.moveCall({
target: `${config.examplePackageId}::example::create_quote_consumer`,
arguments: [
createTx.pure.id(state.oracleQueueId),
createTx.pure.u64(config.maxAgeMs),
createTx.pure.u64(config.maxDeviationBps),
],
});
const createRes = await client.signAndExecuteTransaction({
signer: keypair,
transaction: createTx,
options: { showEffects: true, showObjectChanges: true, showEvents: true },
});
// Extract QuoteConsumer ID
let quoteConsumerId: string | null = null;
for (const change of createRes.objectChanges ?? []) {
if (change.type === "created" && change.objectType?.includes("::example::QuoteConsumer")) {
quoteConsumerId = change.objectId;
console.log(`QuoteConsumer Created: ${quoteConsumerId}\n`);
break;
}
}
if (!quoteConsumerId) {
throw new Error("Failed to create QuoteConsumer");
}
// Wait for object availability
await new Promise(resolve => setTimeout(resolve, 2000));
// Step 2: Fetch Oracle Data
console.log("Step 2: Fetching Oracle Data...");
const updateTx = new Transaction();
const quotes = await Quote.fetchUpdateQuote(sb, updateTx, {
feedHashes: [config.feedHash],
numOracles: config.numOracles,
});
console.log("Oracle data fetched successfully\n");
// Step 3: Verify and Update Price
console.log("Step 3: Verifying and Updating Price...");
updateTx.moveCall({
target: `${config.examplePackageId}::example::update_price`,
arguments: [
updateTx.object(quoteConsumerId),
quotes,
updateTx.pure.vector("u8", Array.from(Buffer.from(config.feedHash.replace("0x", ""), "hex"))),
updateTx.object("0x6"), // Sui Clock
],
});
const updateRes = await client.signAndExecuteTransaction({
signer: keypair,
transaction: updateTx,
options: { showEffects: true, showEvents: true },
});
// Display results
if (updateRes.effects?.status.status === "success") {
console.log("Price Update Successful!\n");
}
// Parse events
for (const event of updateRes.events ?? []) {
if (event.type.includes("PriceUpdated")) {
const data = event.parsedJson as any;
console.log("PriceUpdated Event:");
console.log(` Feed Hash: ${Buffer.from(data.feed_hash).toString('hex')}`);
console.log(` New Price: ${data.new_price}`);
console.log(` Timestamp: ${new Date(parseInt(data.timestamp)).toISOString()}`);
console.log(` Oracles: ${data.num_oracles}`);
}
}
}
main().catch(console.error);
const createTx = new Transaction();
createTx.moveCall({
target: `${config.examplePackageId}::example::create_quote_consumer`,
arguments: [
createTx.pure.id(state.oracleQueueId), // Oracle queue from Switchboard state
createTx.pure.u64(config.maxAgeMs), // Max quote age (5 minutes)
createTx.pure.u64(config.maxDeviationBps), // Max deviation (10%)
],
});
# Build explicitly for testnet
npm run build:testnet
# Build explicitly for mainnet
npm run build:mainnet
# Run Move tests
npm run test
# Deploy to testnet
npm run deploy:testnet
# Deploy to mainnet
npm run deploy:mainnet
# Run the complete example with Move integration
npm run example
# Run simple quote fetching example (defaults to mainnet)
npm run quotes
# Run simple quote fetching example on testnet
npm run quotes -- --network testnet
git clone https://github.com/switchboard-xyz/sb-on-demand-examples
cd sb-on-demand-examples/sui/feeds/basic
npm install
# Defaults to mainnet and dry-runs without a private key
npm run quotes
# Optional: target testnet explicitly
npm run quotes -- --network testnet
# For testnet
npm run build:testnet
# For mainnet
npm run build:mainnet
# For testnet
npm run deploy:testnet
# For mainnet
npm run deploy:mainnet
# Match the network to the Move package you deployed
export SUI_NETWORK=testnet
export EXAMPLE_PACKAGE_ID=0xYOUR_PACKAGE_ID
# Run the example
npm run example
# Or with custom parameters
export FEED_HASH=0x4cd1cad962425681af07b9254b7d804de3ca3446fbfd1371bb258d2c75059812
export NUM_ORACLES=5
npm run example