diff --git a/docs/superpowers/plans/2026-05-19-solisting.md b/docs/superpowers/plans/2026-05-19-solisting.md index 1921eb7..8ede77d 100644 --- a/docs/superpowers/plans/2026-05-19-solisting.md +++ b/docs/superpowers/plans/2026-05-19-solisting.md @@ -4,25 +4,28 @@ > ⚠️ **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 from the original solisting plan (adding `AcceptancePolicy` enum to `descro_ext_resolvers`) — already documented in the old plan's prerequisite section +> 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. It orchestrates the full flow from buyer intent → bilateral consent → active `descro` escrow, without ever touching the descro vault directly. +**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:** -- `solisting` holds two account types: `ListingAccount` (seller's offer with payment options) and `OrderAccount` (pending bilateral consent record). -- Payment method is negotiated per-order: the seller declares supported options in the listing (`DescroPaymentOption` with accepted resolvers), the buyer picks one when creating an order. -- For Descro orders: `create_order` CPIs `descro.buyer_create_escrow` atomically — funds go directly into descro's vault. `accept_order` CPIs `descro.seller_confirm`. Solisting never holds or transfers escrow SOL. -- The `OrderAccount` has no state enum. Its existence means the order is pending. Closure means it is resolved. -- State drift (buyer cancelling directly on descro) is handled gracefully: all terminal solisting instructions read the descro escrow state first and skip the CPI if descro already resolved it. +- `ListingAccount` holds a single canonical price (`canonical_currency` + `canonical_amount`) as the sole source of truth. Alternative currencies (`alt_currencies`) are always converted from canonical at `create_order` time via an on-chain Pyth oracle — no independent price per currency, no arbitrage possible. The oracle account is only required when `alt_currencies` is non-empty. +- `OrderAccount` records the bilateral consent: which currency the buyer chose, the oracle-computed amount, and a pointer to the descro escrow. +- Four seller archetypes: (1) SOL-only, (2) SOL-primary + USDT via oracle, (3) USDT-only, (4) USDT-primary + SOL via oracle. +- `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 -- Listing `quantity_reserved` tracks pending orders; decremented at `create_order`, restored at `reject_order`/`cancel_order` +- `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 in a terminal 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. Depends on `descro` (CPI features) and `descro_ext_resolvers` (for `AcceptancePolicy` type). Build order: `descro_ext_resolvers` → `descro` → `solisting`. +**Tech Stack:** Rust, Anchor 1.0.x, LiteSVM 0.10.0, `pyth-sdk-solana` for oracle reads. Build order: `descro_ext_resolvers` → `descro` → `solisting`. --- @@ -30,20 +33,21 @@ | Action | Path | Responsibility | |---|---|---| -| Create | `programs/solisting/Cargo.toml` | Crate definition + dependencies | +| Create | `programs/solisting/Cargo.toml` | Crate definition + dependencies (incl. pyth-sdk-solana) | | Create | `programs/solisting/src/lib.rs` | Program entrypoints + declare_id! | -| Create | `programs/solisting/src/state.rs` | ListingAccount + OrderAccount + PaymentOption | -| Create | `programs/solisting/src/error.rs` | SolistingError enum | +| Create | `programs/solisting/src/state.rs` | `ListingAccount`, `OrderAccount`, `Currency` enum | +| Create | `programs/solisting/src/error.rs` | `SolistingError` enum | +| Create | `programs/solisting/src/oracle.rs` | Pyth price read + SOL↔stablecoin conversion + slippage check | | Create | `programs/solisting/src/instructions.rs` | Module re-exports | -| Create | `programs/solisting/src/instructions/create_listing.rs` | | -| Create | `programs/solisting/src/instructions/update_listing.rs` | | -| Create | `programs/solisting/src/instructions/close_listing.rs` | | -| Create | `programs/solisting/src/instructions/create_order.rs` | Core: validates payment option, CPIs buyer_create_escrow | -| Create | `programs/solisting/src/instructions/accept_order.rs` | CPIs seller_confirm | -| Create | `programs/solisting/src/instructions/reject_order.rs` | CPIs descro.cancel (defensive) | -| Create | `programs/solisting/src/instructions/cancel_order.rs` | CPIs descro.cancel (defensive) | -| Create | `programs/solisting/src/instructions/close_stale_order.rs` | Cleans up orphaned OrderAccounts | -| Create | `programs/solisting/tests/common/mod.rs` | LiteSVM setup + helpers | +| 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 | @@ -83,6 +87,9 @@ custom-panic = [] 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. +# Verify latest compatible version at https://crates.io/crates/pyth-sdk-solana +pyth-sdk-solana = "0.10" [dev-dependencies] litesvm = "0.10.0" @@ -102,6 +109,7 @@ 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 @@ -122,26 +130,45 @@ git commit -m "chore(solisting): add crate skeleton and workspace entry" --- -## Task 2: State and Errors +## Task 2: State, Errors, Oracle Module -**Files:** `state.rs`, `error.rs` +**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 defined by the mint's decimals field. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace, Debug, PartialEq)] +pub enum Currency { + Sol, + Spl { mint: Pubkey }, +} + #[account] #[derive(InitSpace)] pub struct ListingAccount { pub seller: Pubkey, - pub price: u64, - /// Total units available (including reserved). + /// 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 canonical_amount: u64, + /// Other currencies the seller accepts. Amounts are always oracle-computed at create_order + /// time — never stored independently. Empty = oracle never needed. + #[max_len(3)] + pub alt_currencies: Vec, + /// Pyth Price Feed account. Must be Some when alt_currencies is non-empty. + /// The seller chooses which oracle they trust. + pub price_oracle: Option, + /// 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 (AwaitingSellerAccept) orders. quantity - quantity_reserved = available. + /// Units held by pending orders. available = quantity - quantity_reserved. pub quantity_reserved: u32, - /// Descro payment option; None means descro is not accepted for this listing. - pub descro_option: Option, #[max_len(256)] pub metadata_uri: String, pub listing_id: u64, @@ -149,21 +176,16 @@ pub struct ListingAccount { pub bump: u8, } -#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)] -pub struct DescroPaymentOption { - /// Resolvers the seller accepts. Empty = any resolver is acceptable. - #[max_len(4)] - pub accepted_resolvers: Vec, -} - #[account] #[derive(InitSpace)] pub struct OrderAccount { pub listing: Pubkey, pub buyer: Pubkey, pub seller: Pubkey, - /// The resolver agreed upon for this order (validated against listing's accepted_resolvers). pub resolver: Pubkey, + /// The currency the buyer chose to pay in. + pub payment_currency: Currency, + /// Actual amount paid (may differ from canonical_amount when oracle-converted). pub amount: u64, /// The descro EscrowAccount PDA for this order. pub escrow_account: Pubkey, @@ -175,8 +197,6 @@ pub struct OrderAccount { } ``` -> **`escrow_id` derivation note:** At `create_order` time, the `order_account` PDA has been derived but not yet initialized. Solisting passes the `escrow_id` as an instruction argument. The frontend/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 2: Write `error.rs`** ```rust @@ -190,28 +210,262 @@ pub enum SolistingError { OutOfStock, #[msg("Signer is not authorized")] Unauthorized, - #[msg("Listing does not support Descro payments")] - DescroNotSupported, + #[msg("Currency not accepted by this listing")] + CurrencyNotAccepted, #[msg("Resolver is not accepted by this listing")] ResolverNotAccepted, - #[msg("Descro escrow is not in expected state — order may have already been resolved")] + #[msg("alt_currencies is non-empty but price_oracle is None on the listing")] + OracleRequired, + #[msg("Oracle account key does not match listing.price_oracle")] + 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 reads a Pyth SOL/USD price feed and converts between SOL (Lamports) and a +stablecoin (6-decimal SPL token, e.g. USDT/USDC). Stablecoins are treated as exactly $1.00 — +no second feed needed for the stable side. + +```rust +use anchor_lang::prelude::*; +use pyth_sdk_solana::load_price_feed_from_account_info; +use crate::error::SolistingError; +use crate::state::Currency; + +/// Maximum age of the oracle price in seconds before it is considered stale. +const ORACLE_MAX_AGE_SECS: u64 = 60; + +/// Reads the Pyth SOL/USD price feed and returns (price_i64, exponent_i32). +/// price_i64 * 10^exponent = USD per SOL. +/// Returns OraclePriceUnavailable if the price is stale or the feed account is invalid. +fn sol_usd_price(oracle_account: &AccountInfo) -> Result<(i64, i32)> { + let feed = load_price_feed_from_account_info(oracle_account) + .map_err(|_| error!(SolistingError::OraclePriceUnavailable))?; + let now = Clock::get()?.unix_timestamp; + let price = feed + .get_price_no_older_than(now, ORACLE_MAX_AGE_SECS) + .ok_or(error!(SolistingError::OraclePriceUnavailable))?; + Ok((price.price, price.expo)) +} + +/// Converts `canonical_amount` (in `canonical_currency`) to `target_currency` units. +/// +/// Supported conversions: +/// Sol → Spl (stablecoin 6 decimals): lamports → token_units +/// Spl (stablecoin 6 decimals) → Sol: token_units → lamports +/// +/// Formula Sol → Spl (assuming 1 stablecoin = $1, 6 decimals): +/// stablecoin_units = lamports * price * 10^expo / 10^9 * 10^6 +/// = lamports * price * 10^(expo + 6 - 9) +/// = lamports * price * 10^(expo - 3) +/// With expo=-8: stablecoin_units = lamports * price / 10^11 +/// +/// Uses u128 intermediate to avoid overflow (max ~1.8e19 in u64 is too small). +/// +/// Slippage check: if the oracle-computed amount differs from `expected_amount` by more than +/// `max_slippage_bps` basis points, returns SlippageExceeded. +pub fn convert_with_slippage( + oracle_account: &AccountInfo, + canonical_currency: &Currency, + canonical_amount: u64, + target_currency: &Currency, + expected_amount: u64, + max_slippage_bps: u16, +) -> Result { + let (price_raw, expo) = sol_usd_price(oracle_account)?; + require!(price_raw > 0, SolistingError::OraclePriceUnavailable); + + let computed = match (canonical_currency, target_currency) { + (Currency::Sol, Currency::Spl { .. }) => { + // lamports → stablecoin units (6 decimals, $1 peg) + // = lamports * price_raw / 10^(9 - 6 + (-expo)) + // = lamports * price_raw / 10^(3 - expo) [expo is negative, so 3 - expo > 3] + let shift: u32 = (3i32 - expo) as u32; // expo=-8 → shift=11 + let intermediate = (canonical_amount as u128) + .checked_mul(price_raw as u128) + .ok_or(error!(SolistingError::OraclePriceUnavailable))?; + let divisor = 10u128.pow(shift); + u64::try_from(intermediate / divisor) + .map_err(|_| error!(SolistingError::OraclePriceUnavailable))? + } + (Currency::Spl { .. }, Currency::Sol) => { + // stablecoin units (6 decimals, $1 peg) → lamports + // = token_units * 10^9 / (price_raw * 10^expo) + // = token_units * 10^9 / price_raw * 10^(-expo) + // = token_units * 10^(9 + (-expo)) / price_raw + // = token_units * 10^(9 - expo) / price_raw [expo negative, so 9 - expo > 9] + // Example expo=-8: = token_units * 10^17 / price_raw + // Then divide out the stablecoin decimals (6): + // = token_units * 10^(9 - expo - 6) / price_raw = token_units * 10^(3 - expo) / price_raw + let shift: u32 = (3i32 - expo) as u32; // expo=-8 → shift=11 + let numerator = (canonical_amount as u128) + .checked_mul(10u128.pow(shift)) + .ok_or(error!(SolistingError::OraclePriceUnavailable))?; + u64::try_from(numerator / price_raw as u128) + .map_err(|_| error!(SolistingError::OraclePriceUnavailable))? + } + _ => return Err(error!(SolistingError::OraclePriceUnavailable)), + }; + + // Slippage: |computed - expected| / expected <= max_slippage_bps / 10_000 + if max_slippage_bps > 0 { + let tolerance = (expected_amount as u128) + .checked_mul(max_slippage_bps as u128) + .unwrap_or(u128::MAX) + / 10_000; + let diff = if computed > expected_amount { + computed as u128 - expected_amount as u128 + } else { + expected_amount as u128 - computed 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 `create_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, + vec![], // no alt_currencies + None, // no oracle + 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.canonical_amount, price); + assert_eq!(listing.alt_currencies.len(), 0); + assert_eq!(listing.price_oracle, None); + assert_eq!(listing.quantity, 10); + assert_eq!(listing.quantity_reserved, 0); + assert!(listing.is_active); +} + +#[test] +fn create_listing_with_alt_currency_requires_oracle() { + // alt_currencies non-empty but price_oracle = None → should fail + let (mut svm, seller, _) = setup(); + let usdt_mint = Pubkey::new_unique(); + let ix = ix_create_listing( + &seller.pubkey(), + 42, + Currency::Sol, + 1_000_000_000, + vec![Currency::Spl { mint: usdt_mint }], + None, // oracle missing — must fail + vec![], + 5, + "ipfs://x".to_string(), + ); + let result = try_send(&mut svm, &[ix], &[&seller]); + assert!(result.is_err()); +} + +#[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, vec![], None, 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, + vec![], + None, + vec![], + 20, + "ipfs://new".to_string(), + ); + send(&mut svm, &[ix_update], &[&seller]); + + let listing = read_listing(&svm, &seller.pubkey(), listing_id); + assert_eq!(listing.canonical_amount, 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, vec![], None, 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::{DescroPaymentOption, ListingAccount}; +use crate::state::{Currency, ListingAccount}; +use crate::error::SolistingError; #[derive(Accounts)] #[instruction(listing_id: u64)] @@ -234,17 +488,28 @@ pub struct CreateListing<'info> { pub fn handler( ctx: Context, listing_id: u64, - price: u64, + canonical_currency: Currency, + canonical_amount: u64, + alt_currencies: Vec, + price_oracle: Option, + accepted_resolvers: Vec, quantity: u32, - descro_option: Option, metadata_uri: String, ) -> Result<()> { + require!( + alt_currencies.is_empty() || price_oracle.is_some(), + SolistingError::OracleRequired + ); + let listing = &mut ctx.accounts.listing_account; listing.seller = ctx.accounts.seller.key(); - listing.price = price; + listing.canonical_currency = canonical_currency; + listing.canonical_amount = canonical_amount; + listing.alt_currencies = alt_currencies; + listing.price_oracle = price_oracle; + listing.accepted_resolvers = accepted_resolvers; listing.quantity = quantity; listing.quantity_reserved = 0; - listing.descro_option = descro_option; listing.metadata_uri = metadata_uri; listing.listing_id = listing_id; listing.is_active = true; @@ -253,11 +518,11 @@ pub fn handler( } ``` -- [ ] **Step 2: Write `update_listing.rs`** +- [ ] **Step 4: Write `update_listing.rs`** ```rust use anchor_lang::prelude::*; -use crate::state::{DescroPaymentOption, ListingAccount}; +use crate::state::{Currency, ListingAccount}; use crate::error::SolistingError; #[derive(Accounts)] @@ -276,21 +541,32 @@ pub struct UpdateListing<'info> { pub fn handler( ctx: Context, - price: u64, + canonical_currency: Currency, + canonical_amount: u64, + alt_currencies: Vec, + price_oracle: Option, + accepted_resolvers: Vec, quantity: u32, - descro_option: Option, metadata_uri: String, ) -> Result<()> { + require!( + alt_currencies.is_empty() || price_oracle.is_some(), + SolistingError::OracleRequired + ); + let listing = &mut ctx.accounts.listing_account; - listing.price = price; + listing.canonical_currency = canonical_currency; + listing.canonical_amount = canonical_amount; + listing.alt_currencies = alt_currencies; + listing.price_oracle = price_oracle; + listing.accepted_resolvers = accepted_resolvers; listing.quantity = quantity; - listing.descro_option = descro_option; listing.metadata_uri = metadata_uri; Ok(()) } ``` -- [ ] **Step 3: Write `close_listing.rs`** +- [ ] **Step 5: Write `close_listing.rs`** ```rust use anchor_lang::prelude::*; @@ -317,60 +593,310 @@ pub fn handler(_ctx: Context) -> Result<()> { } ``` ---- - -## Task 4: `create_order` — Validate + CPI buyer_create_escrow - -This is the buyer's single transaction: it validates the order, reserves quantity on the listing, CPIs `descro.buyer_create_escrow` to atomically create and fund the escrow, and records the order. - -**File:** `programs/solisting/src/instructions/create_order.rs` - -- [ ] **Step 1: Write failing test** (in `tests/test_orders.rs`) +- [ ] **Step 6: Write `tests/common/mod.rs` skeleton (listing helpers only for now)** ```rust -#[test] -fn buyer_can_create_order_descro() { - // setup listing with DescroPaymentOption (any resolver) - // buyer calls create_order with resolver keypair - // Assert: OrderAccount exists with correct fields - // Assert: descro EscrowAccount exists, state == AwaitingSellerConfirm - // Assert: descro vault has listing.price lamports - // Assert: listing.quantity_reserved == 1 +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) } -#[test] -fn create_order_fails_if_listing_inactive() { } - -#[test] -fn create_order_fails_if_out_of_stock() { } - -#[test] -fn create_order_fails_if_resolver_not_accepted() { - // listing has specific accepted_resolvers = [resolver_a] - // buyer tries with resolver_b → must fail +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 } -#[test] -fn create_order_fails_if_descro_not_supported() { - // listing has descro_option = None - // buyer tries to create descro order → must fail +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, + canonical_amount: u64, + alt_currencies: Vec, + price_oracle: Option, + 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, + canonical_amount: u64, + alt_currencies: Vec, + price_oracle: Option, + 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") } ``` -- [ ] **Step 2: Implement `create_order.rs`** +> **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, + canonical_amount: u64, + alt_currencies: Vec, + price_oracle: Option, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, + ) -> Result<()> { + create_listing::handler( + ctx, listing_id, canonical_currency, canonical_amount, + alt_currencies, price_oracle, accepted_resolvers, quantity, metadata_uri, + ) + } + + pub fn update_listing( + ctx: Context, + canonical_currency: state::Currency, + canonical_amount: u64, + alt_currencies: Vec, + price_oracle: Option, + accepted_resolvers: Vec, + quantity: u32, + metadata_uri: String, + ) -> Result<()> { + update_listing::handler( + ctx, canonical_currency, canonical_amount, + alt_currencies, price_oracle, 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.canonical_amount 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, vec![], None, 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, vec![], None, 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, vec![], None, 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)], &[&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)], &[&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, vec![], None, 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 }, 0, 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, vec![], None, 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, 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::{ListingAccount, OrderAccount}; +use crate::state::{Currency, ListingAccount, OrderAccount}; use crate::error::SolistingError; +use crate::oracle; #[derive(Accounts)] -#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey)] +#[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 + /// CHECK: Seller — verified against listing.seller pub seller: UncheckedAccount<'info>, #[account( @@ -410,6 +936,10 @@ pub struct CreateOrder<'info> { )] pub descro_vault: UncheckedAccount<'info>, + /// CHECK: Pyth price feed account. Required when payment_currency != canonical_currency. + /// Pass SystemProgram ID as a no-op placeholder when oracle is not needed. + pub oracle: UncheckedAccount<'info>, + /// CHECK: Descro program — verified in handler pub descro_program: UncheckedAccount<'info>, @@ -421,6 +951,9 @@ pub fn handler( 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(), @@ -429,19 +962,45 @@ pub fn handler( let listing = &ctx.accounts.listing_account; - // Validate descro payment option is supported - let descro_opt = listing.descro_option.as_ref() - .ok_or(SolistingError::DescroNotSupported)?; - - // Validate resolver is accepted (empty list = any resolver ok) - if !descro_opt.accepted_resolvers.is_empty() { + // Validate resolver + if !listing.accepted_resolvers.is_empty() { require!( - descro_opt.accepted_resolvers.contains(&resolver), + listing.accepted_resolvers.contains(&resolver), SolistingError::ResolverNotAccepted ); } - let amount = listing.price; + // Determine amount in payment_currency + let amount = if payment_currency == listing.canonical_currency { + // No oracle needed — pay canonical amount directly + listing.canonical_amount + } else { + // Must be an accepted alt_currency + require!( + listing.alt_currencies.contains(&payment_currency), + SolistingError::CurrencyNotAccepted + ); + // Oracle must be configured on listing + let oracle_key = listing.price_oracle.ok_or(error!(SolistingError::OracleRequired))?; + require!( + ctx.accounts.oracle.key() == oracle_key, + SolistingError::OracleMismatch + ); + oracle::convert_with_slippage( + ctx.accounts.oracle.as_ref(), + &listing.canonical_currency, + listing.canonical_amount, + &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( @@ -460,15 +1019,14 @@ pub fn handler( escrow_id, )?; - // Reserve quantity ctx.accounts.listing_account.quantity_reserved += 1; - // Record order 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; @@ -479,11 +1037,91 @@ pub fn handler( } ``` +> **`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() +} + +// ix_create_order: derive escrow_id from order_pda bytes[0..8], pass SystemProgram as oracle placeholder +pub fn ix_create_order( + buyer: &Pubkey, + seller: &Pubkey, + listing_id: u64, + order_id: u64, + payment_currency: Currency, + max_slippage_bps: u16, + 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, 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, + 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 oracle_key = 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 list") +} +``` + +- [ ] **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 +## Task 5: `accept_order` — CPI `seller_confirm` -The seller accepts: CPIs `descro.seller_confirm` to move the escrow to `Active`, then closes the `OrderAccount`. +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` @@ -492,12 +1130,26 @@ The seller accepts: CPIs `descro.seller_confirm` to move the escrow to `Active`, ```rust #[test] fn seller_accept_creates_active_descro_escrow() { - // create_order → AwaitingSellerConfirm - // seller calls accept_order - // Assert: descro escrow state == Active - // Assert: OrderAccount is closed - // Assert: listing.quantity_reserved == 0 - // Assert: listing.quantity == original - 1 + 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, vec![], None, 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)], &[&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 } ``` @@ -514,7 +1166,7 @@ pub struct AcceptOrder<'info> { pub seller: Signer<'info>, /// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated (descro enforces this) - pub resolver: AccountInfo<'info>, + pub resolver: UncheckedAccount<'info>, #[account( mut, @@ -533,7 +1185,7 @@ pub struct AcceptOrder<'info> { )] pub order_account: Account<'info, OrderAccount>, - /// CHECK: Descro EscrowAccount — verified via seeds, state checked by descro.seller_confirm + /// 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()], @@ -543,7 +1195,7 @@ pub struct AcceptOrder<'info> { )] pub escrow_account: UncheckedAccount<'info>, - /// CHECK: Optional resolver registry entry — passed through to descro.seller_confirm for AcceptancePolicy check + /// CHECK: Optional resolver registry entry — passed through to descro.seller_confirm pub resolver_entry: UncheckedAccount<'info>, /// CHECK: Descro program @@ -558,7 +1210,6 @@ pub fn handler(ctx: Context) -> Result<()> { SolistingError::InvalidDescroProgram ); - // CPI: descro.seller_confirm — descro checks AcceptancePolicy internally descro::cpi::seller_confirm( CpiContext::new( ctx.accounts.descro_program.to_account_info(), @@ -571,7 +1222,6 @@ pub fn handler(ctx: Context) -> Result<()> { ), )?; - // Commit quantity: reserved → sold let listing = &mut ctx.accounts.listing_account; listing.quantity_reserved = listing.quantity_reserved.saturating_sub(1); listing.quantity = listing.quantity.saturating_sub(1); @@ -580,11 +1230,27 @@ pub fn handler(ctx: Context) -> Result<()> { } ``` +- [ ] **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 +## Task 6: `reject_order` and `cancel_order` — Defensive CPI to `descro.cancel` -Both instructions read the descro escrow state before attempting the cancel CPI. If descro already resolved the escrow (buyer cancelled directly), the CPI is skipped and the `OrderAccount` is still closed. This prevents stuck accounts and failed transactions. +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` @@ -593,32 +1259,52 @@ Both instructions read the descro escrow state before attempting the cancel CPI. ```rust #[test] fn seller_can_reject_order() { - // create_order → AwaitingSellerConfirm - // seller calls reject_order - // Assert: OrderAccount closed - // Assert: buyer received SOL back (descro vault drained) - // Assert: listing.quantity_reserved == 0 + 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, vec![], None, vec![], 5, "".to_string())], &[&seller]); + send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, 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_anytime() { - // create_order → AwaitingSellerConfirm - // buyer calls cancel_order immediately (no timeout) - // Assert: same as reject — funds returned, order closed +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, vec![], None, vec![], 5, "".to_string())], &[&seller]); + send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, 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 calls descro.cancel directly (bypassing solisting) - // seller calls solisting.reject_order - // Assert: no panic, OrderAccount is still closed cleanly -} + // 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, vec![], None, vec![], 5, "".to_string())], &[&seller]); + send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None)], &[&buyer]); -#[test] -fn cancel_handles_already_cancelled_escrow() { - // buyer calls descro.cancel directly - // buyer calls solisting.cancel_order - // Assert: OrderAccount closed, no CPI error + 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]); } ``` @@ -634,7 +1320,7 @@ pub struct RejectOrder<'info> { #[account(mut)] pub seller: Signer<'info>, - /// CHECK: Buyer receives the vault refund from descro.cancel + /// CHECK: Buyer receives vault refund from descro.cancel #[account(mut)] pub buyer: UncheckedAccount<'info>, @@ -656,7 +1342,7 @@ pub struct RejectOrder<'info> { )] pub order_account: Account<'info, OrderAccount>, - /// CHECK: Descro EscrowAccount — read to check current state + /// 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()], @@ -686,13 +1372,11 @@ pub fn handler(ctx: Context) -> Result<()> { SolistingError::InvalidDescroProgram ); - // Defensive: only CPI if escrow is still in AwaitingSellerConfirm - // If buyer already cancelled directly, skip CPI — just close the order + // 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()?; - let escrow = descro::EscrowAccount::try_deserialize(&mut data.as_ref()); - if let Ok(e) = escrow { - if e.state == descro::EscrowState::AwaitingSellerConfirm { + 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( @@ -710,22 +1394,15 @@ pub fn handler(ctx: Context) -> Result<()> { } } - // Restore reserved quantity 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` except: -- `buyer: Signer<'info>` instead of `seller: Signer<'info>` -- Constraint checks buyer == order.buyer -- CPI `canceller` = buyer -- `close = buyer` -- No timeout — buyer can cancel anytime pre-Active +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::*; @@ -785,9 +1462,8 @@ pub fn handler(ctx: Context) -> Result<()> { if !ctx.accounts.escrow_account.data_is_empty() { let data = ctx.accounts.escrow_account.try_borrow_data()?; - let escrow = descro::EscrowAccount::try_deserialize(&mut data.as_ref()); - if let Ok(e) = escrow { - if e.state == descro::EscrowState::AwaitingSellerConfirm { + 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( @@ -807,19 +1483,80 @@ pub fn handler(ctx: Context) -> Result<()> { 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 (`Cancelled` or `Complete`). Rent goes to the caller as incentive. +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, vec![], None, vec![], 5, "".to_string())], &[&seller]); + send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, 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, vec![], None, vec![], 5, "".to_string())], &[&seller]); + send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, 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; @@ -838,7 +1575,7 @@ pub struct CloseStaleOrder<'info> { )] pub order_account: Account<'info, OrderAccount>, - /// CHECK: Descro EscrowAccount — must be in terminal state + /// CHECK: Descro EscrowAccount — must be in terminal state or closed pub escrow_account: UncheckedAccount<'info>, } @@ -848,7 +1585,7 @@ pub fn handler(ctx: Context) -> Result<()> { SolistingError::EscrowStateUnexpected ); - // Only allow close if escrow is terminal or gone (already closed) + // 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()) { @@ -864,11 +1601,27 @@ pub fn handler(ctx: Context) -> Result<()> { } ``` +- [ ] **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`, Build, Test +## Task 8: Wire Up `lib.rs` and `instructions.rs`, Full Build + Test -- [ ] **Step 1: Write `instructions.rs`** +- [ ] **Step 1: Complete `instructions.rs`** ```rust #![allow(ambiguous_glob_reexports)] @@ -892,57 +1645,39 @@ pub use reject_order::*; pub use update_listing::*; ``` -- [ ] **Step 2: Write `lib.rs`** +- [ ] **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 after anchor keys list +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, - price: u64, - quantity: u32, - descro_option: Option, - metadata_uri: String, - ) -> Result<()> { - create_listing::handler(ctx, listing_id, price, quantity, descro_option, metadata_uri) + pub fn create_listing(ctx: Context, listing_id: u64, canonical_currency: state::Currency, canonical_amount: u64, alt_currencies: Vec, price_oracle: Option, accepted_resolvers: Vec, quantity: u32, metadata_uri: String) -> Result<()> { + create_listing::handler(ctx, listing_id, canonical_currency, canonical_amount, alt_currencies, price_oracle, accepted_resolvers, quantity, metadata_uri) } - pub fn update_listing( - ctx: Context, - price: u64, - quantity: u32, - descro_option: Option, - metadata_uri: String, - ) -> Result<()> { - update_listing::handler(ctx, price, quantity, descro_option, metadata_uri) + pub fn update_listing(ctx: Context, canonical_currency: state::Currency, canonical_amount: u64, alt_currencies: Vec, price_oracle: Option, accepted_resolvers: Vec, quantity: u32, metadata_uri: String) -> Result<()> { + update_listing::handler(ctx, canonical_currency, canonical_amount, alt_currencies, price_oracle, 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, - ) -> Result<()> { - create_order::handler(ctx, order_id, escrow_id, resolver) + 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<()> { + create_order::handler(ctx, order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps) } pub fn accept_order(ctx: Context) -> Result<()> { @@ -971,6 +1706,8 @@ 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 @@ -985,20 +1722,22 @@ Expected: all green. ```bash git add programs/solisting/ Cargo.toml Cargo.lock -git commit -m "feat(solisting): implement listing + order flow with buyer-initiated descro escrow" +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 with clean separation: -- `solisting` = coordination and discovery only; never holds escrow SOL -- `descro` = sole custodian of vault; enforces AcceptancePolicy +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: -- USDC/SPL token payment option in `ListingAccount` -- `ProgramGated` resolver CPI in `descro.seller_confirm` +**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)