Files
descro/docs/superpowers/plans/2026-05-19-solisting.md
2026-05-25 21:55:02 +02:00

67 KiB
Raw Blame History

Solisting Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

⚠️ Reference plan — do not implement yet. Two prerequisites must be completed first:

  1. 2026-05-19-descro-buyer-flow.md — adds buyer_create_escrow, seller_confirm, and symmetric cancel to descro
  2. AcceptancePolicy prerequisite (adding AcceptancePolicy enum to descro_ext_resolvers) — documented in the old plan's prerequisite section

Goal: Build the solisting Anchor program — a coordination and discovery layer for product listings and bilateral order consent, orchestrating the full flow from buyer intent → bilateral consent → active descro escrow without ever touching the descro vault directly.

Architecture:

  • ListingAccount holds a single canonical price (canonical_currency + price) as the sole source of truth. Alternative currencies (alt_currencies: Vec<AltCurrencyConfig>) are always converted from canonical at create_order time via on-chain Pyth oracles — no independent price per currency, no arbitrage possible.
  • Each AltCurrencyConfig carries its own usd_oracle: Option<Pubkey> (the Pyth TOKEN/USD feed for that token; None = treat as a $1 USD stablecoin). The canonical currency has a parallel canonical_oracle: Option<Pubkey>. Currency::Spl stores decimals: u8 so the program never needs to look up the mint.
  • OrderAccount records the bilateral consent: which currency the buyer chose, the oracle-computed amount, and a pointer to the descro escrow.
  • The oracle module converts between any two currencies through USD as an intermediate (using up to two Pyth feeds): canonical_usd = amount × canonical_price, target_amount = canonical_usd ÷ target_price. Both sides can independently be a stablecoin (None oracle = $1 peg, no account needed).
  • accepted_resolvers is a global list per listing — resolvers handle disputes, not payments, so one list covers all currencies.
  • create_order CPIs descro.buyer_create_escrow atomically. accept_order CPIs descro.seller_confirm. Solisting never holds or transfers vault funds.
  • State drift (buyer cancels directly on descro) is handled gracefully: terminal instructions read descro state first and skip the CPI if the escrow is already resolved.

Key design decisions:

  • No OrderVault — funds live in descro's vault from the moment create_order is called
  • AcceptancePolicy is enforced by descro in seller_confirm, not by solisting
  • quantity_reserved tracks pending orders; decremented at create_order, restored at reject_order/cancel_order
  • escrow_id is derived from the first 8 bytes of the order_account PDA to guarantee uniqueness without coordinator state
  • close_stale_order can be called by anyone to clean up an OrderAccount whose descro escrow is already terminal
  • max_slippage_bps argument on create_order protects buyer from price movement between submission and confirmation
  • SPL token path (descro_spl CPI) is not yet implemented — create_order errors on payment_currency = Spl until descro_spl exists; state and oracle are designed to support it without schema changes

Tech Stack: Rust, Anchor 1.0.x, LiteSVM 0.10.0, pyth-solana-receiver-sdk for oracle reads. Build order: descro_ext_resolversdescrosolisting.


File Map

Action Path Responsibility
Create programs/solisting/Cargo.toml Crate definition + dependencies (incl. pyth-solana-receiver-sdk)
Create programs/solisting/src/lib.rs Program entrypoints + declare_id!
Create programs/solisting/src/state.rs ListingAccount, OrderAccount, Currency enum, AltCurrencyConfig
Create programs/solisting/src/error.rs SolistingError enum
Create programs/solisting/src/oracle.rs Generic Pyth feed reader + USD-intermediate cross-currency conversion + slippage check
Create programs/solisting/src/instructions.rs Module re-exports
Create programs/solisting/src/instructions/create_listing.rs Init ListingAccount
Create programs/solisting/src/instructions/update_listing.rs Mutate price/quantity/currencies
Create programs/solisting/src/instructions/close_listing.rs Close ListingAccount
Create programs/solisting/src/instructions/create_order.rs Validate currency, optional oracle conversion, CPI buyer_create_escrow
Create programs/solisting/src/instructions/accept_order.rs CPI seller_confirm, close OrderAccount
Create programs/solisting/src/instructions/reject_order.rs Defensive CPI cancel, close OrderAccount
Create programs/solisting/src/instructions/cancel_order.rs Defensive CPI cancel, close OrderAccount
Create programs/solisting/src/instructions/close_stale_order.rs Clean up orphaned OrderAccount
Create programs/solisting/tests/common/mod.rs LiteSVM setup, helpers, mock oracle account builder
Create programs/solisting/tests/test_listings.rs Listing instruction tests
Create programs/solisting/tests/test_orders.rs Order flow tests
Modify Cargo.toml (workspace root) Add solisting to workspace members

Task 1: Workspace + Crate Setup

  • Step 1: Add solisting to workspace root Cargo.toml

Add "programs/solisting" to the [workspace] members array.

  • Step 2: Create programs/solisting/Cargo.toml
[package]
name = "solisting"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "solisting"

[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build"]
anchor-debug = []
custom-heap = []
custom-panic = []

[dependencies]
anchor-lang = "1.0.2"
descro = { path = "../descro", features = ["cpi"] }
descro_ext_resolvers = { path = "../descro_ext_resolvers" }
# Pyth oracle SDK for on-chain price reads (Pull Oracle / V2 accounts).
# Verify latest compatible version at https://crates.io/crates/pyth-solana-receiver-sdk
pyth-solana-receiver-sdk = "0.3"

[dev-dependencies]
litesvm = "0.10.0"
solana-message = "3.0.1"
solana-transaction = "3.0.2"
solana-signer = "3.0.0"
solana-keypair = "3.0.1"

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
  • Step 3: Create directory structure
mkdir -p programs/solisting/src/instructions programs/solisting/tests/common
touch programs/solisting/src/lib.rs
touch programs/solisting/src/state.rs
touch programs/solisting/src/error.rs
touch programs/solisting/src/oracle.rs
touch programs/solisting/src/instructions.rs
touch programs/solisting/src/instructions/create_listing.rs
touch programs/solisting/src/instructions/update_listing.rs
touch programs/solisting/src/instructions/close_listing.rs
touch programs/solisting/src/instructions/create_order.rs
touch programs/solisting/src/instructions/accept_order.rs
touch programs/solisting/src/instructions/reject_order.rs
touch programs/solisting/src/instructions/cancel_order.rs
touch programs/solisting/src/instructions/close_stale_order.rs
  • Step 4: Commit skeleton
git add programs/solisting/ Cargo.toml Cargo.lock
git commit -m "chore(solisting): add crate skeleton and workspace entry"

Task 2: State, Errors, Oracle Module

Files: state.rs, error.rs, oracle.rs

  • Step 1: Write state.rs
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,
}
  • Step 2: Write error.rs
use anchor_lang::prelude::*;

#[error_code]
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,
}
  • Step 3: Write oracle.rs

