Building an Escrow with Price Trigger on Solana: How Pyth Network Provides Real World Market Data.

What is Pyth Network?

Pyth Network is an Oracle protocol that connects the owners of market data to applications on multiple blockchains. Pyth's market data is contributed by over 100 first-party publishers(, including some of the biggest exchanges and market-making firms in the world. Over 350 protocols on 55+ blockchains trust Pyth to secure their applications.

In this blog post, we will be building a Solana program that implements an escrow system with a price trigger. Users can deposit SOL into the escrow, and the funds will be locked until the following conditions are met:

  1. The current SOL/USD price reaches a target price specified by the user when the escrow was created.

  2. The duration (in seconds) specified by the user when the escrow was created has elapsed.

Once these conditions are met, the user who created the escrow can withdraw the deposited SOL.

Setup Anchor

To begin the development of our Solana program, we'll first establish the necessary Anchor workspace configuration.

Before proceeding, verify that Rust is properly installed on your machine. For installation guidance, consult the official Rust documentation.

Then we set our anchor workspace

anchor init pyth_escrow

After a successful installation,we would create a couple of files and folders to completely setup, first we would create an instruction folder, inside the src folder,

this folder would contain the deposit.rs , withdraw.rs and mod.rs file, we would also create an errors.rs and state.rs file outside inside the src folder but outside the instructions folder (check the GitHub repo for correct file structure).

state.rs file contains the state of our escrow account

use anchor_lang::prelude::*;
pub const SOL_USDC_FEED:&str ="0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";

#[account]
#[derive(InitSpace)]
pub struct EscrowState{
    pub unlock_price:f64,
    pub escrow_amount:u64
}

errors.rs contains our custom-made errors.

use anchor_lang::prelude::*;

#[error_code]
#[derive(Eq,PartialEq)]
pub enum EscrowErrorCode{
    #[msg("Not a valid switchboard account")]
    InvalidSwitchboardAccount,
    #[msg("Switchboard feed has not been updated in 5 minutes")]
    StaleFeed,
    #[msg("Switchboard feed exceeded provided confidence interval")]
    ConfidenceIntervalExceeded,
    #[msg("current SOL price is not above Escrow unlock price.")]
    SolPriceAboveUnlockPrice,
    #[msg("Price Overflow")]
    PriceOverFlow
}

deposit.rs contains our Deposit struct and the Deposit handler for handling the deposit logic.

//Deposit Struct

 #[derive(Accounts)]
pub struct Deposit<'info>{
    // user account
    #[account(mut)]
    pub user:Signer<'info>,
    // account to store SOL in escrow
    #[account(
        init,
        seeds=[b"ESCROW",user.key().as_ref()],
        bump,
        payer=user,
        space=EscrowState::INIT_SPACE
    )]
    pub escrow_account:Account<'info,EscrowState>,

    pub system_program:Program<'info,System>
}

In this struct , we create a PDA(Program Derived Address) for our escrow_account , This escrow_account would be responsible for storing SOL. We also made use of the system_program account, which is responsible for creating PDAs. This user is the user depositing the SOL.

deposit_handler function.

pub fn deposit_handler(ctx:Context<Deposit>,escrow_amount:u64,unlock_price:f64)->Result<()>{
    let escrow_state = &mut ctx.accounts.escrow_account;
    escrow_state.unlock_price = unlock_price;
    escrow_state.escrow_amount = escrow_amount;

    let cpi_ctx = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        anchor_lang::system_program::Transfer{
            from:ctx.accounts.user.to_account_info(),
            to:ctx.accounts.escrow_account.to_account_info()
        },
    );
    anchor_lang::system_program::transfer(cpi_ctx,escrow_amount)?;

    msg!("Transfer complete. Escrow will unlock SOL at {}", &ctx.accounts.escrow_account.unlock_price);
    Ok(())
}

This function is responsible for transferring SOL from this users account to the escrow_account.

