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::*;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod constants;
|
||||
pub mod error;
|
||||
pub mod instructions;
|
||||
pub mod oracle;
|
||||
pub mod state;
|
||||
|
||||
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