The oracle module converts between any two currencies using USD as an intermediate. Each currency has an optional Pyth feed (TOKEN/USD). None = treat as $1.00 stablecoin. No feed ID is hardcoded — the seller registers the oracle account address in the listing; the program only verifies that the passed account key matches the stored key (done in create_order, not here).

Math (using USD as pivot):

canonical_usd  = price × (cp_raw × 10^cp_expo) / 10^cd
target_amount  = canonical_usd   × 10^td / (tp_raw × 10^tp_expo)
              = price × cp_raw / tp_raw × 10^(cp_expo  tp_expo + td  cd)
let shift      = cp_expo  tp_expo + td  cd   (can be positive or negative)

Stablecoin sentinel: (1, 0) meaning exactly $1.00 per whole token.

Example — SOL → BONK (cd=9, cp_expo=8, td=5, tp_expo=8): shift = 8 (8) + 5 9 = 4target = ca × cp_raw / tp_raw / 10^4

Example — SOL → USDC (cd=9, cp_expo=8, td=6, tp_raw=1, tp_expo=0 sentinel): shift = 8 0 + 6 9 = 11target = ca × cp_raw / 10^11 ✓ (matches old formula)

use anchor_lang::prelude::*;
use anchor_lang::AccountDeserialize;
use pyth_solana_receiver_sdk::price_update::PriceUpdateV2;
use crate::error::SolistingError;
use crate::state::Currency;

const ORACLE_MAX_AGE_SECS: i64 = 60;

/// Reads any Pyth V2 PriceFeedAccount and returns (price_i64, exponent_i32) where
/// `price * 10^exponent` is the USD value of 1 whole token (price in USD per whole unit).
/// The caller is responsible for verifying the account key matches listing state.
fn read_usd_price(oracle_account: &AccountInfo) -> Result<(i64, i32)> {
    let data = oracle_account.try_borrow_data()?;
    let price_update = PriceUpdateV2::try_deserialize(&mut data.as_ref())
        .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.
///
/// `canonical_oracle` / `target_oracle`: pass `None` when the currency is a USD stablecoin.
/// `canonical_currency` / `target_currency`: used only for their `decimals()` value.
///
/// Slippage: if |computed  expected_amount| / expected_amount > max_slippage_bps / 10_000,
/// returns `SlippageExceeded`. Pass `max_slippage_bps = 0` to skip the check.
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)
}
  • Step 4: Verify state.rs compiles in isolation (no instruction files yet)
cargo check --manifest-path programs/solisting/Cargo.toml 2>&1 | head -20

Expected: errors only about missing modules (lib.rs not wired yet), no type errors in state.rs itself.

  • Step 5: Commit
git add programs/solisting/src/state.rs programs/solisting/src/error.rs programs/solisting/src/oracle.rs
git commit -m "feat(solisting): add state, errors, and oracle conversion module"

Task 3: Listing Instructions

Files: create_listing.rs, update_listing.rs, close_listing.rs

  • Step 1: Write failing tests in tests/test_listings.rs
mod common;
use common::*;

#[test]
fn seller_can_create_sol_only_listing() {
    let (mut svm, seller, _) = setup();
    let listing_id: u64 = 1;
    let price = 100_000_000u64; // 0.1 SOL in lamports

    let ix = ix_create_listing(
        &seller.pubkey(),
        listing_id,
        Currency::Sol,
        price,
        None,           // canonical_oracle: None (no alt currencies)
        vec![],         // no alt_currencies
        vec![],         // any resolver
        10,             // quantity
        "ipfs://test".to_string(),
    );
    send(&mut svm, &[ix], &[&seller]);

    let listing = read_listing(&svm, &seller.pubkey(), listing_id);
    assert_eq!(listing.canonical_currency, Currency::Sol);
    assert_eq!(listing.price, price);
    assert_eq!(listing.alt_currencies.len(), 0);
    assert_eq!(listing.canonical_oracle, None);
    assert_eq!(listing.quantity, 10);
    assert_eq!(listing.quantity_reserved, 0);
    assert!(listing.is_active);
}

#[test]
fn seller_can_create_listing_with_usdc_alt_stablecoin() {
    // canonical=Sol, alt=USDC with no oracle (stablecoin $1 peg)
    let (mut svm, seller, _) = setup();
    let usdc_mint = Pubkey::new_unique();
    let sol_usd_feed = Pubkey::new_unique(); // mock SOL/USD feed key
    let ix = ix_create_listing(
        &seller.pubkey(),
        10,
        Currency::Sol,
        1_000_000_000,
        Some(sol_usd_feed),     // canonical_oracle = SOL/USD feed
        vec![AltCurrencyConfig {
            currency: Currency::Spl { mint: usdc_mint, decimals: 6 },
            usd_oracle: None,   // USDC is a stablecoin — no feed needed
        }],
        vec![],
        5,
        "ipfs://x".to_string(),
    );
    send(&mut svm, &[ix], &[&seller]);

    let listing = read_listing(&svm, &seller.pubkey(), 10);
    assert_eq!(listing.canonical_oracle, Some(sol_usd_feed));
    assert_eq!(listing.alt_currencies[0].usd_oracle, None);
}

#[test]
fn seller_can_create_listing_with_bonk_alt_oracle() {
    // canonical=Sol, alt=BONK — needs both SOL/USD and BONK/USD feeds
    let (mut svm, seller, _) = setup();
    let bonk_mint = Pubkey::new_unique();
    let sol_usd_feed = Pubkey::new_unique();
    let bonk_usd_feed = Pubkey::new_unique();
    let ix = ix_create_listing(
        &seller.pubkey(),
        11,
        Currency::Sol,
        1_000_000_000,
        Some(sol_usd_feed),
        vec![AltCurrencyConfig {
            currency: Currency::Spl { mint: bonk_mint, decimals: 5 },
            usd_oracle: Some(bonk_usd_feed),
        }],
        vec![],
        5,
        "".to_string(),
    );
    send(&mut svm, &[ix], &[&seller]);

    let listing = read_listing(&svm, &seller.pubkey(), 11);
    assert_eq!(listing.canonical_oracle, Some(sol_usd_feed));
    assert_eq!(listing.alt_currencies[0].usd_oracle, Some(bonk_usd_feed));
}

#[test]
fn seller_can_update_listing() {
    let (mut svm, seller, _) = setup();
    let listing_id: u64 = 2;
    let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, None, vec![], vec![], 10, "".to_string());
    send(&mut svm, &[ix], &[&seller]);

    let ix_update = ix_update_listing(
        &seller.pubkey(),
        listing_id,
        Currency::Sol,
        2_000_000_000,
        None,
        vec![],
        vec![],
        20,
        "ipfs://new".to_string(),
    );
    send(&mut svm, &[ix_update], &[&seller]);

    let listing = read_listing(&svm, &seller.pubkey(), listing_id);
    assert_eq!(listing.price, 2_000_000_000);
    assert_eq!(listing.quantity, 20);
}

