feat(solisting): add state, errors, and oracle conversion module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thesn10
2026-05-28 00:14:35 +02:00
parent 25da6f5c28
commit ee1e6bdb06
4 changed files with 226 additions and 3 deletions

View File

@@ -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,
}

View File

@@ -1,6 +1,7 @@
pub mod constants;
pub mod error;
pub mod instructions;
pub mod oracle;
pub mod state;
use anchor_lang::prelude::*;

View File

@@ -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)
}

View File

@@ -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,
}