diff --git a/programs/solisting/src/error.rs b/programs/solisting/src/error.rs index c37199a..0ee9aaf 100644 --- a/programs/solisting/src/error.rs +++ b/programs/solisting/src/error.rs @@ -1,7 +1,29 @@ use anchor_lang::prelude::*; #[error_code] -pub enum ErrorCode { - #[msg("Custom error message")] - CustomError, +pub enum SolistingError { + #[msg("Listing is not active")] + 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, } diff --git a/programs/solisting/src/lib.rs b/programs/solisting/src/lib.rs index a3d8663..6653a02 100644 --- a/programs/solisting/src/lib.rs +++ b/programs/solisting/src/lib.rs @@ -1,6 +1,7 @@ pub mod constants; pub mod error; pub mod instructions; +pub mod oracle; pub mod state; use anchor_lang::prelude::*; diff --git a/programs/solisting/src/oracle.rs b/programs/solisting/src/oracle.rs index e69de29..fc650c4 100644 --- a/programs/solisting/src/oracle.rs +++ b/programs/solisting/src/oracle.rs @@ -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 { + 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) +} diff --git a/programs/solisting/src/state.rs b/programs/solisting/src/state.rs index e69de29..16e7cd0 100644 --- a/programs/solisting/src/state.rs +++ b/programs/solisting/src/state.rs @@ -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, +} + +#[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, + /// 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, + /// Resolvers the seller accepts for dispute resolution. Empty = any resolver ok. + #[max_len(4)] + pub accepted_resolvers: Vec, + /// 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, +}