#[test]
fn seller_can_close_listing() {
    let (mut svm, seller, _) = setup();
    let listing_id: u64 = 3;
    let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, None, vec![], vec![], 5, "".to_string());
    send(&mut svm, &[ix], &[&seller]);

    let ix_close = ix_close_listing(&seller.pubkey(), listing_id);
    send(&mut svm, &[ix_close], &[&seller]);

    let pda = listing_pda(&seller.pubkey(), listing_id);
    assert!(svm.get_account(&pda).is_none());
}
  • Step 2: Run tests — verify they fail with "unresolved" errors (not compiled yet)
cargo test --manifest-path programs/solisting/Cargo.toml --test test_listings 2>&1 | head -20

Expected: compile errors (ix helpers not defined yet).

  • Step 3: Write create_listing.rs
use anchor_lang::prelude::*;
use crate::state::{Currency, ListingAccount};
use crate::error::SolistingError;

#[derive(Accounts)]
#[instruction(listing_id: u64)]
pub struct CreateListing<'info> {
    #[account(mut)]
    pub seller: Signer<'info>,

    #[account(
        init,
        payer = seller,
        space = 8 + ListingAccount::INIT_SPACE,
        seeds = [b"listing", seller.key().as_ref(), &listing_id.to_le_bytes()],
        bump
    )]
    pub listing_account: Account<'info, ListingAccount>,

    pub system_program: Program<'info, System>,
}

pub fn handler(
    ctx: Context<CreateListing>,
    listing_id: u64,
    canonical_currency: Currency,
    price: u64,
    canonical_oracle: Option<Pubkey>,
    alt_currencies: Vec<AltCurrencyConfig>,
    accepted_resolvers: Vec<Pubkey>,
    quantity: u32,
    metadata_uri: String,
) -> Result<()> {
    let listing = &mut ctx.accounts.listing_account;
    listing.seller = ctx.accounts.seller.key();
    listing.canonical_currency = canonical_currency;
    listing.price = price;
    listing.canonical_oracle = canonical_oracle;
    listing.alt_currencies = alt_currencies;
    listing.accepted_resolvers = accepted_resolvers;
    listing.quantity = quantity;
    listing.quantity_reserved = 0;
    listing.metadata_uri = metadata_uri;
    listing.listing_id = listing_id;
    listing.is_active = true;
    listing.bump = ctx.bumps.listing_account;
    Ok(())
}
  • Step 4: Write update_listing.rs
use anchor_lang::prelude::*;
use crate::state::{Currency, ListingAccount};
use crate::error::SolistingError;

#[derive(Accounts)]
pub struct UpdateListing<'info> {
    pub seller: Signer<'info>,

    #[account(
        mut,
        seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
        bump = listing_account.bump,
        constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
        constraint = listing_account.is_active @ SolistingError::ListingNotActive,
    )]
    pub listing_account: Account<'info, ListingAccount>,
}

pub fn handler(
    ctx: Context<UpdateListing>,
    canonical_currency: Currency,
    price: u64,
    canonical_oracle: Option<Pubkey>,
    alt_currencies: Vec<AltCurrencyConfig>,
    accepted_resolvers: Vec<Pubkey>,
    quantity: u32,
    metadata_uri: String,
) -> Result<()> {
    let listing = &mut ctx.accounts.listing_account;
    listing.canonical_currency = canonical_currency;
    listing.price = price;
    listing.canonical_oracle = canonical_oracle;
    listing.alt_currencies = alt_currencies;
    listing.accepted_resolvers = accepted_resolvers;
    listing.quantity = quantity;
    listing.metadata_uri = metadata_uri;
    Ok(())
}
  • Step 5: Write close_listing.rs
use anchor_lang::prelude::*;
use crate::state::ListingAccount;
use crate::error::SolistingError;

#[derive(Accounts)]
pub struct CloseListing<'info> {
    #[account(mut)]
    pub seller: Signer<'info>,

    #[account(
        mut,
        seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
        bump = listing_account.bump,
        constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
        close = seller,
    )]
    pub listing_account: Account<'info, ListingAccount>,
}

pub fn handler(_ctx: Context<CloseListing>) -> Result<()> {
    Ok(())
}
  • Step 6: Write tests/common/mod.rs skeleton (listing helpers only for now)
use litesvm::LiteSVM;
use solana_keypair::Keypair;
use solana_signer::Signer;
use anchor_lang::prelude::Pubkey;
use solisting::state::{Currency, ListingAccount};

pub fn setup() -> (LiteSVM, Keypair, Keypair) {
    let mut svm = LiteSVM::new();
    svm.add_program_from_file(
        solisting::id(),
        "../../target/deploy/solisting.so",
    ).unwrap();
    let seller = Keypair::new();
    let buyer = Keypair::new();
    svm.airdrop(&seller.pubkey(), 10_000_000_000).unwrap();
    svm.airdrop(&buyer.pubkey(), 10_000_000_000).unwrap();
    (svm, seller, buyer)
}

pub fn listing_pda(seller: &Pubkey, listing_id: u64) -> Pubkey {
    Pubkey::find_program_address(
        &[b"listing", seller.as_ref(), &listing_id.to_le_bytes()],
        &solisting::id(),
    ).0
}

pub fn read_listing(svm: &LiteSVM, seller: &Pubkey, listing_id: u64) -> ListingAccount {
    let pda = listing_pda(seller, listing_id);
    let account = svm.get_account(&pda).unwrap();
    ListingAccount::try_deserialize(&mut account.data.as_slice()).unwrap()
}

pub fn send(svm: &mut LiteSVM, ixs: &[solana_message::compiled_instruction::CompiledInstruction], signers: &[&Keypair]) {
    // build and send transaction — adapt to LiteSVM 0.10 API (see existing descro tests for pattern)
    todo!("fill from descro tests/common/mod.rs send() helper")
}

pub fn try_send(svm: &mut LiteSVM, ixs: &[solana_message::compiled_instruction::CompiledInstruction], signers: &[&Keypair]) -> Result<(), Box<dyn std::error::Error>> {
    todo!("fill from descro tests/common/mod.rs try_send() helper")
}

// --- Instruction builders ---

pub fn ix_create_listing(
    seller: &Pubkey,
    listing_id: u64,
    canonical_currency: Currency,
    price: u64,
    canonical_oracle: Option<Pubkey>,
    alt_currencies: Vec<AltCurrencyConfig>,
    accepted_resolvers: Vec<Pubkey>,
    quantity: u32,
    metadata_uri: String,
) -> solana_message::compiled_instruction::CompiledInstruction {
    todo!("build anchor instruction for create_listing")
}

pub fn ix_update_listing(
    seller: &Pubkey,
    listing_id: u64,
    canonical_currency: Currency,
    price: u64,
    canonical_oracle: Option<Pubkey>,
    alt_currencies: Vec<AltCurrencyConfig>,
    accepted_resolvers: Vec<Pubkey>,
    quantity: u32,
    metadata_uri: String,
) -> solana_message::compiled_instruction::CompiledInstruction {
    todo!("build anchor instruction for update_listing")
}

