Pyth oracle integration
Learn how to test smart contracts that depend on Pyth oracle price feeds using Clarinet.
What you'll learn
In this guide, you'll learn how to test Clarity contracts that integrate with Pyth oracle price feeds.
Prerequisites
To follow this guide, you'll need:
Quickstart
Configure mainnet dependencies
Add the Pyth oracle contracts to your Clarinet.toml requirements:
[project]name = "my-oracle-project"[[project.requirements]]contract_id = "SP3R4F6C1J3JQWWCVZ3S7FRRYPMYG6ZW6RZK31FXY.pyth-oracle-v3"[[project.requirements]]contract_id = "SP3R4F6C1J3JQWWCVZ3S7FRRYPMYG6ZW6RZK31FXY.pyth-storage-v3"[[project.requirements]]contract_id = "SP3R4F6C1J3JQWWCVZ3S7FRRYPMYG6ZW6RZK31FXY.pyth-pnau-decoder-v2"[[project.requirements]]contract_id = "SP3R4F6C1J3JQWWCVZ3S7FRRYPMYG6ZW6RZK31FXY.wormhole-core-v3"
This allows your tests to interact with the actual mainnet oracle contracts.
Create a mock oracle for unit tests
For fast unit tests, create a mock oracle contract:
;; Mock Pyth Oracle for testing(define-map price-feeds(buff 32) ;; price-feed-id{price: int,conf: uint,expo: int,ema-price: int,ema-conf: uint,publish-time: uint,prev-publish-time: uint});; Set a mock price(define-public (set-mock-price(feed-id (buff 32))(price int)(expo int))(ok (map-set price-feeds feed-id {price: price,conf: u1000000,expo: expo,ema-price: price,ema-conf: u1000000,publish-time: block-height,prev-publish-time: (- block-height u1)})));; Mock get-price function matching real oracle interface(define-read-only (get-price(feed-id (buff 32))(storage-contract principal))(ok (unwrap!(map-get? price-feeds feed-id)(err u404))));; Mock verify-and-update-price-feeds (no-op for testing)(define-public (verify-and-update-price-feeds(vaa (buff 8192))(params (tuple)))(ok u1))
Write unit tests with mocked prices
Create unit tests using the mock oracle:
import { Cl } from '@stacks/transactions';import { describe, expect, it } from "vitest";const accounts = simnet.getAccounts();const deployer = accounts.get("deployer")!;const wallet1 = accounts.get("wallet_1")!;describe("Benjamin Club with Mock Oracle", () => {it("should set up mock BTC price", () => {// Set BTC price to $100,000 (with 8 decimals)const btcFeedId = "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43";const mockPrice = Cl.int(10000000000000); // $100,000.00 * 10^8const expo = Cl.int(-8);const setPriceResponse = simnet.callPublicFn("mock-pyth-oracle","set-mock-price",[Cl.bufferFromHex(btcFeedId), mockPrice, expo],deployer);expect(setPriceResponse.result).toBeOk(Cl.uint(1));});it("should calculate correct sBTC amount for $100", () => {// With BTC at $100,000, $100 = 0.001 BTCconst response = simnet.callReadOnlyFn("benjamin-club","get-required-sbtc-amount",[],wallet1);// 0.001 BTC = 100,000 satsexpect(response.result).toBeOk(Cl.uint(100000));});it("should mint NFT when user has enough sBTC", () => {// Mock VAA data (can be dummy data for unit tests)const mockVaa = Cl.bufferFromHex("00".repeat(100));// Give user 1 BTC worth of sBTCsimnet.callPublicFn("sbtc-token","mint",[Cl.uint(100000000), Cl.principal(wallet1)],deployer);const mintResponse = simnet.callPublicFn("benjamin-club","mint-for-hundred-dollars",[mockVaa],wallet1);expect(mintResponse.result).toBeOk(Cl.uint(1));});});
Set up mainnet execution tests
For integration tests with real oracle data, use mainnet transaction execution:
import { buildDevnetNetworkOrchestrator } from "@hirosystems/clarinet-sdk";import { Cl } from '@stacks/transactions';import { PriceServiceConnection } from '@pythnetwork/price-service-client';import { describe, expect, it } from "vitest";describe("Benjamin Club with Mainnet Oracle", () => {let orchestrator: any;beforeAll(async () => {orchestrator = buildDevnetNetworkOrchestrator({// Point to a mainnet forkmanifest: "./Clarinet.toml",networkId: 1 // Mainnet});});it("should interact with real Pyth oracle", async () => {// Fetch real VAA from Pythconst pythClient = new PriceServiceConnection("https://hermes.pyth.network",{ priceFeedRequestConfig: { binary: true } });const btcFeedId = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43";const vaas = await pythClient.getLatestVaas([btcFeedId]);const vaaHex = Buffer.from(vaas[0], 'base64').toString('hex');// Use mainnet execution to test with real oracleconst response = await orchestrator.callPublicFn("benjamin-club","get-current-btc-price",[Cl.bufferFromHex(vaaHex)],"wallet_1");expect(response.result).toBeOk();// Verify price is reasonable (between $10k and $200k)const priceData = response.result.value;const price = priceData.data.price.value;expect(price).toBeGreaterThan(1000000000000); // $10kexpect(price).toBeLessThan(20000000000000); // $200k});});
Common patterns
Testing different price scenarios
Create helper functions to test various market conditions:
export function setupPriceScenario(scenario: 'bull' | 'bear' | 'stable',simnet: any) {const prices = {bull: {btc: 15000000000000, // $150,000stx: 500000000, // $5.00eth: 500000000000 // $5,000},bear: {btc: 2000000000000, // $20,000stx: 50000000, // $0.50eth: 100000000000 // $1,000},stable: {btc: 10000000000000, // $100,000stx: 200000000, // $2.00eth: 300000000000 // $3,000}};const selectedPrices = prices[scenario];// Set up all price feedsObject.entries(selectedPrices).forEach(([asset, price]) => {const feedId = getFeedId(asset);simnet.callPublicFn("mock-pyth-oracle","set-mock-price",[Cl.bufferFromHex(feedId), Cl.int(price), Cl.int(-8)],"deployer");});}
Testing price staleness
Ensure your contract handles stale prices correctly:
describe("Price Staleness Handling", () => {it("should reject stale prices", () => {// Set a price with old timestampconst oldTimestamp = simnet.getBlockHeight() - 1000;simnet.callPublicFn("mock-pyth-oracle","set-price-with-timestamp",[Cl.bufferFromHex(BTC_FEED_ID),Cl.int(10000000000000),Cl.int(-8),Cl.uint(oldTimestamp)],deployer);const response = simnet.callPublicFn("benjamin-club","mint-for-hundred-dollars",[mockVaa],wallet1);expect(response.result).toBeErr(Cl.uint(ERR_STALE_PRICE));});});
Deployment testing
Create deployment tests to ensure proper configuration:
---id: 0name: "Mainnet deployment"network: mainnetstacks-node: "https://api.mainnet.hiro.so"bitcoin-node: "https://blockstream.info/api/"plan:batches:- id: 0transactions:- contract-call:contract-id: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRCBGD7R.benjamin-clubmethod: initializeparameters:- "'SP3R4F6C1J3JQWWCVZ3S7FRRYPMYG6ZW6RZK31FXY.pyth-oracle-v3"- "'SP3R4F6C1J3JQWWCVZ3S7FRRYPMYG6ZW6RZK31FXY.pyth-storage-v3"
Gas estimation
Test gas consumption for oracle operations:
describe("Gas Estimation", () => {it("should estimate gas for oracle update + mint", () => {const txCost = simnet.getTransactionCost("benjamin-club","mint-for-hundred-dollars",[Cl.bufferFromHex("00".repeat(1000))], // Typical VAA sizewallet1);console.log("Estimated gas cost:", txCost);// Ensure gas cost is reasonableexpect(txCost.write_length).toBeLessThan(20000);expect(txCost.runtime).toBeLessThan(50000000);});});
CI/CD integration
Set up GitHub Actions for automated testing:
name: Test Oracle Integrationon: [push, pull_request]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v3- name: Install Clarinetrun: |curl -L https://github.com/hirosystems/clarinet/releases/download/v2.1.0/clarinet-linux-x64.tar.gz | tar xzchmod +x ./clarinetsudo mv ./clarinet /usr/local/bin- name: Run unit testsrun: clarinet test --coverage- name: Run mainnet fork testsrun: clarinet test --mainnet-forkenv:MAINNET_API_KEY: ${{ secrets.MAINNET_API_KEY }}