feat(solisting): add state, errors, and oracle conversion module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,29 @@
|
|||||||
use anchor_lang::prelude::*;
|
use anchor_lang::prelude::*;
|
||||||
|
|
||||||
#[error_code]
|
#[error_code]
|
||||||
pub enum ErrorCode {
|
pub enum SolistingError {
|
||||||
#[msg("Custom error message")]
|
#[msg("Listing is not active")]
|
||||||
CustomError,
|
ListingNotActive,
|
||||||
|
#[msg("No quantity available")]
|
||||||
|
OutOfStock,
|
||||||
|
#[msg("Signer is not authorized")]
|
||||||
|
Unauthorized,
|
||||||
|
#[msg("Currency not accepted by this listing")]
|
||||||
|
CurrencyNotAccepted,
|
||||||
|
#[msg("Resolver is not accepted by this listing")]
|
||||||
|
ResolverNotAccepted,
|
||||||
|
#[msg("Oracle account is required but was not provided (non-stablecoin currency)")]
|
||||||
|
OracleRequired,
|
||||||
|
#[msg("Passed oracle account key does not match the address stored in the listing")]
|
||||||
|
OracleMismatch,
|
||||||
|
#[msg("Oracle price is unavailable, stale, or has too wide a confidence interval")]
|
||||||
|
OraclePriceUnavailable,
|
||||||
|
#[msg("Slippage tolerance exceeded — oracle rate moved unfavorably since tx was built")]
|
||||||
|
SlippageExceeded,
|
||||||
|
#[msg("SPL payment path not yet implemented — descro_spl does not exist")]
|
||||||
|
SplNotImplemented,
|
||||||
|
#[msg("Descro escrow is not in expected state")]
|
||||||
|
EscrowStateUnexpected,
|
||||||
|
#[msg("Descro program address mismatch")]
|
||||||
|
InvalidDescroProgram,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod instructions;
|
pub mod instructions;
|
||||||
|
pub mod oracle;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
use anchor_lang::prelude::*;
|
use anchor_lang::prelude::*;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use anchor_lang::prelude::*;
|
||||||
|
use anchor_lang::AnchorDeserialize;
|
||||||
|
use crate::error::SolistingError;
|
||||||
|
use crate::state::Currency;
|
||||||
|
|
||||||
|
const ORACLE_MAX_AGE_SECS: i64 = 60;
|
||||||
|
|
||||||
|
/// Mirrors pyth_solana_receiver_sdk::price_update::VerificationLevel (borsh layout).
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
|
||||||
|
enum VerificationLevel {
|
||||||
|
Partial { num_signatures: u8 },
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors pythnet_sdk::messages::PriceFeedMessage (borsh layout).
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
|
||||||
|
struct PriceFeedMessage {
|
||||||
|
pub feed_id: [u8; 32],
|
||||||
|
pub price: i64,
|
||||||
|
pub conf: u64,
|
||||||
|
pub exponent: i32,
|
||||||
|
pub publish_time: i64,
|
||||||
|
pub prev_publish_time: i64,
|
||||||
|
pub ema_price: i64,
|
||||||
|
pub ema_conf: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors pyth_solana_receiver_sdk::price_update::PriceUpdateV2 (borsh layout after discriminator).
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
|
||||||
|
struct PriceUpdateV2Local {
|
||||||
|
pub write_authority: Pubkey,
|
||||||
|
pub verification_level: VerificationLevel,
|
||||||
|
pub price_message: PriceFeedMessage,
|
||||||
|
pub posted_slot: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a Pyth V2 PriceFeedAccount and returns (price_i64, exponent_i32).
|
||||||
|
/// `price * 10^exponent` is the USD value of 1 whole token.
|
||||||
|
/// The caller must verify the account key matches listing state before calling this.
|
||||||
|
fn read_usd_price(oracle_account: &AccountInfo) -> Result<(i64, i32)> {
|
||||||
|
let data = oracle_account.try_borrow_data()?;
|
||||||
|
require!(data.len() > 8, SolistingError::OraclePriceUnavailable);
|
||||||
|
// Skip the 8-byte Anchor discriminator, then borsh-decode the rest.
|
||||||
|
let price_update = PriceUpdateV2Local::try_from_slice(&data[8..])
|
||||||
|
.map_err(|_| error!(SolistingError::OraclePriceUnavailable))?;
|
||||||
|
let msg = &price_update.price_message;
|
||||||
|
let now = Clock::get()?.unix_timestamp;
|
||||||
|
require!(
|
||||||
|
now - msg.publish_time <= ORACLE_MAX_AGE_SECS,
|
||||||
|
SolistingError::OraclePriceUnavailable
|
||||||
|
);
|
||||||
|
require!(msg.price > 0, SolistingError::OraclePriceUnavailable);
|
||||||
|
Ok((msg.price, msg.exponent))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the USD price as (raw_i64, exponent_i32) for a currency.
|
||||||
|
/// Stablecoin sentinel (oracle = None) returns (1, 0) = exactly $1.00 per whole token.
|
||||||
|
fn usd_price(oracle: Option<&AccountInfo>) -> Result<(i64, i32)> {
|
||||||
|
match oracle {
|
||||||
|
None => Ok((1, 0)),
|
||||||
|
Some(account) => read_usd_price(account),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts `price` (smallest units of `canonical_currency`) to the equivalent
|
||||||
|
/// amount in `target_currency` smallest units, using USD as an intermediate.
|
||||||
|
///
|
||||||
|
/// Math:
|
||||||
|
/// shift = cp_expo − tp_expo + td − cd
|
||||||
|
/// target = price × cp_raw / tp_raw × 10^shift
|
||||||
|
///
|
||||||
|
/// `canonical_oracle` / `target_oracle`: pass `None` when the currency is a USD stablecoin.
|
||||||
|
/// `expected_amount` + `max_slippage_bps`: slippage check. Pass max_slippage_bps=0 to skip.
|
||||||
|
pub fn convert_with_slippage(
|
||||||
|
canonical_oracle: Option<&AccountInfo>,
|
||||||
|
canonical_currency: &Currency,
|
||||||
|
price: u64,
|
||||||
|
target_oracle: Option<&AccountInfo>,
|
||||||
|
target_currency: &Currency,
|
||||||
|
expected_amount: u64,
|
||||||
|
max_slippage_bps: u16,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let (cp_raw, cp_expo) = usd_price(canonical_oracle)?;
|
||||||
|
let (tp_raw, tp_expo) = usd_price(target_oracle)?;
|
||||||
|
|
||||||
|
let cd = canonical_currency.decimals() as i32;
|
||||||
|
let td = target_currency.decimals() as i32;
|
||||||
|
|
||||||
|
// shift = cp_expo − tp_expo + td − cd
|
||||||
|
let shift: i32 = cp_expo - tp_expo + td - cd;
|
||||||
|
|
||||||
|
// target = price × cp_raw / tp_raw × 10^shift
|
||||||
|
// Use u128 to avoid overflow. tp_raw and cp_raw are always positive (checked above).
|
||||||
|
let numerator = (price as u128)
|
||||||
|
.checked_mul(cp_raw as u128)
|
||||||
|
.ok_or(error!(SolistingError::OraclePriceUnavailable))?;
|
||||||
|
|
||||||
|
let computed: u64 = if shift >= 0 {
|
||||||
|
let scaled = numerator
|
||||||
|
.checked_mul(10u128.pow(shift as u32))
|
||||||
|
.ok_or(error!(SolistingError::OraclePriceUnavailable))?;
|
||||||
|
u64::try_from(scaled / tp_raw as u128)
|
||||||
|
.map_err(|_| error!(SolistingError::OraclePriceUnavailable))?
|
||||||
|
} else {
|
||||||
|
let divisor = 10u128.pow((-shift) as u32);
|
||||||
|
u64::try_from(numerator / divisor / tp_raw as u128)
|
||||||
|
.map_err(|_| error!(SolistingError::OraclePriceUnavailable))?
|
||||||
|
};
|
||||||
|
|
||||||
|
if max_slippage_bps > 0 && expected_amount > 0 {
|
||||||
|
let tolerance = (expected_amount as u128)
|
||||||
|
.checked_mul(max_slippage_bps as u128)
|
||||||
|
.unwrap_or(u128::MAX)
|
||||||
|
/ 10_000;
|
||||||
|
let diff = computed.abs_diff(expected_amount) as u128;
|
||||||
|
require!(diff <= tolerance, SolistingError::SlippageExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(computed)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use anchor_lang::prelude::*;
|
||||||
|
|
||||||
|
/// Payment currency identifier. Amounts are always in the currency's smallest unit:
|
||||||
|
/// Sol → Lamports (u64), Spl → token units (smallest unit per `decimals`).
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace, Debug, PartialEq)]
|
||||||
|
pub enum Currency {
|
||||||
|
Sol,
|
||||||
|
/// `decimals` mirrors the SPL mint's decimals field (e.g. 6 for USDC/USDT, 5 for BONK).
|
||||||
|
/// Storing it here avoids a mint account lookup at order time.
|
||||||
|
Spl { mint: Pubkey, decimals: u8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Currency {
|
||||||
|
pub fn decimals(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Currency::Sol => 9,
|
||||||
|
Currency::Spl { decimals, .. } => *decimals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-alt-currency oracle configuration. Each alt currency carries its own Pyth feed
|
||||||
|
/// because different tokens have different price feeds.
|
||||||
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace, Debug)]
|
||||||
|
pub struct AltCurrencyConfig {
|
||||||
|
pub currency: Currency,
|
||||||
|
/// Pyth V2 PriceFeedAccount for this token priced in USD (TOKEN/USD).
|
||||||
|
/// `None` = treat as a $1.00 USD stablecoin — no oracle account required at order time.
|
||||||
|
pub usd_oracle: Option<Pubkey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[account]
|
||||||
|
#[derive(InitSpace)]
|
||||||
|
pub struct ListingAccount {
|
||||||
|
pub seller: Pubkey,
|
||||||
|
/// Sole source of truth for price. All alt_currencies are derived from this via oracle.
|
||||||
|
pub canonical_currency: Currency,
|
||||||
|
/// Price in canonical_currency's smallest unit.
|
||||||
|
pub price: u64,
|
||||||
|
/// Pyth V2 PriceFeedAccount for the canonical currency priced in USD.
|
||||||
|
/// `None` = canonical is a $1.00 USD stablecoin.
|
||||||
|
/// For Sol canonical: set to the SOL/USD Pyth feed.
|
||||||
|
pub canonical_oracle: Option<Pubkey>,
|
||||||
|
/// Alt currencies the seller accepts. Amounts are oracle-computed at create_order time.
|
||||||
|
/// Empty = no oracle ever needed.
|
||||||
|
#[max_len(3)]
|
||||||
|
pub alt_currencies: Vec<AltCurrencyConfig>,
|
||||||
|
/// Resolvers the seller accepts for dispute resolution. Empty = any resolver ok.
|
||||||
|
#[max_len(4)]
|
||||||
|
pub accepted_resolvers: Vec<Pubkey>,
|
||||||
|
/// Total units the seller offers (includes quantity_reserved).
|
||||||
|
pub quantity: u32,
|
||||||
|
/// Units held by pending orders. available = quantity - quantity_reserved.
|
||||||
|
pub quantity_reserved: u32,
|
||||||
|
#[max_len(256)]
|
||||||
|
pub metadata_uri: String,
|
||||||
|
pub listing_id: u64,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub bump: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[account]
|
||||||
|
#[derive(InitSpace)]
|
||||||
|
pub struct OrderAccount {
|
||||||
|
pub listing: Pubkey,
|
||||||
|
pub buyer: Pubkey,
|
||||||
|
pub seller: Pubkey,
|
||||||
|
pub resolver: Pubkey,
|
||||||
|
/// The currency the buyer chose to pay in.
|
||||||
|
pub payment_currency: Currency,
|
||||||
|
/// Actual amount paid (may differ from price when oracle-converted).
|
||||||
|
pub amount: u64,
|
||||||
|
/// The descro EscrowAccount PDA for this order.
|
||||||
|
pub escrow_account: Pubkey,
|
||||||
|
/// Derived from order_account PDA bytes[0..8]; used as descro escrow_id seed.
|
||||||
|
pub escrow_id: u64,
|
||||||
|
pub order_id: u64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub bump: u8,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user