pub fn ix_close_listing(
    seller: &Pubkey,
    listing_id: u64,
) -> solana_message::compiled_instruction::CompiledInstruction {
    todo!("build anchor instruction for close_listing")
}

Note: Copy the send() / try_send() pattern from programs/descro/tests/common/mod.rs and wire the instruction builders using Anchor's discriminator + borsh-encode args pattern from those existing tests.

  • Step 7: Wire lib.rs and instructions.rs (listing instructions only)

instructions.rs:

pub mod close_listing;
pub mod create_listing;
pub mod update_listing;

pub use close_listing::*;
pub use create_listing::*;
pub use update_listing::*;

lib.rs (listing portion — extend in Task 4):

pub mod error;
pub mod instructions;
pub mod oracle;
pub mod state;

use anchor_lang::prelude::*;
pub use error::*;
pub use instructions::*;
pub use state::*;

declare_id!("So1istingProgramID11111111111111111111111111"); // replace after anchor keys list

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

    pub fn create_listing(
        ctx: Context<CreateListing>,
        listing_id: u64,
        canonical_currency: state::Currency,
        price: u64,
        canonical_oracle: Option<Pubkey>,
        alt_currencies: Vec<state::AltCurrencyConfig>,
        accepted_resolvers: Vec<Pubkey>,
        quantity: u32,
        metadata_uri: String,
    ) -> Result<()> {
        create_listing::handler(
            ctx, listing_id, canonical_currency, price,
            canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
        )
    }

    pub fn update_listing(
        ctx: Context<UpdateListing>,
        canonical_currency: state::Currency,
        price: u64,
        canonical_oracle: Option<Pubkey>,
        alt_currencies: Vec<state::AltCurrencyConfig>,
        accepted_resolvers: Vec<Pubkey>,
        quantity: u32,
        metadata_uri: String,
    ) -> Result<()> {
        update_listing::handler(
            ctx, canonical_currency, price,
            canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
        )
    }

    pub fn close_listing(ctx: Context<CloseListing>) -> Result<()> {
        close_listing::handler(ctx)
    }
}
  • Step 8: Build and run listing tests
cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \
cargo build-sbf --manifest-path programs/descro/Cargo.toml && \
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_listings 2>&1 | tail -10

Expected: all listing tests pass.

  • Step 9: Commit
git add programs/solisting/
git commit -m "feat(solisting): implement listing create/update/close with canonical price model"

Task 4: create_order — Validate Currency, Oracle Conversion, CPI buyer_create_escrow

The buyer's single transaction: validates payment currency against listing, optionally reads oracle, reserves quantity, CPIs descro.buyer_create_escrow.

File: programs/solisting/src/instructions/create_order.rs

  • Step 1: Write failing tests in tests/test_orders.rs
mod common;
use common::*;

#[test]
fn buyer_can_create_order_canonical_sol() {
    // Listing: canonical=Sol(0.1 SOL), no alt_currencies
    // Buyer creates order paying in Sol
    // Assert: OrderAccount exists with payment_currency=Sol, amount=0.1 SOL in lamports
    // Assert: descro EscrowAccount exists, state == AwaitingSellerConfirm
    // Assert: descro vault has listing.price lamports
    // Assert: listing.quantity_reserved == 1
    let (mut svm, seller, buyer) = setup();
    let listing_id = 1u64;
    let price = 100_000_000u64;
    let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string());
    send(&mut svm, &[ix], &[&seller]);

    let order_id = 1u64;
    let ix_order = ix_create_order(
        &buyer.pubkey(),
        &seller.pubkey(),
        listing_id,
        order_id,
        Currency::Sol,
        0,   // max_slippage_bps — unused for canonical
        None, // no oracle account needed
    );
    send(&mut svm, &[ix_order], &[&buyer]);

    let order = read_order(&svm, listing_pda(&seller.pubkey(), listing_id), buyer.pubkey(), order_id);
    assert_eq!(order.payment_currency, Currency::Sol);
    assert_eq!(order.amount, price);

    let listing = read_listing(&svm, &seller.pubkey(), listing_id);
    assert_eq!(listing.quantity_reserved, 1);
}

#[test]
fn create_order_fails_if_listing_inactive() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 2u64;
    let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, None, vec![], vec![], 1, "".to_string());
    send(&mut svm, &[ix], &[&seller]);
    let ix_close = ix_close_listing(&seller.pubkey(), listing_id);
    send(&mut svm, &[ix_close], &[&seller]);

    let result = try_send(&mut svm, &[ix_create_order(
        &buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None,
    )], &[&buyer]);
    assert!(result.is_err());
}

#[test]
fn create_order_fails_if_out_of_stock() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 3u64;
    // quantity = 1, reserve it with first order
    let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 1, "".to_string());
    send(&mut svm, &[ix], &[&seller]);
    send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);

    let buyer2 = Keypair::new();
    svm.airdrop(&buyer2.pubkey(), 10_000_000_000).unwrap();
    let result = try_send(&mut svm, &[ix_create_order(&buyer2.pubkey(), &seller.pubkey(), listing_id, 2, Currency::Sol, 0, None, None)], &[&buyer2]);
    assert!(result.is_err());
}

#[test]
fn create_order_fails_if_currency_not_accepted() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 4u64;
    // Sol-only listing — buyer tries to pay with fake SPL
    let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string());
    send(&mut svm, &[ix], &[&seller]);

    let fake_mint = Pubkey::new_unique();
    let result = try_send(&mut svm, &[ix_create_order(
        &buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Spl { mint: fake_mint, decimals: 6 }, 0, None, None,
    )], &[&buyer]);
    assert!(result.is_err());
}

#[test]
fn create_order_fails_if_resolver_not_accepted() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 5u64;
    let allowed_resolver = Pubkey::new_unique();
    let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![allowed_resolver], 5, "".to_string());
    send(&mut svm, &[ix], &[&seller]);

    // Pass a different resolver — must fail
    let result = try_send(&mut svm, &[ix_create_order_with_resolver(
        &buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None, Pubkey::new_unique(),
    )], &[&buyer]);
    assert!(result.is_err());
}
  • Step 2: Run tests — verify compile failure (create_order instruction not yet written)
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders 2>&1 | head -10

Expected: compile errors.

  • Step 3: Implement create_order.rs
use anchor_lang::prelude::*;
use crate::state::{Currency, ListingAccount, OrderAccount};
use crate::error::SolistingError;
use crate::oracle;

