# 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`) 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` (the Pyth TOKEN/USD feed for that token; `None` = treat as a $1 USD stablecoin). The canonical currency has a parallel `canonical_oracle: Option`. `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_resolvers` → `descro` → `solisting`. --- ## 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`** ```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** ```bash 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** ```bash 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`** ```rust 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, } ``` - [ ] **Step 2: Write `error.rs`** ```rust 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 = −4` → `target = 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 = −11` → `target = ca × cp_raw / 10^11` ✓ (matches old formula) ```rust 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 { 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)** ```bash 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** ```bash 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`** ```rust 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)** ```bash 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`** ```rust 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, listing_id: u64, canonical_currency: Currency, price: u64, canonical_oracle: Option, alt_currencies: Vec, accepted_resolvers: Vec, 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`** ```rust 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, canonical_currency: Currency, price: u64, canonical_oracle: Option, alt_currencies: Vec, accepted_resolvers: Vec, 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`** ```rust 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) -> Result<()> { Ok(()) } ``` - [ ] **Step 6: Write `tests/common/mod.rs` skeleton (listing helpers only for now)** ```rust 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> { 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, alt_currencies: Vec, accepted_resolvers: Vec, 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, alt_currencies: Vec, accepted_resolvers: Vec, 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`: ```rust 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): ```rust 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, listing_id: u64, canonical_currency: state::Currency, price: u64, canonical_oracle: Option, alt_currencies: Vec, accepted_resolvers: Vec, 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, canonical_currency: state::Currency, price: u64, canonical_oracle: Option, alt_currencies: Vec, accepted_resolvers: Vec, 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) -> Result<()> { close_listing::handler(ctx) } } ``` - [ ] **Step 8: Build and run listing tests** ```bash 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** ```bash 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`** ```rust 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)** ```bash cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders 2>&1 | head -10 ``` Expected: compile errors. - [ ] **Step 3: Implement `create_order.rs`** ```rust 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, 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`. ```rust 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, target_oracle: Option, ) -> 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, target_oracle: Option, 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** ```bash 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** ```bash 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** ```rust #[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`** ```rust 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) -> 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** ```bash 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** ```bash 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** ```rust #[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`** ```rust 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) -> 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`. ```rust 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) -> 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** ```bash 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** ```bash 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** ```rust #[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`** ```rust 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) -> 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** ```bash 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** ```bash 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`** ```rust #![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`** ```rust 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, listing_id: u64, canonical_currency: state::Currency, price: u64, canonical_oracle: Option, alt_currencies: Vec, accepted_resolvers: Vec, 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, canonical_currency: state::Currency, price: u64, canonical_oracle: Option, alt_currencies: Vec, accepted_resolvers: Vec, 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) -> Result<()> { close_listing::handler(ctx) } pub fn create_order(ctx: Context, 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) -> Result<()> { accept_order::handler(ctx) } pub fn reject_order(ctx: Context) -> Result<()> { reject_order::handler(ctx) } pub fn cancel_order(ctx: Context) -> Result<()> { cancel_order::handler(ctx) } pub fn close_stale_order(ctx: Context) -> Result<()> { close_stale_order::handler(ctx) } } ``` - [ ] **Step 3: Build all three programs** ```bash 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** ```bash 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** ```bash 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)