the complete deposit.rs

use crate::state::*;
use anchor_lang::prelude::*;

pub fn deposit_handler(ctx:Context<Deposit>,escrow_amount:u64,unlock_price:f64)->Result<()>{
    msg!("Desposited funds in escrow....");

    let escrow_state = &mut ctx.accounts.escrow_account;
    escrow_state.unlock_price = unlock_price;
    escrow_state.escrow_amount = escrow_amount;

    let cpi_ctx = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        anchor_lang::system_program::Transfer{
            from:ctx.accounts.user.to_account_info(),
            to:ctx.accounts.escrow_account.to_account_info()
        },
    );
    anchor_lang::system_program::transfer(cpi_ctx,escrow_amount)?;

    msg!("Transfer complete. Escrow will unlock SOL at {}", &ctx.accounts.escrow_account.unlock_price);
    Ok(())
}


#[derive(Accounts)]
pub struct Deposit<'info>{
    // user account
    #[account(mut)]
    pub user:Signer<'info>,
    // account to store SOL in escrow
    #[account(
        init,
        seeds=[b"escrow",user.key().as_ref()],
        bump,
        payer=user,
        space=EscrowState::INIT_SPACE
    )]
    pub escrow_account:Account<'info,EscrowState>,

    pub system_program:Program<'info,System>
}

withdraw.rs

#[derive(Accounts)]
pub struct Withdraw<'info>{

    #[account(mut)]
    pub user:Signer<'info>,
    // escrow_account
    #[account(
        mut,
        seeds=[b"escrow", user.key().as_ref()],
        bump,
        close=user
    )]
    pub escrow_account:Account<'info,EscrowState>,
    pub price_update: Account<'info, PriceUpdateV2>,
    pub system_program: Program<'info, System>,
}

The withdraw struct is quite similar to the deposit struct , with two exceptions

  1. The escrow_account is no longer initialized, but is now a mutable Account.

  2. The price_update account which is gotten from the pyth_solana_receiver_sdk crate, which we must install.

     cargo add pyth_solana_receiver_sdk
    

    The price_update is the account responsible for fetching the current SOL/USD price feed from Pyth Network.

withdraw_handler function


pub fn withdraw_handler(ctx:Context<Withdraw>,escrow_amount:u64)->Result<()>{
    // Get accounts
    let escrow_state = &ctx.accounts.escrow_account;
    let price_update = &ctx.accounts.price_update;
     // get_price_no_older_than will fail if the price update is more than 30 seconds old
    let maximum_age: u64 = 30;

    // get_price_no_older_than will fail if the price update is for a different price feed.
    // This string is the id of the SOL/USD feed. See https://pyth.network/developers/price-feed-ids for all available IDs.

    let feed_id: [u8; 32] = get_feed_id_from_hex(SOL_USDC_FEED)?;
    let price = price_update.get_price_no_older_than(&Clock::get()?, maximum_age, &feed_id)?;

    let current_price = price.price
    .checked_mul(10_i64.pow(price.exponent.unsigned_abs()))
    .ok_or(EscrowErrorCode::PriceOverFlow)?;

    if current_price  < (escrow_state.unlock_price as u64).try_into().unwrap(){
       return Err(EscrowErrorCode::SolPriceAboveUnlockPrice.into())
    }
    let cpi_ctx = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        anchor_lang::system_program::Transfer{        
            from:ctx.accounts.escrow_account.to_account_info(),
            to:ctx.accounts.user.to_account_info(),
        },
    );
    anchor_lang::system_program::transfer(cpi_ctx,escrow_amount)?;

    Ok(())
}

The function is responsible for withdrawing of SOL from the escrow_account back to the user’s account.

let feed_id: [u8; 32] = get_feed_id_from_hex(SOL_USDC_FEED)?;

let price = price_update.get_price_no_older_than(&Clock::get()?, maximum_age, &feed_id)?;