#[derive(Accounts)]
#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: Currency)]
pub struct CreateOrder<'info> {
    #[account(mut)]
    pub buyer: Signer<'info>,

    /// CHECK: Seller — verified against listing.seller
    pub seller: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
        bump = listing_account.bump,
        constraint = listing_account.is_active @ SolistingError::ListingNotActive,
        constraint = listing_account.quantity > listing_account.quantity_reserved @ SolistingError::OutOfStock,
        constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
    )]
    pub listing_account: Account<'info, ListingAccount>,

    #[account(
        init,
        payer = buyer,
        space = 8 + OrderAccount::INIT_SPACE,
        seeds = [b"order", listing_account.key().as_ref(), buyer.key().as_ref(), &order_id.to_le_bytes()],
        bump
    )]
    pub order_account: Account<'info, OrderAccount>,

    /// CHECK: Descro EscrowAccount PDA — created by the CPI
    #[account(
        mut,
        seeds = [b"escrow", seller.key().as_ref(), &escrow_id.to_le_bytes()],
        bump,
        seeds::program = descro::id(),
    )]
    pub escrow_account: UncheckedAccount<'info>,

    /// CHECK: Descro vault PDA — funded by the CPI
    #[account(
        mut,
        seeds = [b"vault", escrow_account.key().as_ref()],
        bump,
        seeds::program = descro::id(),
    )]
    pub descro_vault: UncheckedAccount<'info>,

    /// CHECK: Pyth USD feed for the canonical currency (listing.canonical_oracle).
    /// Pass SystemProgram as placeholder when canonical_oracle is None (stablecoin).
    pub canonical_oracle: UncheckedAccount<'info>,

    /// CHECK: Pyth USD feed for the chosen alt currency (alt_cfg.usd_oracle).
    /// Pass SystemProgram as placeholder when usd_oracle is None (stablecoin).
    /// Also pass SystemProgram when paying in the canonical currency (oracle unused).
    pub target_oracle: UncheckedAccount<'info>,

    /// CHECK: Descro program — verified in handler
    pub descro_program: UncheckedAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(
    ctx: Context<CreateOrder>,
    order_id: u64,
    escrow_id: u64,
    resolver: Pubkey,
    payment_currency: Currency,
    expected_amount: u64, // amount buyer expects to pay (for slippage check)
    max_slippage_bps: u16,
) -> Result<()> {
    require!(
        ctx.accounts.descro_program.key() == descro::id(),
        SolistingError::InvalidDescroProgram
    );

    let listing = &ctx.accounts.listing_account;

    // Validate resolver
    if !listing.accepted_resolvers.is_empty() {
        require!(
            listing.accepted_resolvers.contains(&resolver),
            SolistingError::ResolverNotAccepted
        );
    }

    // Determine amount in payment_currency
    let amount = if payment_currency == listing.canonical_currency {
        // Paying in canonical currency — no oracle needed
        listing.price
    } else {
        // Must be a configured alt_currency
        let alt_cfg = listing.alt_currencies
            .iter()
            .find(|c| c.currency == payment_currency)
            .ok_or(error!(SolistingError::CurrencyNotAccepted))?;

        // Validate passed oracle accounts against listing state
        if let Some(expected_canonical) = listing.canonical_oracle {
            require!(
                ctx.accounts.canonical_oracle.key() == expected_canonical,
                SolistingError::OracleMismatch
            );
        }
        if let Some(expected_target) = alt_cfg.usd_oracle {
            require!(
                ctx.accounts.target_oracle.key() == expected_target,
                SolistingError::OracleMismatch
            );
        }

        let c_oracle = listing.canonical_oracle.map(|_| ctx.accounts.canonical_oracle.as_ref());
        let t_oracle = alt_cfg.usd_oracle.map(|_| ctx.accounts.target_oracle.as_ref());

        oracle::convert_with_slippage(
            c_oracle,
            &listing.canonical_currency,
            listing.price,
            t_oracle,
            &payment_currency,
            expected_amount,
            max_slippage_bps,
        )?
    };

    // Only Sol payment supported now (descro_spl does not exist yet)
    require!(
        payment_currency == Currency::Sol,
        SolistingError::SplNotImplemented
    );

    // CPI: descro.buyer_create_escrow — creates EscrowAccount + funds vault atomically
    descro::cpi::buyer_create_escrow(
        CpiContext::new(
            ctx.accounts.descro_program.to_account_info(),
            descro::cpi::accounts::BuyerCreateEscrow {
                buyer: ctx.accounts.buyer.to_account_info(),
                seller: ctx.accounts.seller.to_account_info(),
                escrow_account: ctx.accounts.escrow_account.to_account_info(),
                vault: ctx.accounts.descro_vault.to_account_info(),
                system_program: ctx.accounts.system_program.to_account_info(),
            },
        ),
        amount,
        Some(resolver),
        escrow_id,
    )?;

    ctx.accounts.listing_account.quantity_reserved += 1;

    let order = &mut ctx.accounts.order_account;
    order.listing = ctx.accounts.listing_account.key();
    order.buyer = ctx.accounts.buyer.key();
    order.seller = ctx.accounts.seller.key();
    order.resolver = resolver;
    order.payment_currency = payment_currency;
    order.amount = amount;
    order.escrow_account = ctx.accounts.escrow_account.key();
    order.escrow_id = escrow_id;
    order.order_id = order_id;
    order.created_at = Clock::get()?.unix_timestamp;
    order.bump = ctx.bumps.order_account;
    Ok(())
}

escrow_id derivation note: Solisting passes escrow_id as an argument. The SDK derives it as u64::from_le_bytes(order_pda.to_bytes()[0..8]). This ties the escrow_id to the order PDA, ensuring uniqueness as long as order_id is unique per buyer+listing.

  • Step 4: Add order helpers to tests/common/mod.rs

Add order_pda(), read_order(), ix_create_order(), ix_create_order_with_resolver(). Mirror the pattern from listing helpers: PDA derivation, borsh-encode instruction data, build CompiledInstruction.

pub fn order_pda(listing: Pubkey, buyer: Pubkey, order_id: u64) -> Pubkey {
    Pubkey::find_program_address(
        &[b"order", listing.as_ref(), buyer.as_ref(), &order_id.to_le_bytes()],
        &solisting::id(),
    ).0
}

pub fn read_order(svm: &LiteSVM, listing: Pubkey, buyer: Pubkey, order_id: u64) -> solisting::state::OrderAccount {
    let pda = order_pda(listing, buyer, order_id);
    let account = svm.get_account(&pda).unwrap();
    solisting::state::OrderAccount::try_deserialize(&mut account.data.as_slice()).unwrap()
}

/// `canonical_oracle` / `target_oracle`: pass None to use SystemProgram as placeholder
/// (i.e. when paying in canonical currency or when the oracle is a stablecoin None).
pub fn ix_create_order(
    buyer: &Pubkey,
    seller: &Pubkey,
    listing_id: u64,
    order_id: u64,
    payment_currency: Currency,
    max_slippage_bps: u16,
    canonical_oracle: Option<Pubkey>,
    target_oracle: Option<Pubkey>,
) -> solana_message::compiled_instruction::CompiledInstruction {
    let listing = listing_pda(seller, listing_id);
    let order = order_pda(listing, *buyer, order_id);
    let escrow_id = u64::from_le_bytes(order.to_bytes()[0..8].try_into().unwrap());
    let resolver = Pubkey::new_unique(); // default resolver for simple tests
    ix_create_order_with_resolver(buyer, seller, listing_id, order_id, payment_currency, max_slippage_bps, canonical_oracle, target_oracle, resolver)
}

