Files
solisting/docs/superpowers/plans/2026-05-19-solisting.md
2026-05-27 23:15:18 +02:00

1826 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Solisting Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> ⚠️ **Reference plan — do not implement yet.** Two prerequisites must be completed first:
> 1. `2026-05-19-descro-buyer-flow.md` — adds `buyer_create_escrow`, `seller_confirm`, and symmetric `cancel` to descro
> 2. `AcceptancePolicy` prerequisite (adding `AcceptancePolicy` enum to `descro_ext_resolvers`) — documented in the old plan's prerequisite section
**Goal:** Build the `solisting` Anchor program — a coordination and discovery layer for product listings and bilateral order consent, orchestrating the full flow from buyer intent → bilateral consent → active `descro` escrow without ever touching the descro vault directly.
**Architecture:**
- `ListingAccount` holds a single canonical price (`canonical_currency` + `price`) as the sole source of truth. Alternative currencies (`alt_currencies: Vec<AltCurrencyConfig>`) are always converted from canonical at `create_order` time via on-chain Pyth oracles — no independent price per currency, no arbitrage possible.
- Each `AltCurrencyConfig` carries its own `usd_oracle: Option<Pubkey>` (the Pyth TOKEN/USD feed for that token; `None` = treat as a $1 USD stablecoin). The canonical currency has a parallel `canonical_oracle: Option<Pubkey>`. `Currency::Spl` stores `decimals: u8` so the program never needs to look up the mint.
- `OrderAccount` records the bilateral consent: which currency the buyer chose, the oracle-computed amount, and a pointer to the descro escrow.
- The oracle module converts between any two currencies through USD as an intermediate (using up to two Pyth feeds): `canonical_usd = amount × canonical_price`, `target_amount = canonical_usd ÷ target_price`. Both sides can independently be a stablecoin (`None` oracle = $1 peg, no account needed).
- `accepted_resolvers` is a global list per listing — resolvers handle disputes, not payments, so one list covers all currencies.
- `create_order` CPIs `descro.buyer_create_escrow` atomically. `accept_order` CPIs `descro.seller_confirm`. Solisting never holds or transfers vault funds.
- State drift (buyer cancels directly on descro) is handled gracefully: terminal instructions read descro state first and skip the CPI if the escrow is already resolved.
**Key design decisions:**
- No `OrderVault` — funds live in descro's vault from the moment `create_order` is called
- `AcceptancePolicy` is enforced by descro in `seller_confirm`, not by solisting
- `quantity_reserved` tracks pending orders; decremented at `create_order`, restored at `reject_order`/`cancel_order`
- `escrow_id` is derived from the first 8 bytes of the `order_account` PDA to guarantee uniqueness without coordinator state
- `close_stale_order` can be called by anyone to clean up an OrderAccount whose descro escrow is already terminal
- `max_slippage_bps` argument on `create_order` protects buyer from price movement between submission and confirmation
- SPL token path (`descro_spl` CPI) is not yet implemented — `create_order` errors on `payment_currency = Spl` until `descro_spl` exists; state and oracle are designed to support it without schema changes
**Tech Stack:** Rust, Anchor 1.0.x, LiteSVM 0.10.0, `pyth-solana-receiver-sdk` for oracle reads. Build order: `descro_ext_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<Pubkey>,
}
#[account]
#[derive(InitSpace)]
pub struct ListingAccount {
pub seller: Pubkey,
/// Sole source of truth for price. All alt_currencies are derived from this via oracle.
pub canonical_currency: Currency,
/// Price in canonical_currency's smallest unit.
pub price: u64,
/// Pyth V2 PriceFeedAccount for the canonical currency priced in USD.
/// `None` = canonical is a $1.00 USD stablecoin.
/// For Sol canonical: set to the SOL/USD Pyth feed.
pub canonical_oracle: Option<Pubkey>,
/// Alt currencies the seller accepts. Amounts are oracle-computed at create_order time.
/// Empty = no oracle ever needed.
#[max_len(3)]
pub alt_currencies: Vec<AltCurrencyConfig>,
/// Resolvers the seller accepts for dispute resolution. Empty = any resolver ok.
#[max_len(4)]
pub accepted_resolvers: Vec<Pubkey>,
/// Total units the seller offers (includes quantity_reserved).
pub quantity: u32,
/// Units held by pending orders. available = quantity - quantity_reserved.
pub quantity_reserved: u32,
#[max_len(256)]
pub metadata_uri: String,
pub listing_id: u64,
pub is_active: bool,
pub bump: u8,
}
#[account]
#[derive(InitSpace)]
pub struct OrderAccount {
pub listing: Pubkey,
pub buyer: Pubkey,
pub seller: Pubkey,
pub resolver: Pubkey,
/// The currency the buyer chose to pay in.
pub payment_currency: Currency,
/// Actual amount paid (may differ from price when oracle-converted).
pub amount: u64,
/// The descro EscrowAccount PDA for this order.
pub escrow_account: Pubkey,
/// Derived from order_account PDA bytes[0..8]; used as descro escrow_id seed.
pub escrow_id: u64,
pub order_id: u64,
pub created_at: i64,
pub bump: u8,
}
```
- [ ] **Step 2: Write `error.rs`**
```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<u64> {
let (cp_raw, cp_expo) = usd_price(canonical_oracle)?;
let (tp_raw, tp_expo) = usd_price(target_oracle)?;
let cd = canonical_currency.decimals() as i32;
let td = target_currency.decimals() as i32;
// shift = cp_expo tp_expo + td cd
let shift: i32 = cp_expo - tp_expo + td - cd;
// target = price × cp_raw / tp_raw × 10^shift
// Use u128 to avoid overflow. tp_raw and cp_raw are always positive (checked above).
let numerator = (price as u128)
.checked_mul(cp_raw as u128)
.ok_or(error!(SolistingError::OraclePriceUnavailable))?;
let computed: u64 = if shift >= 0 {
let scaled = numerator
.checked_mul(10u128.pow(shift as u32))
.ok_or(error!(SolistingError::OraclePriceUnavailable))?;
u64::try_from(scaled / tp_raw as u128)
.map_err(|_| error!(SolistingError::OraclePriceUnavailable))?
} else {
let divisor = 10u128.pow((-shift) as u32);
u64::try_from(numerator / divisor / tp_raw as u128)
.map_err(|_| error!(SolistingError::OraclePriceUnavailable))?
};
if max_slippage_bps > 0 && expected_amount > 0 {
let tolerance = (expected_amount as u128)
.checked_mul(max_slippage_bps as u128)
.unwrap_or(u128::MAX)
/ 10_000;
let diff = computed.abs_diff(expected_amount) as u128;
require!(diff <= tolerance, SolistingError::SlippageExceeded);
}
Ok(computed)
}
```
- [ ] **Step 4: Verify `state.rs` compiles in isolation (no instruction files yet)**
```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<CreateListing>,
listing_id: u64,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
listing.seller = ctx.accounts.seller.key();
listing.canonical_currency = canonical_currency;
listing.price = price;
listing.canonical_oracle = canonical_oracle;
listing.alt_currencies = alt_currencies;
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.quantity_reserved = 0;
listing.metadata_uri = metadata_uri;
listing.listing_id = listing_id;
listing.is_active = true;
listing.bump = ctx.bumps.listing_account;
Ok(())
}
```
- [ ] **Step 4: Write `update_listing.rs`**
```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<UpdateListing>,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
listing.canonical_currency = canonical_currency;
listing.price = price;
listing.canonical_oracle = canonical_oracle;
listing.alt_currencies = alt_currencies;
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.metadata_uri = metadata_uri;
Ok(())
}
```
- [ ] **Step 5: Write `close_listing.rs`**
```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<CloseListing>) -> 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<dyn std::error::Error>> {
todo!("fill from descro tests/common/mod.rs try_send() helper")
}
// --- Instruction builders ---
pub fn ix_create_listing(
seller: &Pubkey,
listing_id: u64,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> solana_message::compiled_instruction::CompiledInstruction {
todo!("build anchor instruction for create_listing")
}
pub fn ix_update_listing(
seller: &Pubkey,
listing_id: u64,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> solana_message::compiled_instruction::CompiledInstruction {
todo!("build anchor instruction for update_listing")
}
pub fn ix_close_listing(
seller: &Pubkey,
listing_id: u64,
) -> solana_message::compiled_instruction::CompiledInstruction {
todo!("build anchor instruction for close_listing")
}
```
> **Note:** Copy the `send()` / `try_send()` pattern from `programs/descro/tests/common/mod.rs` and wire the instruction builders using Anchor's discriminator + borsh-encode args pattern from those existing tests.
- [ ] **Step 7: Wire `lib.rs` and `instructions.rs` (listing instructions only)**
`instructions.rs`:
```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<CreateListing>,
listing_id: u64,
canonical_currency: state::Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
create_listing::handler(
ctx, listing_id, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
)
}
pub fn update_listing(
ctx: Context<UpdateListing>,
canonical_currency: state::Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
update_listing::handler(
ctx, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
)
}
pub fn close_listing(ctx: Context<CloseListing>) -> Result<()> {
close_listing::handler(ctx)
}
}
```
- [ ] **Step 8: Build and run listing tests**
```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<CreateOrder>,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
payment_currency: Currency,
expected_amount: u64, // amount buyer expects to pay (for slippage check)
max_slippage_bps: u16,
) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
let listing = &ctx.accounts.listing_account;
// Validate resolver
if !listing.accepted_resolvers.is_empty() {
require!(
listing.accepted_resolvers.contains(&resolver),
SolistingError::ResolverNotAccepted
);
}
// Determine amount in payment_currency
let amount = if payment_currency == listing.canonical_currency {
// Paying in canonical currency — no oracle needed
listing.price
} else {
// Must be a configured alt_currency
let alt_cfg = listing.alt_currencies
.iter()
.find(|c| c.currency == payment_currency)
.ok_or(error!(SolistingError::CurrencyNotAccepted))?;
// Validate passed oracle accounts against listing state
if let Some(expected_canonical) = listing.canonical_oracle {
require!(
ctx.accounts.canonical_oracle.key() == expected_canonical,
SolistingError::OracleMismatch
);
}
if let Some(expected_target) = alt_cfg.usd_oracle {
require!(
ctx.accounts.target_oracle.key() == expected_target,
SolistingError::OracleMismatch
);
}
let c_oracle = listing.canonical_oracle.map(|_| ctx.accounts.canonical_oracle.as_ref());
let t_oracle = alt_cfg.usd_oracle.map(|_| ctx.accounts.target_oracle.as_ref());
oracle::convert_with_slippage(
c_oracle,
&listing.canonical_currency,
listing.price,
t_oracle,
&payment_currency,
expected_amount,
max_slippage_bps,
)?
};
// Only Sol payment supported now (descro_spl does not exist yet)
require!(
payment_currency == Currency::Sol,
SolistingError::SplNotImplemented
);
// CPI: descro.buyer_create_escrow — creates EscrowAccount + funds vault atomically
descro::cpi::buyer_create_escrow(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::BuyerCreateEscrow {
buyer: ctx.accounts.buyer.to_account_info(),
seller: ctx.accounts.seller.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
vault: ctx.accounts.descro_vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
amount,
Some(resolver),
escrow_id,
)?;
ctx.accounts.listing_account.quantity_reserved += 1;
let order = &mut ctx.accounts.order_account;
order.listing = ctx.accounts.listing_account.key();
order.buyer = ctx.accounts.buyer.key();
order.seller = ctx.accounts.seller.key();
order.resolver = resolver;
order.payment_currency = payment_currency;
order.amount = amount;
order.escrow_account = ctx.accounts.escrow_account.key();
order.escrow_id = escrow_id;
order.order_id = order_id;
order.created_at = Clock::get()?.unix_timestamp;
order.bump = ctx.bumps.order_account;
Ok(())
}
```
> **`escrow_id` derivation note:** Solisting passes `escrow_id` as an argument. The SDK derives it as `u64::from_le_bytes(order_pda.to_bytes()[0..8])`. This ties the escrow_id to the order PDA, ensuring uniqueness as long as `order_id` is unique per buyer+listing.
- [ ] **Step 4: Add order helpers to `tests/common/mod.rs`**
Add `order_pda()`, `read_order()`, `ix_create_order()`, `ix_create_order_with_resolver()`. Mirror the pattern from listing helpers: PDA derivation, borsh-encode instruction data, build `CompiledInstruction`.
```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<Pubkey>,
target_oracle: Option<Pubkey>,
) -> solana_message::compiled_instruction::CompiledInstruction {
let listing = listing_pda(seller, listing_id);
let order = order_pda(listing, *buyer, order_id);
let escrow_id = u64::from_le_bytes(order.to_bytes()[0..8].try_into().unwrap());
let resolver = Pubkey::new_unique(); // default resolver for simple tests
ix_create_order_with_resolver(buyer, seller, listing_id, order_id, payment_currency, max_slippage_bps, canonical_oracle, target_oracle, resolver)
}
pub fn ix_create_order_with_resolver(
buyer: &Pubkey,
seller: &Pubkey,
listing_id: u64,
order_id: u64,
payment_currency: Currency,
max_slippage_bps: u16,
canonical_oracle: Option<Pubkey>,
target_oracle: Option<Pubkey>,
resolver: Pubkey,
) -> solana_message::compiled_instruction::CompiledInstruction {
let listing = listing_pda(seller, listing_id);
let order = order_pda(listing, *buyer, order_id);
let escrow_id = u64::from_le_bytes(order.to_bytes()[0..8].try_into().unwrap());
let (escrow_account, _) = Pubkey::find_program_address(
&[b"escrow", seller.as_ref(), &escrow_id.to_le_bytes()],
&descro::id(),
);
let (descro_vault, _) = Pubkey::find_program_address(
&[b"vault", escrow_account.as_ref()],
&descro::id(),
);
let canonical_oracle_key = canonical_oracle.unwrap_or(solana_sdk::system_program::id());
let target_oracle_key = target_oracle.unwrap_or(solana_sdk::system_program::id());
let expected_amount = 0u64; // tests that don't use oracle set this to 0
todo!("build CompiledInstruction: discriminator + borsh encode (order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps), accounts: [..., canonical_oracle_key, target_oracle_key, ...]")
}
```
- [ ] **Step 5: Run tests**
```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<AcceptOrder>) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
descro::cpi::seller_confirm(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::SellerConfirm {
seller: ctx.accounts.seller.to_account_info(),
resolver: ctx.accounts.resolver.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
resolver_entry: ctx.accounts.resolver_entry.to_account_info(),
},
),
)?;
let listing = &mut ctx.accounts.listing_account;
listing.quantity_reserved = listing.quantity_reserved.saturating_sub(1);
listing.quantity = listing.quantity.saturating_sub(1);
Ok(())
}
```
- [ ] **Step 3: Run test**
```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<RejectOrder>) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
// Defensive: skip CPI if escrow already resolved (buyer cancelled directly on descro)
if !ctx.accounts.escrow_account.data_is_empty() {
let data = ctx.accounts.escrow_account.try_borrow_data()?;
if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
if escrow.state == descro::EscrowState::AwaitingSellerConfirm {
drop(data);
descro::cpi::cancel(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::Cancel {
canceller: ctx.accounts.seller.to_account_info(),
buyer: ctx.accounts.buyer.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
)?;
}
}
}
ctx.accounts.listing_account.quantity_reserved =
ctx.accounts.listing_account.quantity_reserved.saturating_sub(1);
Ok(())
}
```
- [ ] **Step 3: Implement `cancel_order.rs`**
Identical to `reject_order.rs` with three differences: `buyer: Signer` instead of `seller: Signer`, constraints check `buyer == order.buyer`, and CPI `canceller` = buyer, `close = buyer`.
```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<CancelOrder>) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
if !ctx.accounts.escrow_account.data_is_empty() {
let data = ctx.accounts.escrow_account.try_borrow_data()?;
if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
if escrow.state == descro::EscrowState::AwaitingSellerConfirm {
drop(data);
descro::cpi::cancel(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::Cancel {
canceller: ctx.accounts.buyer.to_account_info(),
buyer: ctx.accounts.buyer.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
)?;
}
}
}
ctx.accounts.listing_account.quantity_reserved =
ctx.accounts.listing_account.quantity_reserved.saturating_sub(1);
Ok(())
}
```
- [ ] **Step 4: Run all order tests**
```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<CloseStaleOrder>) -> Result<()> {
require!(
ctx.accounts.escrow_account.key() == ctx.accounts.order_account.escrow_account,
SolistingError::EscrowStateUnexpected
);
// Closed (zero data) escrow = already resolved = ok to clean up
if !ctx.accounts.escrow_account.data_is_empty() {
let data = ctx.accounts.escrow_account.try_borrow_data()?;
if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
require!(
escrow.state == descro::EscrowState::Cancelled
|| escrow.state == descro::EscrowState::Complete,
SolistingError::EscrowStateUnexpected
);
}
}
Ok(())
}
```
- [ ] **Step 3: Run tests**
```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<CreateListing>, listing_id: u64, canonical_currency: state::Currency, price: u64, canonical_oracle: Option<Pubkey>, alt_currencies: Vec<state::AltCurrencyConfig>, accepted_resolvers: Vec<Pubkey>, quantity: u32, metadata_uri: String) -> Result<()> {
create_listing::handler(ctx, listing_id, canonical_currency, price, canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri)
}
pub fn update_listing(ctx: Context<UpdateListing>, canonical_currency: state::Currency, price: u64, canonical_oracle: Option<Pubkey>, alt_currencies: Vec<state::AltCurrencyConfig>, accepted_resolvers: Vec<Pubkey>, quantity: u32, metadata_uri: String) -> Result<()> {
update_listing::handler(ctx, canonical_currency, price, canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri)
}
pub fn close_listing(ctx: Context<CloseListing>) -> Result<()> {
close_listing::handler(ctx)
}
pub fn create_order(ctx: Context<CreateOrder>, order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: state::Currency, expected_amount: u64, max_slippage_bps: u16) -> Result<()> {
// canonical_oracle and target_oracle come from the account struct, not args
create_order::handler(ctx, order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps)
}
pub fn accept_order(ctx: Context<AcceptOrder>) -> Result<()> {
accept_order::handler(ctx)
}
pub fn reject_order(ctx: Context<RejectOrder>) -> Result<()> {
reject_order::handler(ctx)
}
pub fn cancel_order(ctx: Context<CancelOrder>) -> Result<()> {
cancel_order::handler(ctx)
}
pub fn close_stale_order(ctx: Context<CloseStaleOrder>) -> Result<()> {
close_stale_order::handler(ctx)
}
}
```
- [ ] **Step 3: Build all three programs**
```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)