The get_feed_id_from_hex() function gets a FeedId from a hex string. Price feed IDs are a 32-byte unique identifier for each price feed in the Pyth network. They are sometimes represented as a 64-character hex string (with or without a 0x prefix).

let current_price = price.price
    .checked_mul(10_i64.pow(price.exponent.unsigned_abs()))
    .ok_or(EscrowErrorCode::PriceOverFlow)?;

Let me break down this line of code.

price.exponent is coming from the price feed oracle. For example:

  • If the price feed returns SOL = $102.50, it might be represented as:

  • price.price = 10250

  • price.exponent = -2 (meaning move decimal 2 places left).

The complete withdraw.rs

use crate::state::*;
use crate::errors::*;
use std::str::FromStr;
use anchor_lang::prelude::*;
use pyth_solana_receiver_sdk::price_update::{ get_feed_id_from_hex,
    PriceUpdateV2};
use anchor_lang::solana_program::clock::Clock;

#[derive(Accounts)]
pub struct Withdraw<'info>{

    #[account(mut)]
    pub user:Signer<'info>,
    // escrow_account
    #[account(
        mut,
        seeds=[b"escrow", user.key().as_ref()],
        bump,
        close=user
    )]
    pub escrow_account:Account<'info,EscrowState>,
    pub price_update: Account<'info, PriceUpdateV2>,
    pub system_program: Program<'info, System>,
}

pub fn withdraw_handler(ctx:Context<Withdraw>,escrow_amount:u64)->Result<()>{
    // Get accounts
    let escrow_state = &ctx.accounts.escrow_account;
    let price_update = &ctx.accounts.price_update;
     // get_price_no_older_than will fail if the price update is more than 30 seconds old
    let maximum_age: u64 = 30;

    // get_price_no_older_than will fail if the price update is for a different price feed.
    // This string is the id of the SOL/USD feed. See https://pyth.network/developers/price-feed-ids for all available IDs.

    let feed_id: [u8; 32] = get_feed_id_from_hex(SOL_USDC_FEED)?;
    let price = price_update.get_price_no_older_than(&Clock::get()?, maximum_age, &feed_id)?;

    let current_price = price.price
    .checked_mul(10_i64.pow(price.exponent.unsigned_abs()))
    .ok_or(EscrowErrorCode::PriceOverFlow)?;

    if current_price  < (escrow_state.unlock_price as u64).try_into().unwrap(){
       return Err(EscrowErrorCode::SolPriceAboveUnlockPrice.into())
    }
    let cpi_ctx = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        anchor_lang::system_program::Transfer{        
            from:ctx.accounts.escrow_account.to_account_info(),
            to:ctx.accounts.user.to_account_info(),
        },
    );
    anchor_lang::system_program::transfer(cpi_ctx,escrow_amount)?;

    Ok(())
}

mod.rs

pub mod deposit;
pub use deposit::*;

pub mod withdraw;
pub use withdraw::*;

lib.rs

use anchor_lang::prelude::*;
use instructions::*;
mod instructions;
mod state;
mod errors;

declare_id!("YOUR_PROGRAM_ID");

#[program]
pub mod escrow {
    use super::*;

    pub fn deposit_sol(ctx:Context<Deposit>,escrow_amt:u64,unlock_price:f64)->Result<()>{
       deposit_handler(ctx,escrow_amt,unlock_price)
    }

    pub fn withdraw_sol(ctx: Context<Withdraw>,escrow_amount:u64) -> Result<()> {
        withdraw_handler(ctx,escrow_amount)
    }
}

Check out my github repository for the full code base

https://github.com/OFUZORCHUKWUEMEKE/solana-escrow

3
Subscribe to my newsletter

Read articles from Ofuzor Chukwuemeke directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ofuzor Chukwuemeke
Ofuzor Chukwuemeke

I'm a full-stack Blockchain engineer