pub fn ix_create_order_with_resolver(
    buyer: &Pubkey,
    seller: &Pubkey,
    listing_id: u64,
    order_id: u64,
    payment_currency: Currency,
    max_slippage_bps: u16,
    canonical_oracle: Option<Pubkey>,
    target_oracle: Option<Pubkey>,
    resolver: Pubkey,
) -> solana_message::compiled_instruction::CompiledInstruction {
    let listing = listing_pda(seller, listing_id);
    let order = order_pda(listing, *buyer, order_id);
    let escrow_id = u64::from_le_bytes(order.to_bytes()[0..8].try_into().unwrap());
    let (escrow_account, _) = Pubkey::find_program_address(
        &[b"escrow", seller.as_ref(), &escrow_id.to_le_bytes()],
        &descro::id(),
    );
    let (descro_vault, _) = Pubkey::find_program_address(
        &[b"vault", escrow_account.as_ref()],
        &descro::id(),
    );
    let canonical_oracle_key = canonical_oracle.unwrap_or(solana_sdk::system_program::id());
    let target_oracle_key = target_oracle.unwrap_or(solana_sdk::system_program::id());
    let expected_amount = 0u64; // tests that don't use oracle set this to 0
    todo!("build CompiledInstruction: discriminator + borsh encode (order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps), accounts: [..., canonical_oracle_key, target_oracle_key, ...]")
}
  • Step 5: Run tests
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders -- buyer_can_create_order_canonical_sol create_order_fails 2>&1 | tail -20

Expected: all create_order_* tests pass.

  • Step 6: Commit
git add programs/solisting/
git commit -m "feat(solisting): implement create_order with oracle-driven currency conversion"

Task 5: accept_order — CPI seller_confirm

The seller accepts: CPIs descro.seller_confirm to move the escrow to Active, updates quantity, closes the OrderAccount.

File: programs/solisting/src/instructions/accept_order.rs

  • Step 1: Write failing test
#[test]
fn seller_accept_creates_active_descro_escrow() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 10u64;
    let price = 100_000_000u64;
    send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())], &[&seller]);
    let order_id = 1u64;
    send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, order_id, Currency::Sol, 0, None, None)], &[&buyer]);

    let listing_key = listing_pda(&seller.pubkey(), listing_id);
    let order = read_order(&svm, listing_key, buyer.pubkey(), order_id);

    send(&mut svm, &[ix_accept_order(&seller.pubkey(), listing_id, buyer.pubkey(), order_id, order.escrow_id, order.resolver)], &[&seller]);

    // OrderAccount must be closed
    let order_key = order_pda(listing_key, buyer.pubkey(), order_id);
    assert!(svm.get_account(&order_key).is_none());

    // quantity reduced, reservation cleared
    let listing = read_listing(&svm, &seller.pubkey(), listing_id);
    assert_eq!(listing.quantity_reserved, 0);
    assert_eq!(listing.quantity, 4); // 5 - 1
}
  • Step 2: Implement accept_order.rs
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, OrderAccount};
use crate::error::SolistingError;

#[derive(Accounts)]
pub struct AcceptOrder<'info> {
    #[account(mut)]
    pub seller: Signer<'info>,

    /// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated (descro enforces this)
    pub resolver: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
        bump = listing_account.bump,
        constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
    )]
    pub listing_account: Account<'info, ListingAccount>,

    #[account(
        mut,
        seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
        bump = order_account.bump,
        constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized,
        close = seller,
    )]
    pub order_account: Account<'info, OrderAccount>,

    /// CHECK: Descro EscrowAccount — state checked by descro.seller_confirm
    #[account(
        mut,
        seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()],
        bump,
        seeds::program = descro::id(),
        constraint = escrow_account.key() == order_account.escrow_account @ SolistingError::EscrowStateUnexpected,
    )]
    pub escrow_account: UncheckedAccount<'info>,

    /// CHECK: Optional resolver registry entry — passed through to descro.seller_confirm
    pub resolver_entry: UncheckedAccount<'info>,

    /// CHECK: Descro program
    pub descro_program: UncheckedAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<AcceptOrder>) -> Result<()> {
    require!(
        ctx.accounts.descro_program.key() == descro::id(),
        SolistingError::InvalidDescroProgram
    );

    descro::cpi::seller_confirm(
        CpiContext::new(
            ctx.accounts.descro_program.to_account_info(),
            descro::cpi::accounts::SellerConfirm {
                seller: ctx.accounts.seller.to_account_info(),
                resolver: ctx.accounts.resolver.to_account_info(),
                escrow_account: ctx.accounts.escrow_account.to_account_info(),
                resolver_entry: ctx.accounts.resolver_entry.to_account_info(),
            },
        ),
    )?;

    let listing = &mut ctx.accounts.listing_account;
    listing.quantity_reserved = listing.quantity_reserved.saturating_sub(1);
    listing.quantity = listing.quantity.saturating_sub(1);

    Ok(())
}
  • Step 3: Run test
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders -- seller_accept 2>&1 | tail -10

Expected: PASS.

  • Step 4: Commit
git add programs/solisting/src/instructions/accept_order.rs programs/solisting/tests/
git commit -m "feat(solisting): implement accept_order"

Task 6: reject_order and cancel_order — Defensive CPI to descro.cancel

Both instructions read descro escrow state before cancelling. If the escrow is already resolved (buyer cancelled directly on descro), the CPI is skipped and the OrderAccount is still closed.

Files: reject_order.rs, cancel_order.rs

  • Step 1: Write failing tests
#[test]
fn seller_can_reject_order() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 20u64;
    let price = 100_000_000u64;
    send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())], &[&seller]);
    send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);

    let buyer_before = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
    let listing_key = listing_pda(&seller.pubkey(), listing_id);
    let order = read_order(&svm, listing_key, buyer.pubkey(), 1);

    send(&mut svm, &[ix_reject_order(&seller.pubkey(), listing_id, buyer.pubkey(), 1, order.escrow_id)], &[&seller]);

    let buyer_after = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
    assert!(buyer_after >= buyer_before + price - 10_000); // roughly refunded
    assert_eq!(read_listing(&svm, &seller.pubkey(), listing_id).quantity_reserved, 0);
    assert!(svm.get_account(&order_pda(listing_key, buyer.pubkey(), 1)).is_none());
}

#[test]
fn buyer_can_cancel_order() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 21u64;
    send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
    send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);

    let listing_key = listing_pda(&seller.pubkey(), listing_id);
    let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
    send(&mut svm, &[ix_cancel_order(&buyer.pubkey(), listing_id, &seller.pubkey(), 1, order.escrow_id)], &[&buyer]);

    assert_eq!(read_listing(&svm, &seller.pubkey(), listing_id).quantity_reserved, 0);
}

#[test]
fn reject_handles_already_cancelled_escrow() {
    // Buyer cancels directly on descro, then seller calls reject_order — must not panic
    let (mut svm, seller, buyer) = setup();
    let listing_id = 22u64;
    send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
    send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);

    let listing_key = listing_pda(&seller.pubkey(), listing_id);
    let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
    // Buyer cancels directly on descro (bypassing solisting)
    send(&mut svm, &[ix_descro_cancel(&buyer.pubkey(), &seller.pubkey(), order.escrow_id)], &[&buyer]);
    // Seller calls solisting reject — should succeed without CPI error
    send(&mut svm, &[ix_reject_order(&seller.pubkey(), listing_id, &buyer.pubkey(), 1, order.escrow_id)], &[&seller]);
}
  • Step 2: Implement reject_order.rs
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, OrderAccount};
use crate::error::SolistingError;

#[derive(Accounts)]
pub struct RejectOrder<'info> {
    #[account(mut)]
    pub seller: Signer<'info>,

    /// CHECK: Buyer receives vault refund from descro.cancel
    #[account(mut)]
    pub buyer: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
        bump = listing_account.bump,
        constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
    )]
    pub listing_account: Account<'info, ListingAccount>,

    #[account(
        mut,
        seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
        bump = order_account.bump,
        constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized,
        constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized,
        close = seller,
    )]
    pub order_account: Account<'info, OrderAccount>,

    /// CHECK: Descro EscrowAccount — read to check current state before CPI
    #[account(
        mut,
        seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()],
        bump,
        seeds::program = descro::id(),
    )]
    pub escrow_account: UncheckedAccount<'info>,

    /// CHECK: Descro vault — drained to buyer by descro.cancel
    #[account(
        mut,
        seeds = [b"vault", escrow_account.key().as_ref()],
        bump,
        seeds::program = descro::id(),
    )]
    pub vault: UncheckedAccount<'info>,

    /// CHECK: Descro program
    pub descro_program: UncheckedAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<RejectOrder>) -> Result<()> {
    require!(
        ctx.accounts.descro_program.key() == descro::id(),
        SolistingError::InvalidDescroProgram
    );

    // Defensive: skip CPI if escrow already resolved (buyer cancelled directly on descro)
    if !ctx.accounts.escrow_account.data_is_empty() {
        let data = ctx.accounts.escrow_account.try_borrow_data()?;
        if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
            if escrow.state == descro::EscrowState::AwaitingSellerConfirm {
                drop(data);
                descro::cpi::cancel(
                    CpiContext::new(
                        ctx.accounts.descro_program.to_account_info(),
                        descro::cpi::accounts::Cancel {
                            canceller: ctx.accounts.seller.to_account_info(),
                            buyer: ctx.accounts.buyer.to_account_info(),
                            escrow_account: ctx.accounts.escrow_account.to_account_info(),
                            vault: ctx.accounts.vault.to_account_info(),
                            system_program: ctx.accounts.system_program.to_account_info(),
                        },
                    ),
                )?;
            }
        }
    }

    ctx.accounts.listing_account.quantity_reserved =
        ctx.accounts.listing_account.quantity_reserved.saturating_sub(1);
    Ok(())
}
  • Step 3: Implement cancel_order.rs

Identical to reject_order.rs with three differences: buyer: Signer instead of seller: Signer, constraints check buyer == order.buyer, and CPI canceller = buyer, close = buyer.

use anchor_lang::prelude::*;
use crate::state::{ListingAccount, OrderAccount};
use crate::error::SolistingError;

#[derive(Accounts)]
pub struct CancelOrder<'info> {
    #[account(mut)]
    pub buyer: Signer<'info>,

    #[account(
        mut,
        seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
        bump = listing_account.bump,
    )]
    pub listing_account: Account<'info, ListingAccount>,

    #[account(
        mut,
        seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
        bump = order_account.bump,
        constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized,
        close = buyer,
    )]
    pub order_account: Account<'info, OrderAccount>,

    /// CHECK: Descro EscrowAccount — read defensively
    #[account(
        mut,
        seeds = [b"escrow", order_account.seller.as_ref(), &order_account.escrow_id.to_le_bytes()],
        bump,
        seeds::program = descro::id(),
    )]
    pub escrow_account: UncheckedAccount<'info>,

    /// CHECK: Descro vault
    #[account(
        mut,
        seeds = [b"vault", escrow_account.key().as_ref()],
        bump,
        seeds::program = descro::id(),
    )]
    pub vault: UncheckedAccount<'info>,

    /// CHECK: Descro program
    pub descro_program: UncheckedAccount<'info>,

    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<CancelOrder>) -> Result<()> {
    require!(
        ctx.accounts.descro_program.key() == descro::id(),
        SolistingError::InvalidDescroProgram
    );

    if !ctx.accounts.escrow_account.data_is_empty() {
        let data = ctx.accounts.escrow_account.try_borrow_data()?;
        if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
            if escrow.state == descro::EscrowState::AwaitingSellerConfirm {
                drop(data);
                descro::cpi::cancel(
                    CpiContext::new(
                        ctx.accounts.descro_program.to_account_info(),
                        descro::cpi::accounts::Cancel {
                            canceller: ctx.accounts.buyer.to_account_info(),
                            buyer: ctx.accounts.buyer.to_account_info(),
                            escrow_account: ctx.accounts.escrow_account.to_account_info(),
                            vault: ctx.accounts.vault.to_account_info(),
                            system_program: ctx.accounts.system_program.to_account_info(),
                        },
                    ),
                )?;
            }
        }
    }

    ctx.accounts.listing_account.quantity_reserved =
        ctx.accounts.listing_account.quantity_reserved.saturating_sub(1);
    Ok(())
}
  • Step 4: Run all order tests
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders 2>&1 | tail -15

Expected: all pass.

  • Step 5: Commit
git add programs/solisting/src/instructions/reject_order.rs programs/solisting/src/instructions/cancel_order.rs programs/solisting/tests/
git commit -m "feat(solisting): implement reject_order and cancel_order with defensive escrow check"

Task 7: close_stale_order — Cleanup Orphaned Orders

Anyone can call this to close an OrderAccount whose descro escrow is in a terminal state. Rent goes to caller as incentive.

File: programs/solisting/src/instructions/close_stale_order.rs

  • Step 1: Write failing test
#[test]
fn anyone_can_close_stale_order_after_terminal_escrow() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 30u64;
    send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
    send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);

    let listing_key = listing_pda(&seller.pubkey(), listing_id);
    let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
    // Buyer cancels on descro directly → escrow is Cancelled
    send(&mut svm, &[ix_descro_cancel(&buyer.pubkey(), &seller.pubkey(), order.escrow_id)], &[&buyer]);

    // A third party can now close the stale OrderAccount
    let janitor = Keypair::new();
    svm.airdrop(&janitor.pubkey(), 1_000_000).unwrap();
    let janitor_before = svm.get_account(&janitor.pubkey()).unwrap().lamports;
    send(&mut svm, &[ix_close_stale_order(&janitor.pubkey(), listing_key, buyer.pubkey(), 1, order.escrow_account)], &[&janitor]);

    assert!(svm.get_account(&order_pda(listing_key, buyer.pubkey(), 1)).is_none());
    let janitor_after = svm.get_account(&janitor.pubkey()).unwrap().lamports;
    assert!(janitor_after > janitor_before); // received rent
}

#[test]
fn close_stale_order_fails_if_escrow_still_active() {
    let (mut svm, seller, buyer) = setup();
    let listing_id = 31u64;
    send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
    send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);

    let listing_key = listing_pda(&seller.pubkey(), listing_id);
    let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
    // Don't cancel — escrow is still AwaitingSellerConfirm

    let janitor = Keypair::new();
    svm.airdrop(&janitor.pubkey(), 1_000_000).unwrap();
    let result = try_send(&mut svm, &[ix_close_stale_order(&janitor.pubkey(), listing_key, buyer.pubkey(), 1, order.escrow_account)], &[&janitor]);
    assert!(result.is_err());
}
  • Step 2: Implement close_stale_order.rs
use anchor_lang::prelude::*;
use crate::state::OrderAccount;
use crate::error::SolistingError;

#[derive(Accounts)]
pub struct CloseStaleOrder<'info> {
    #[account(mut)]
    pub caller: Signer<'info>,

    #[account(
        mut,
        seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
        bump = order_account.bump,
        close = caller,
    )]
    pub order_account: Account<'info, OrderAccount>,

    /// CHECK: Descro EscrowAccount — must be in terminal state or closed
    pub escrow_account: UncheckedAccount<'info>,
}

pub fn handler(ctx: Context<CloseStaleOrder>) -> Result<()> {
    require!(
        ctx.accounts.escrow_account.key() == ctx.accounts.order_account.escrow_account,
        SolistingError::EscrowStateUnexpected
    );

    // Closed (zero data) escrow = already resolved = ok to clean up
    if !ctx.accounts.escrow_account.data_is_empty() {
        let data = ctx.accounts.escrow_account.try_borrow_data()?;
        if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
            require!(
                escrow.state == descro::EscrowState::Cancelled
                    || escrow.state == descro::EscrowState::Complete,
                SolistingError::EscrowStateUnexpected
            );
        }
    }

    Ok(())
}
  • Step 3: Run tests
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders -- close_stale 2>&1 | tail -10

Expected: PASS.

  • Step 4: Commit
git add programs/solisting/src/instructions/close_stale_order.rs programs/solisting/tests/
git commit -m "feat(solisting): implement close_stale_order for orphaned order cleanup"

Task 8: Wire Up lib.rs and instructions.rs, Full Build + Test

  • Step 1: Complete instructions.rs
#![allow(ambiguous_glob_reexports)]

pub mod accept_order;
pub mod cancel_order;
pub mod close_listing;
pub mod close_stale_order;
pub mod create_listing;
pub mod create_order;
pub mod reject_order;
pub mod update_listing;

pub use accept_order::*;
pub use cancel_order::*;
pub use close_listing::*;
pub use close_stale_order::*;
pub use create_listing::*;
pub use create_order::*;
pub use reject_order::*;
pub use update_listing::*;
  • Step 2: Complete lib.rs
pub mod error;
pub mod instructions;
pub mod oracle;
pub mod state;

use anchor_lang::prelude::*;
pub use error::*;
pub use instructions::*;
pub use state::*;

declare_id!("So1istingProgramID11111111111111111111111111"); // replace with: solana-keygen grind or anchor keys list

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

    pub fn create_listing(ctx: Context<CreateListing>, listing_id: u64, canonical_currency: state::Currency, price: u64, canonical_oracle: Option<Pubkey>, alt_currencies: Vec<state::AltCurrencyConfig>, accepted_resolvers: Vec<Pubkey>, quantity: u32, metadata_uri: String) -> Result<()> {
        create_listing::handler(ctx, listing_id, canonical_currency, price, canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri)
    }

    pub fn update_listing(ctx: Context<UpdateListing>, canonical_currency: state::Currency, price: u64, canonical_oracle: Option<Pubkey>, alt_currencies: Vec<state::AltCurrencyConfig>, accepted_resolvers: Vec<Pubkey>, quantity: u32, metadata_uri: String) -> Result<()> {
        update_listing::handler(ctx, canonical_currency, price, canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri)
    }

    pub fn close_listing(ctx: Context<CloseListing>) -> Result<()> {
        close_listing::handler(ctx)
    }

    pub fn create_order(ctx: Context<CreateOrder>, order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: state::Currency, expected_amount: u64, max_slippage_bps: u16) -> Result<()> {
        // canonical_oracle and target_oracle come from the account struct, not args
        create_order::handler(ctx, order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps)
    }

    pub fn accept_order(ctx: Context<AcceptOrder>) -> Result<()> {
        accept_order::handler(ctx)
    }

    pub fn reject_order(ctx: Context<RejectOrder>) -> Result<()> {
        reject_order::handler(ctx)
    }

    pub fn cancel_order(ctx: Context<CancelOrder>) -> Result<()> {
        cancel_order::handler(ctx)
    }

    pub fn close_stale_order(ctx: Context<CloseStaleOrder>) -> Result<()> {
        close_stale_order::handler(ctx)
    }
}
  • Step 3: Build all three programs
cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \
cargo build-sbf --manifest-path programs/descro/Cargo.toml && \
cargo build-sbf --manifest-path programs/solisting/Cargo.toml

Expected: all three compile to .so without errors.

  • Step 4: Run full test suite
cargo test --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -5
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5
cargo test --manifest-path programs/solisting/Cargo.toml 2>&1 | tail -5

Expected: all green.

  • Step 5: Final commit
git add programs/solisting/ Cargo.toml Cargo.lock
git commit -m "feat(solisting): complete listing + order flow with canonical price model and oracle conversion"

Done

Full buyer-to-escrow flow across three programs:

  • solisting = coordination and discovery; single canonical price per listing; oracle-based alt-currency conversion at order time; never holds vault funds
  • descro = sole custodian of SOL vault; enforces AcceptancePolicy
  • descro_ext_resolvers = resolver registry; read by descro at seller_confirm time

Remaining items for future phases:

  • Implement descro_spl program → remove SplNotImplemented guard in create_order
  • Oracle LiteSVM mock: build a mock Pyth account data layout for test_orders.rs oracle path tests
  • Event emission (emit!()) for off-chain indexers
  • Guard against closing listing while pending orders exist (quantity_reserved > 0)
  • close_stale_order should also restore quantity_reserved on the listing (requires listing account to be passed in)