67 KiB
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:
2026-05-19-descro-buyer-flow.md— addsbuyer_create_escrow,seller_confirm, and symmetriccancelto descroAcceptancePolicyprerequisite (addingAcceptancePolicyenum todescro_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:
ListingAccountholds a single canonical price (canonical_currency+price) as the sole source of truth. Alternative currencies (alt_currencies: Vec<AltCurrencyConfig>) are always converted from canonical atcreate_ordertime via on-chain Pyth oracles — no independent price per currency, no arbitrage possible.- Each
AltCurrencyConfigcarries its ownusd_oracle: Option<Pubkey>(the Pyth TOKEN/USD feed for that token;None= treat as a $1 USD stablecoin). The canonical currency has a parallelcanonical_oracle: Option<Pubkey>.Currency::Splstoresdecimals: u8so the program never needs to look up the mint. OrderAccountrecords 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 (Noneoracle = $1 peg, no account needed). accepted_resolversis a global list per listing — resolvers handle disputes, not payments, so one list covers all currencies.create_orderCPIsdescro.buyer_create_escrowatomically.accept_orderCPIsdescro.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 momentcreate_orderis called AcceptancePolicyis enforced by descro inseller_confirm, not by solistingquantity_reservedtracks pending orders; decremented atcreate_order, restored atreject_order/cancel_orderescrow_idis derived from the first 8 bytes of theorder_accountPDA to guarantee uniqueness without coordinator stateclose_stale_ordercan be called by anyone to clean up an OrderAccount whose descro escrow is already terminalmax_slippage_bpsargument oncreate_orderprotects buyer from price movement between submission and confirmation- SPL token path (
descro_splCPI) is not yet implemented —create_ordererrors onpayment_currency = Spluntildescro_splexists; 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
solistingto workspace rootCargo.toml
Add "programs/solisting" to the [workspace] members array.
- Step 2: Create
programs/solisting/Cargo.toml
[package]
name = "solisting"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "solisting"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build"]
anchor-debug = []
custom-heap = []
custom-panic = []
[dependencies]
anchor-lang = "1.0.2"
descro = { path = "../descro", features = ["cpi"] }
descro_ext_resolvers = { path = "../descro_ext_resolvers" }
# Pyth oracle SDK for on-chain price reads (Pull Oracle / V2 accounts).
# Verify latest compatible version at https://crates.io/crates/pyth-solana-receiver-sdk
pyth-solana-receiver-sdk = "0.3"
[dev-dependencies]
litesvm = "0.10.0"
solana-message = "3.0.1"
solana-transaction = "3.0.2"
solana-signer = "3.0.0"
solana-keypair = "3.0.1"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
- Step 3: Create directory structure
mkdir -p programs/solisting/src/instructions programs/solisting/tests/common
touch programs/solisting/src/lib.rs
touch programs/solisting/src/state.rs
touch programs/solisting/src/error.rs
touch programs/solisting/src/oracle.rs
touch programs/solisting/src/instructions.rs
touch programs/solisting/src/instructions/create_listing.rs
touch programs/solisting/src/instructions/update_listing.rs
touch programs/solisting/src/instructions/close_listing.rs
touch programs/solisting/src/instructions/create_order.rs
touch programs/solisting/src/instructions/accept_order.rs
touch programs/solisting/src/instructions/reject_order.rs
touch programs/solisting/src/instructions/cancel_order.rs
touch programs/solisting/src/instructions/close_stale_order.rs
- Step 4: Commit skeleton
git add programs/solisting/ Cargo.toml Cargo.lock
git commit -m "chore(solisting): add crate skeleton and workspace entry"
Task 2: State, Errors, Oracle Module
Files: state.rs, error.rs, oracle.rs
- Step 1: Write
state.rs
use anchor_lang::prelude::*;
/// Payment currency identifier. Amounts are always in the currency's smallest unit:
/// Sol → Lamports (u64), Spl → token units (smallest unit per `decimals`).
#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace, Debug, PartialEq)]
pub enum Currency {
Sol,
/// `decimals` mirrors the SPL mint's decimals field (e.g. 6 for USDC/USDT, 5 for BONK).
/// Storing it here avoids a mint account lookup at order time.
Spl { mint: Pubkey, decimals: u8 },
}
impl Currency {
pub fn decimals(&self) -> u8 {
match self {
Currency::Sol => 9,
Currency::Spl { decimals, .. } => *decimals,
}
}
}
/// Per-alt-currency oracle configuration. Each alt currency carries its own Pyth feed
/// because different tokens have different price feeds.
#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace, Debug)]
pub struct AltCurrencyConfig {
pub currency: Currency,
/// Pyth V2 PriceFeedAccount for this token priced in USD (TOKEN/USD).
/// `None` = treat as a $1.00 USD stablecoin — no oracle account required at order time.
pub usd_oracle: Option<Pubkey>,
}
#[account]
#[derive(InitSpace)]
pub struct ListingAccount {
pub seller: Pubkey,
/// Sole source of truth for price. All alt_currencies are derived from this via oracle.
pub canonical_currency: Currency,
/// Price in canonical_currency's smallest unit.
pub price: u64,
/// Pyth V2 PriceFeedAccount for the canonical currency priced in USD.
/// `None` = canonical is a $1.00 USD stablecoin.
/// For Sol canonical: set to the SOL/USD Pyth feed.
pub canonical_oracle: Option<Pubkey>,
/// Alt currencies the seller accepts. Amounts are oracle-computed at create_order time.
/// Empty = no oracle ever needed.
#[max_len(3)]
pub alt_currencies: Vec<AltCurrencyConfig>,
/// Resolvers the seller accepts for dispute resolution. Empty = any resolver ok.
#[max_len(4)]
pub accepted_resolvers: Vec<Pubkey>,
/// Total units the seller offers (includes quantity_reserved).
pub quantity: u32,
/// Units held by pending orders. available = quantity - quantity_reserved.
pub quantity_reserved: u32,
#[max_len(256)]
pub metadata_uri: String,
pub listing_id: u64,
pub is_active: bool,
pub bump: u8,
}
#[account]
#[derive(InitSpace)]
pub struct OrderAccount {
pub listing: Pubkey,
pub buyer: Pubkey,
pub seller: Pubkey,
pub resolver: Pubkey,
/// The currency the buyer chose to pay in.
pub payment_currency: Currency,
/// Actual amount paid (may differ from price when oracle-converted).
pub amount: u64,
/// The descro EscrowAccount PDA for this order.
pub escrow_account: Pubkey,
/// Derived from order_account PDA bytes[0..8]; used as descro escrow_id seed.
pub escrow_id: u64,
pub order_id: u64,
pub created_at: i64,
pub bump: u8,
}
- Step 2: Write
error.rs
use anchor_lang::prelude::*;
#[error_code]
pub enum SolistingError {
#[msg("Listing is not active")]
ListingNotActive,
#[msg("No quantity available")]
OutOfStock,
#[msg("Signer is not authorized")]
Unauthorized,
#[msg("Currency not accepted by this listing")]
CurrencyNotAccepted,
#[msg("Resolver is not accepted by this listing")]
ResolverNotAccepted,
#[msg("Oracle account is required but was not provided (non-stablecoin currency)")]
OracleRequired,
#[msg("Passed oracle account key does not match the address stored in the listing")]
OracleMismatch,
#[msg("Oracle price is unavailable, stale, or has too wide a confidence interval")]
OraclePriceUnavailable,
#[msg("Slippage tolerance exceeded — oracle rate moved unfavorably since tx was built")]
SlippageExceeded,
#[msg("SPL payment path not yet implemented — descro_spl does not exist")]
SplNotImplemented,
#[msg("Descro escrow is not in expected state")]
EscrowStateUnexpected,
#[msg("Descro program address mismatch")]
InvalidDescroProgram,
}
- Step 3: Write
oracle.rs
The oracle module converts between any two currencies using USD as an intermediate. Each
currency has an optional Pyth feed (TOKEN/USD). None = treat as $1.00 stablecoin. No feed ID
is hardcoded — the seller registers the oracle account address in the listing; the program only
verifies that the passed account key matches the stored key (done in create_order, not here).
Math (using USD as pivot):
canonical_usd = price × (cp_raw × 10^cp_expo) / 10^cd
target_amount = canonical_usd × 10^td / (tp_raw × 10^tp_expo)
= price × cp_raw / tp_raw × 10^(cp_expo − tp_expo + td − cd)
let shift = cp_expo − tp_expo + td − cd (can be positive or negative)
Stablecoin sentinel: (1, 0) meaning exactly $1.00 per whole token.
Example — SOL → BONK (cd=9, cp_expo=−8, td=5, tp_expo=−8):
shift = −8 − (−8) + 5 − 9 = −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)
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.rscompiles in isolation (no instruction files yet)
cargo check --manifest-path programs/solisting/Cargo.toml 2>&1 | head -20
Expected: errors only about missing modules (lib.rs not wired yet), no type errors in state.rs itself.
- Step 5: Commit
git add programs/solisting/src/state.rs programs/solisting/src/error.rs programs/solisting/src/oracle.rs
git commit -m "feat(solisting): add state, errors, and oracle conversion module"
Task 3: Listing Instructions
Files: create_listing.rs, update_listing.rs, close_listing.rs
- Step 1: Write failing tests in
tests/test_listings.rs
mod common;
use common::*;
#[test]
fn seller_can_create_sol_only_listing() {
let (mut svm, seller, _) = setup();
let listing_id: u64 = 1;
let price = 100_000_000u64; // 0.1 SOL in lamports
let ix = ix_create_listing(
&seller.pubkey(),
listing_id,
Currency::Sol,
price,
None, // canonical_oracle: None (no alt currencies)
vec![], // no alt_currencies
vec![], // any resolver
10, // quantity
"ipfs://test".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
let listing = read_listing(&svm, &seller.pubkey(), listing_id);
assert_eq!(listing.canonical_currency, Currency::Sol);
assert_eq!(listing.price, price);
assert_eq!(listing.alt_currencies.len(), 0);
assert_eq!(listing.canonical_oracle, None);
assert_eq!(listing.quantity, 10);
assert_eq!(listing.quantity_reserved, 0);
assert!(listing.is_active);
}
#[test]
fn seller_can_create_listing_with_usdc_alt_stablecoin() {
// canonical=Sol, alt=USDC with no oracle (stablecoin $1 peg)
let (mut svm, seller, _) = setup();
let usdc_mint = Pubkey::new_unique();
let sol_usd_feed = Pubkey::new_unique(); // mock SOL/USD feed key
let ix = ix_create_listing(
&seller.pubkey(),
10,
Currency::Sol,
1_000_000_000,
Some(sol_usd_feed), // canonical_oracle = SOL/USD feed
vec![AltCurrencyConfig {
currency: Currency::Spl { mint: usdc_mint, decimals: 6 },
usd_oracle: None, // USDC is a stablecoin — no feed needed
}],
vec![],
5,
"ipfs://x".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
let listing = read_listing(&svm, &seller.pubkey(), 10);
assert_eq!(listing.canonical_oracle, Some(sol_usd_feed));
assert_eq!(listing.alt_currencies[0].usd_oracle, None);
}
#[test]
fn seller_can_create_listing_with_bonk_alt_oracle() {
// canonical=Sol, alt=BONK — needs both SOL/USD and BONK/USD feeds
let (mut svm, seller, _) = setup();
let bonk_mint = Pubkey::new_unique();
let sol_usd_feed = Pubkey::new_unique();
let bonk_usd_feed = Pubkey::new_unique();
let ix = ix_create_listing(
&seller.pubkey(),
11,
Currency::Sol,
1_000_000_000,
Some(sol_usd_feed),
vec![AltCurrencyConfig {
currency: Currency::Spl { mint: bonk_mint, decimals: 5 },
usd_oracle: Some(bonk_usd_feed),
}],
vec![],
5,
"".to_string(),
);
send(&mut svm, &[ix], &[&seller]);
let listing = read_listing(&svm, &seller.pubkey(), 11);
assert_eq!(listing.canonical_oracle, Some(sol_usd_feed));
assert_eq!(listing.alt_currencies[0].usd_oracle, Some(bonk_usd_feed));
}
#[test]
fn seller_can_update_listing() {
let (mut svm, seller, _) = setup();
let listing_id: u64 = 2;
let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, None, vec![], vec![], 10, "".to_string());
send(&mut svm, &[ix], &[&seller]);
let ix_update = ix_update_listing(
&seller.pubkey(),
listing_id,
Currency::Sol,
2_000_000_000,
None,
vec![],
vec![],
20,
"ipfs://new".to_string(),
);
send(&mut svm, &[ix_update], &[&seller]);
let listing = read_listing(&svm, &seller.pubkey(), listing_id);
assert_eq!(listing.price, 2_000_000_000);
assert_eq!(listing.quantity, 20);
}
#[test]
fn seller_can_close_listing() {
let (mut svm, seller, _) = setup();
let listing_id: u64 = 3;
let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, None, vec![], vec![], 5, "".to_string());
send(&mut svm, &[ix], &[&seller]);
let ix_close = ix_close_listing(&seller.pubkey(), listing_id);
send(&mut svm, &[ix_close], &[&seller]);
let pda = listing_pda(&seller.pubkey(), listing_id);
assert!(svm.get_account(&pda).is_none());
}
- Step 2: Run tests — verify they fail with "unresolved" errors (not compiled yet)
cargo test --manifest-path programs/solisting/Cargo.toml --test test_listings 2>&1 | head -20
Expected: compile errors (ix helpers not defined yet).
- Step 3: Write
create_listing.rs
use anchor_lang::prelude::*;
use crate::state::{Currency, ListingAccount};
use crate::error::SolistingError;
#[derive(Accounts)]
#[instruction(listing_id: u64)]
pub struct CreateListing<'info> {
#[account(mut)]
pub seller: Signer<'info>,
#[account(
init,
payer = seller,
space = 8 + ListingAccount::INIT_SPACE,
seeds = [b"listing", seller.key().as_ref(), &listing_id.to_le_bytes()],
bump
)]
pub listing_account: Account<'info, ListingAccount>,
pub system_program: Program<'info, System>,
}
pub fn handler(
ctx: Context<CreateListing>,
listing_id: u64,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
listing.seller = ctx.accounts.seller.key();
listing.canonical_currency = canonical_currency;
listing.price = price;
listing.canonical_oracle = canonical_oracle;
listing.alt_currencies = alt_currencies;
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.quantity_reserved = 0;
listing.metadata_uri = metadata_uri;
listing.listing_id = listing_id;
listing.is_active = true;
listing.bump = ctx.bumps.listing_account;
Ok(())
}
- Step 4: Write
update_listing.rs
use anchor_lang::prelude::*;
use crate::state::{Currency, ListingAccount};
use crate::error::SolistingError;
#[derive(Accounts)]
pub struct UpdateListing<'info> {
pub seller: Signer<'info>,
#[account(
mut,
seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
bump = listing_account.bump,
constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
constraint = listing_account.is_active @ SolistingError::ListingNotActive,
)]
pub listing_account: Account<'info, ListingAccount>,
}
pub fn handler(
ctx: Context<UpdateListing>,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
listing.canonical_currency = canonical_currency;
listing.price = price;
listing.canonical_oracle = canonical_oracle;
listing.alt_currencies = alt_currencies;
listing.accepted_resolvers = accepted_resolvers;
listing.quantity = quantity;
listing.metadata_uri = metadata_uri;
Ok(())
}
- Step 5: Write
close_listing.rs
use anchor_lang::prelude::*;
use crate::state::ListingAccount;
use crate::error::SolistingError;
#[derive(Accounts)]
pub struct CloseListing<'info> {
#[account(mut)]
pub seller: Signer<'info>,
#[account(
mut,
seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
bump = listing_account.bump,
constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
close = seller,
)]
pub listing_account: Account<'info, ListingAccount>,
}
pub fn handler(_ctx: Context<CloseListing>) -> Result<()> {
Ok(())
}
- Step 6: Write
tests/common/mod.rsskeleton (listing helpers only for now)
use litesvm::LiteSVM;
use solana_keypair::Keypair;
use solana_signer::Signer;
use anchor_lang::prelude::Pubkey;
use solisting::state::{Currency, ListingAccount};
pub fn setup() -> (LiteSVM, Keypair, Keypair) {
let mut svm = LiteSVM::new();
svm.add_program_from_file(
solisting::id(),
"../../target/deploy/solisting.so",
).unwrap();
let seller = Keypair::new();
let buyer = Keypair::new();
svm.airdrop(&seller.pubkey(), 10_000_000_000).unwrap();
svm.airdrop(&buyer.pubkey(), 10_000_000_000).unwrap();
(svm, seller, buyer)
}
pub fn listing_pda(seller: &Pubkey, listing_id: u64) -> Pubkey {
Pubkey::find_program_address(
&[b"listing", seller.as_ref(), &listing_id.to_le_bytes()],
&solisting::id(),
).0
}
pub fn read_listing(svm: &LiteSVM, seller: &Pubkey, listing_id: u64) -> ListingAccount {
let pda = listing_pda(seller, listing_id);
let account = svm.get_account(&pda).unwrap();
ListingAccount::try_deserialize(&mut account.data.as_slice()).unwrap()
}
pub fn send(svm: &mut LiteSVM, ixs: &[solana_message::compiled_instruction::CompiledInstruction], signers: &[&Keypair]) {
// build and send transaction — adapt to LiteSVM 0.10 API (see existing descro tests for pattern)
todo!("fill from descro tests/common/mod.rs send() helper")
}
pub fn try_send(svm: &mut LiteSVM, ixs: &[solana_message::compiled_instruction::CompiledInstruction], signers: &[&Keypair]) -> Result<(), Box<dyn std::error::Error>> {
todo!("fill from descro tests/common/mod.rs try_send() helper")
}
// --- Instruction builders ---
pub fn ix_create_listing(
seller: &Pubkey,
listing_id: u64,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> solana_message::compiled_instruction::CompiledInstruction {
todo!("build anchor instruction for create_listing")
}
pub fn ix_update_listing(
seller: &Pubkey,
listing_id: u64,
canonical_currency: Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> solana_message::compiled_instruction::CompiledInstruction {
todo!("build anchor instruction for update_listing")
}
pub fn ix_close_listing(
seller: &Pubkey,
listing_id: u64,
) -> solana_message::compiled_instruction::CompiledInstruction {
todo!("build anchor instruction for close_listing")
}
Note: Copy the
send()/try_send()pattern fromprograms/descro/tests/common/mod.rsand wire the instruction builders using Anchor's discriminator + borsh-encode args pattern from those existing tests.
- Step 7: Wire
lib.rsandinstructions.rs(listing instructions only)
instructions.rs:
pub mod close_listing;
pub mod create_listing;
pub mod update_listing;
pub use close_listing::*;
pub use create_listing::*;
pub use update_listing::*;
lib.rs (listing portion — extend in Task 4):
pub mod error;
pub mod instructions;
pub mod oracle;
pub mod state;
use anchor_lang::prelude::*;
pub use error::*;
pub use instructions::*;
pub use state::*;
declare_id!("So1istingProgramID11111111111111111111111111"); // replace after anchor keys list
#[program]
pub mod solisting {
use super::*;
pub fn create_listing(
ctx: Context<CreateListing>,
listing_id: u64,
canonical_currency: state::Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
create_listing::handler(
ctx, listing_id, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
)
}
pub fn update_listing(
ctx: Context<UpdateListing>,
canonical_currency: state::Currency,
price: u64,
canonical_oracle: Option<Pubkey>,
alt_currencies: Vec<state::AltCurrencyConfig>,
accepted_resolvers: Vec<Pubkey>,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
update_listing::handler(
ctx, canonical_currency, price,
canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri,
)
}
pub fn close_listing(ctx: Context<CloseListing>) -> Result<()> {
close_listing::handler(ctx)
}
}
- Step 8: Build and run listing tests
cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \
cargo build-sbf --manifest-path programs/descro/Cargo.toml && \
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_listings 2>&1 | tail -10
Expected: all listing tests pass.
- Step 9: Commit
git add programs/solisting/
git commit -m "feat(solisting): implement listing create/update/close with canonical price model"
Task 4: create_order — Validate Currency, Oracle Conversion, CPI buyer_create_escrow
The buyer's single transaction: validates payment currency against listing, optionally reads oracle, reserves quantity, CPIs descro.buyer_create_escrow.
File: programs/solisting/src/instructions/create_order.rs
- Step 1: Write failing tests in
tests/test_orders.rs
mod common;
use common::*;
#[test]
fn buyer_can_create_order_canonical_sol() {
// Listing: canonical=Sol(0.1 SOL), no alt_currencies
// Buyer creates order paying in Sol
// Assert: OrderAccount exists with payment_currency=Sol, amount=0.1 SOL in lamports
// Assert: descro EscrowAccount exists, state == AwaitingSellerConfirm
// Assert: descro vault has listing.price lamports
// Assert: listing.quantity_reserved == 1
let (mut svm, seller, buyer) = setup();
let listing_id = 1u64;
let price = 100_000_000u64;
let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string());
send(&mut svm, &[ix], &[&seller]);
let order_id = 1u64;
let ix_order = ix_create_order(
&buyer.pubkey(),
&seller.pubkey(),
listing_id,
order_id,
Currency::Sol,
0, // max_slippage_bps — unused for canonical
None, // no oracle account needed
);
send(&mut svm, &[ix_order], &[&buyer]);
let order = read_order(&svm, listing_pda(&seller.pubkey(), listing_id), buyer.pubkey(), order_id);
assert_eq!(order.payment_currency, Currency::Sol);
assert_eq!(order.amount, price);
let listing = read_listing(&svm, &seller.pubkey(), listing_id);
assert_eq!(listing.quantity_reserved, 1);
}
#[test]
fn create_order_fails_if_listing_inactive() {
let (mut svm, seller, buyer) = setup();
let listing_id = 2u64;
let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 1_000_000_000, None, vec![], vec![], 1, "".to_string());
send(&mut svm, &[ix], &[&seller]);
let ix_close = ix_close_listing(&seller.pubkey(), listing_id);
send(&mut svm, &[ix_close], &[&seller]);
let result = try_send(&mut svm, &[ix_create_order(
&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None,
)], &[&buyer]);
assert!(result.is_err());
}
#[test]
fn create_order_fails_if_out_of_stock() {
let (mut svm, seller, buyer) = setup();
let listing_id = 3u64;
// quantity = 1, reserve it with first order
let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 1, "".to_string());
send(&mut svm, &[ix], &[&seller]);
send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);
let buyer2 = Keypair::new();
svm.airdrop(&buyer2.pubkey(), 10_000_000_000).unwrap();
let result = try_send(&mut svm, &[ix_create_order(&buyer2.pubkey(), &seller.pubkey(), listing_id, 2, Currency::Sol, 0, None, None)], &[&buyer2]);
assert!(result.is_err());
}
#[test]
fn create_order_fails_if_currency_not_accepted() {
let (mut svm, seller, buyer) = setup();
let listing_id = 4u64;
// Sol-only listing — buyer tries to pay with fake SPL
let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string());
send(&mut svm, &[ix], &[&seller]);
let fake_mint = Pubkey::new_unique();
let result = try_send(&mut svm, &[ix_create_order(
&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Spl { mint: fake_mint, decimals: 6 }, 0, None, None,
)], &[&buyer]);
assert!(result.is_err());
}
#[test]
fn create_order_fails_if_resolver_not_accepted() {
let (mut svm, seller, buyer) = setup();
let listing_id = 5u64;
let allowed_resolver = Pubkey::new_unique();
let ix = ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![allowed_resolver], 5, "".to_string());
send(&mut svm, &[ix], &[&seller]);
// Pass a different resolver — must fail
let result = try_send(&mut svm, &[ix_create_order_with_resolver(
&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None, Pubkey::new_unique(),
)], &[&buyer]);
assert!(result.is_err());
}
- Step 2: Run tests — verify compile failure (create_order instruction not yet written)
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders 2>&1 | head -10
Expected: compile errors.
- Step 3: Implement
create_order.rs
use anchor_lang::prelude::*;
use crate::state::{Currency, ListingAccount, OrderAccount};
use crate::error::SolistingError;
use crate::oracle;
#[derive(Accounts)]
#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: Currency)]
pub struct CreateOrder<'info> {
#[account(mut)]
pub buyer: Signer<'info>,
/// CHECK: Seller — verified against listing.seller
pub seller: UncheckedAccount<'info>,
#[account(
mut,
seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
bump = listing_account.bump,
constraint = listing_account.is_active @ SolistingError::ListingNotActive,
constraint = listing_account.quantity > listing_account.quantity_reserved @ SolistingError::OutOfStock,
constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
)]
pub listing_account: Account<'info, ListingAccount>,
#[account(
init,
payer = buyer,
space = 8 + OrderAccount::INIT_SPACE,
seeds = [b"order", listing_account.key().as_ref(), buyer.key().as_ref(), &order_id.to_le_bytes()],
bump
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: Descro EscrowAccount PDA — created by the CPI
#[account(
mut,
seeds = [b"escrow", seller.key().as_ref(), &escrow_id.to_le_bytes()],
bump,
seeds::program = descro::id(),
)]
pub escrow_account: UncheckedAccount<'info>,
/// CHECK: Descro vault PDA — funded by the CPI
#[account(
mut,
seeds = [b"vault", escrow_account.key().as_ref()],
bump,
seeds::program = descro::id(),
)]
pub descro_vault: UncheckedAccount<'info>,
/// CHECK: Pyth USD feed for the canonical currency (listing.canonical_oracle).
/// Pass SystemProgram as placeholder when canonical_oracle is None (stablecoin).
pub canonical_oracle: UncheckedAccount<'info>,
/// CHECK: Pyth USD feed for the chosen alt currency (alt_cfg.usd_oracle).
/// Pass SystemProgram as placeholder when usd_oracle is None (stablecoin).
/// Also pass SystemProgram when paying in the canonical currency (oracle unused).
pub target_oracle: UncheckedAccount<'info>,
/// CHECK: Descro program — verified in handler
pub descro_program: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(
ctx: Context<CreateOrder>,
order_id: u64,
escrow_id: u64,
resolver: Pubkey,
payment_currency: Currency,
expected_amount: u64, // amount buyer expects to pay (for slippage check)
max_slippage_bps: u16,
) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
let listing = &ctx.accounts.listing_account;
// Validate resolver
if !listing.accepted_resolvers.is_empty() {
require!(
listing.accepted_resolvers.contains(&resolver),
SolistingError::ResolverNotAccepted
);
}
// Determine amount in payment_currency
let amount = if payment_currency == listing.canonical_currency {
// Paying in canonical currency — no oracle needed
listing.price
} else {
// Must be a configured alt_currency
let alt_cfg = listing.alt_currencies
.iter()
.find(|c| c.currency == payment_currency)
.ok_or(error!(SolistingError::CurrencyNotAccepted))?;
// Validate passed oracle accounts against listing state
if let Some(expected_canonical) = listing.canonical_oracle {
require!(
ctx.accounts.canonical_oracle.key() == expected_canonical,
SolistingError::OracleMismatch
);
}
if let Some(expected_target) = alt_cfg.usd_oracle {
require!(
ctx.accounts.target_oracle.key() == expected_target,
SolistingError::OracleMismatch
);
}
let c_oracle = listing.canonical_oracle.map(|_| ctx.accounts.canonical_oracle.as_ref());
let t_oracle = alt_cfg.usd_oracle.map(|_| ctx.accounts.target_oracle.as_ref());
oracle::convert_with_slippage(
c_oracle,
&listing.canonical_currency,
listing.price,
t_oracle,
&payment_currency,
expected_amount,
max_slippage_bps,
)?
};
// Only Sol payment supported now (descro_spl does not exist yet)
require!(
payment_currency == Currency::Sol,
SolistingError::SplNotImplemented
);
// CPI: descro.buyer_create_escrow — creates EscrowAccount + funds vault atomically
descro::cpi::buyer_create_escrow(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::BuyerCreateEscrow {
buyer: ctx.accounts.buyer.to_account_info(),
seller: ctx.accounts.seller.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
vault: ctx.accounts.descro_vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
amount,
Some(resolver),
escrow_id,
)?;
ctx.accounts.listing_account.quantity_reserved += 1;
let order = &mut ctx.accounts.order_account;
order.listing = ctx.accounts.listing_account.key();
order.buyer = ctx.accounts.buyer.key();
order.seller = ctx.accounts.seller.key();
order.resolver = resolver;
order.payment_currency = payment_currency;
order.amount = amount;
order.escrow_account = ctx.accounts.escrow_account.key();
order.escrow_id = escrow_id;
order.order_id = order_id;
order.created_at = Clock::get()?.unix_timestamp;
order.bump = ctx.bumps.order_account;
Ok(())
}
escrow_idderivation note: Solisting passesescrow_idas an argument. The SDK derives it asu64::from_le_bytes(order_pda.to_bytes()[0..8]). This ties the escrow_id to the order PDA, ensuring uniqueness as long asorder_idis unique per buyer+listing.
- Step 4: Add order helpers to
tests/common/mod.rs
Add order_pda(), read_order(), ix_create_order(), ix_create_order_with_resolver(). Mirror the pattern from listing helpers: PDA derivation, borsh-encode instruction data, build CompiledInstruction.
pub fn order_pda(listing: Pubkey, buyer: Pubkey, order_id: u64) -> Pubkey {
Pubkey::find_program_address(
&[b"order", listing.as_ref(), buyer.as_ref(), &order_id.to_le_bytes()],
&solisting::id(),
).0
}
pub fn read_order(svm: &LiteSVM, listing: Pubkey, buyer: Pubkey, order_id: u64) -> solisting::state::OrderAccount {
let pda = order_pda(listing, buyer, order_id);
let account = svm.get_account(&pda).unwrap();
solisting::state::OrderAccount::try_deserialize(&mut account.data.as_slice()).unwrap()
}
/// `canonical_oracle` / `target_oracle`: pass None to use SystemProgram as placeholder
/// (i.e. when paying in canonical currency or when the oracle is a stablecoin None).
pub fn ix_create_order(
buyer: &Pubkey,
seller: &Pubkey,
listing_id: u64,
order_id: u64,
payment_currency: Currency,
max_slippage_bps: u16,
canonical_oracle: Option<Pubkey>,
target_oracle: Option<Pubkey>,
) -> solana_message::compiled_instruction::CompiledInstruction {
let listing = listing_pda(seller, listing_id);
let order = order_pda(listing, *buyer, order_id);
let escrow_id = u64::from_le_bytes(order.to_bytes()[0..8].try_into().unwrap());
let resolver = Pubkey::new_unique(); // default resolver for simple tests
ix_create_order_with_resolver(buyer, seller, listing_id, order_id, payment_currency, max_slippage_bps, canonical_oracle, target_oracle, resolver)
}
pub fn ix_create_order_with_resolver(
buyer: &Pubkey,
seller: &Pubkey,
listing_id: u64,
order_id: u64,
payment_currency: Currency,
max_slippage_bps: u16,
canonical_oracle: Option<Pubkey>,
target_oracle: Option<Pubkey>,
resolver: Pubkey,
) -> solana_message::compiled_instruction::CompiledInstruction {
let listing = listing_pda(seller, listing_id);
let order = order_pda(listing, *buyer, order_id);
let escrow_id = u64::from_le_bytes(order.to_bytes()[0..8].try_into().unwrap());
let (escrow_account, _) = Pubkey::find_program_address(
&[b"escrow", seller.as_ref(), &escrow_id.to_le_bytes()],
&descro::id(),
);
let (descro_vault, _) = Pubkey::find_program_address(
&[b"vault", escrow_account.as_ref()],
&descro::id(),
);
let canonical_oracle_key = canonical_oracle.unwrap_or(solana_sdk::system_program::id());
let target_oracle_key = target_oracle.unwrap_or(solana_sdk::system_program::id());
let expected_amount = 0u64; // tests that don't use oracle set this to 0
todo!("build CompiledInstruction: discriminator + borsh encode (order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps), accounts: [..., canonical_oracle_key, target_oracle_key, ...]")
}
- Step 5: Run tests
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders -- buyer_can_create_order_canonical_sol create_order_fails 2>&1 | tail -20
Expected: all create_order_* tests pass.
- Step 6: Commit
git add programs/solisting/
git commit -m "feat(solisting): implement create_order with oracle-driven currency conversion"
Task 5: accept_order — CPI seller_confirm
The seller accepts: CPIs descro.seller_confirm to move the escrow to Active, updates quantity, closes the OrderAccount.
File: programs/solisting/src/instructions/accept_order.rs
- Step 1: Write failing test
#[test]
fn seller_accept_creates_active_descro_escrow() {
let (mut svm, seller, buyer) = setup();
let listing_id = 10u64;
let price = 100_000_000u64;
send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())], &[&seller]);
let order_id = 1u64;
send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, order_id, Currency::Sol, 0, None, None)], &[&buyer]);
let listing_key = listing_pda(&seller.pubkey(), listing_id);
let order = read_order(&svm, listing_key, buyer.pubkey(), order_id);
send(&mut svm, &[ix_accept_order(&seller.pubkey(), listing_id, buyer.pubkey(), order_id, order.escrow_id, order.resolver)], &[&seller]);
// OrderAccount must be closed
let order_key = order_pda(listing_key, buyer.pubkey(), order_id);
assert!(svm.get_account(&order_key).is_none());
// quantity reduced, reservation cleared
let listing = read_listing(&svm, &seller.pubkey(), listing_id);
assert_eq!(listing.quantity_reserved, 0);
assert_eq!(listing.quantity, 4); // 5 - 1
}
- Step 2: Implement
accept_order.rs
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, OrderAccount};
use crate::error::SolistingError;
#[derive(Accounts)]
pub struct AcceptOrder<'info> {
#[account(mut)]
pub seller: Signer<'info>,
/// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated (descro enforces this)
pub resolver: UncheckedAccount<'info>,
#[account(
mut,
seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
bump = listing_account.bump,
constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
)]
pub listing_account: Account<'info, ListingAccount>,
#[account(
mut,
seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
bump = order_account.bump,
constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized,
close = seller,
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: Descro EscrowAccount — state checked by descro.seller_confirm
#[account(
mut,
seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()],
bump,
seeds::program = descro::id(),
constraint = escrow_account.key() == order_account.escrow_account @ SolistingError::EscrowStateUnexpected,
)]
pub escrow_account: UncheckedAccount<'info>,
/// CHECK: Optional resolver registry entry — passed through to descro.seller_confirm
pub resolver_entry: UncheckedAccount<'info>,
/// CHECK: Descro program
pub descro_program: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(ctx: Context<AcceptOrder>) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
descro::cpi::seller_confirm(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::SellerConfirm {
seller: ctx.accounts.seller.to_account_info(),
resolver: ctx.accounts.resolver.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
resolver_entry: ctx.accounts.resolver_entry.to_account_info(),
},
),
)?;
let listing = &mut ctx.accounts.listing_account;
listing.quantity_reserved = listing.quantity_reserved.saturating_sub(1);
listing.quantity = listing.quantity.saturating_sub(1);
Ok(())
}
- Step 3: Run test
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders -- seller_accept 2>&1 | tail -10
Expected: PASS.
- Step 4: Commit
git add programs/solisting/src/instructions/accept_order.rs programs/solisting/tests/
git commit -m "feat(solisting): implement accept_order"
Task 6: reject_order and cancel_order — Defensive CPI to descro.cancel
Both instructions read descro escrow state before cancelling. If the escrow is already resolved (buyer cancelled directly on descro), the CPI is skipped and the OrderAccount is still closed.
Files: reject_order.rs, cancel_order.rs
- Step 1: Write failing tests
#[test]
fn seller_can_reject_order() {
let (mut svm, seller, buyer) = setup();
let listing_id = 20u64;
let price = 100_000_000u64;
send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, price, None, vec![], vec![], 5, "".to_string())], &[&seller]);
send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);
let buyer_before = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
let listing_key = listing_pda(&seller.pubkey(), listing_id);
let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
send(&mut svm, &[ix_reject_order(&seller.pubkey(), listing_id, buyer.pubkey(), 1, order.escrow_id)], &[&seller]);
let buyer_after = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
assert!(buyer_after >= buyer_before + price - 10_000); // roughly refunded
assert_eq!(read_listing(&svm, &seller.pubkey(), listing_id).quantity_reserved, 0);
assert!(svm.get_account(&order_pda(listing_key, buyer.pubkey(), 1)).is_none());
}
#[test]
fn buyer_can_cancel_order() {
let (mut svm, seller, buyer) = setup();
let listing_id = 21u64;
send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);
let listing_key = listing_pda(&seller.pubkey(), listing_id);
let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
send(&mut svm, &[ix_cancel_order(&buyer.pubkey(), listing_id, &seller.pubkey(), 1, order.escrow_id)], &[&buyer]);
assert_eq!(read_listing(&svm, &seller.pubkey(), listing_id).quantity_reserved, 0);
}
#[test]
fn reject_handles_already_cancelled_escrow() {
// Buyer cancels directly on descro, then seller calls reject_order — must not panic
let (mut svm, seller, buyer) = setup();
let listing_id = 22u64;
send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);
let listing_key = listing_pda(&seller.pubkey(), listing_id);
let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
// Buyer cancels directly on descro (bypassing solisting)
send(&mut svm, &[ix_descro_cancel(&buyer.pubkey(), &seller.pubkey(), order.escrow_id)], &[&buyer]);
// Seller calls solisting reject — should succeed without CPI error
send(&mut svm, &[ix_reject_order(&seller.pubkey(), listing_id, &buyer.pubkey(), 1, order.escrow_id)], &[&seller]);
}
- Step 2: Implement
reject_order.rs
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, OrderAccount};
use crate::error::SolistingError;
#[derive(Accounts)]
pub struct RejectOrder<'info> {
#[account(mut)]
pub seller: Signer<'info>,
/// CHECK: Buyer receives vault refund from descro.cancel
#[account(mut)]
pub buyer: UncheckedAccount<'info>,
#[account(
mut,
seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
bump = listing_account.bump,
constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized,
)]
pub listing_account: Account<'info, ListingAccount>,
#[account(
mut,
seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
bump = order_account.bump,
constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized,
constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized,
close = seller,
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: Descro EscrowAccount — read to check current state before CPI
#[account(
mut,
seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()],
bump,
seeds::program = descro::id(),
)]
pub escrow_account: UncheckedAccount<'info>,
/// CHECK: Descro vault — drained to buyer by descro.cancel
#[account(
mut,
seeds = [b"vault", escrow_account.key().as_ref()],
bump,
seeds::program = descro::id(),
)]
pub vault: UncheckedAccount<'info>,
/// CHECK: Descro program
pub descro_program: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(ctx: Context<RejectOrder>) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
// Defensive: skip CPI if escrow already resolved (buyer cancelled directly on descro)
if !ctx.accounts.escrow_account.data_is_empty() {
let data = ctx.accounts.escrow_account.try_borrow_data()?;
if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
if escrow.state == descro::EscrowState::AwaitingSellerConfirm {
drop(data);
descro::cpi::cancel(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::Cancel {
canceller: ctx.accounts.seller.to_account_info(),
buyer: ctx.accounts.buyer.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
)?;
}
}
}
ctx.accounts.listing_account.quantity_reserved =
ctx.accounts.listing_account.quantity_reserved.saturating_sub(1);
Ok(())
}
- Step 3: Implement
cancel_order.rs
Identical to reject_order.rs with three differences: buyer: Signer instead of seller: Signer, constraints check buyer == order.buyer, and CPI canceller = buyer, close = buyer.
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, OrderAccount};
use crate::error::SolistingError;
#[derive(Accounts)]
pub struct CancelOrder<'info> {
#[account(mut)]
pub buyer: Signer<'info>,
#[account(
mut,
seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()],
bump = listing_account.bump,
)]
pub listing_account: Account<'info, ListingAccount>,
#[account(
mut,
seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
bump = order_account.bump,
constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized,
close = buyer,
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: Descro EscrowAccount — read defensively
#[account(
mut,
seeds = [b"escrow", order_account.seller.as_ref(), &order_account.escrow_id.to_le_bytes()],
bump,
seeds::program = descro::id(),
)]
pub escrow_account: UncheckedAccount<'info>,
/// CHECK: Descro vault
#[account(
mut,
seeds = [b"vault", escrow_account.key().as_ref()],
bump,
seeds::program = descro::id(),
)]
pub vault: UncheckedAccount<'info>,
/// CHECK: Descro program
pub descro_program: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(ctx: Context<CancelOrder>) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::InvalidDescroProgram
);
if !ctx.accounts.escrow_account.data_is_empty() {
let data = ctx.accounts.escrow_account.try_borrow_data()?;
if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
if escrow.state == descro::EscrowState::AwaitingSellerConfirm {
drop(data);
descro::cpi::cancel(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::Cancel {
canceller: ctx.accounts.buyer.to_account_info(),
buyer: ctx.accounts.buyer.to_account_info(),
escrow_account: ctx.accounts.escrow_account.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
)?;
}
}
}
ctx.accounts.listing_account.quantity_reserved =
ctx.accounts.listing_account.quantity_reserved.saturating_sub(1);
Ok(())
}
- Step 4: Run all order tests
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders 2>&1 | tail -15
Expected: all pass.
- Step 5: Commit
git add programs/solisting/src/instructions/reject_order.rs programs/solisting/src/instructions/cancel_order.rs programs/solisting/tests/
git commit -m "feat(solisting): implement reject_order and cancel_order with defensive escrow check"
Task 7: close_stale_order — Cleanup Orphaned Orders
Anyone can call this to close an OrderAccount whose descro escrow is in a terminal state. Rent goes to caller as incentive.
File: programs/solisting/src/instructions/close_stale_order.rs
- Step 1: Write failing test
#[test]
fn anyone_can_close_stale_order_after_terminal_escrow() {
let (mut svm, seller, buyer) = setup();
let listing_id = 30u64;
send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);
let listing_key = listing_pda(&seller.pubkey(), listing_id);
let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
// Buyer cancels on descro directly → escrow is Cancelled
send(&mut svm, &[ix_descro_cancel(&buyer.pubkey(), &seller.pubkey(), order.escrow_id)], &[&buyer]);
// A third party can now close the stale OrderAccount
let janitor = Keypair::new();
svm.airdrop(&janitor.pubkey(), 1_000_000).unwrap();
let janitor_before = svm.get_account(&janitor.pubkey()).unwrap().lamports;
send(&mut svm, &[ix_close_stale_order(&janitor.pubkey(), listing_key, buyer.pubkey(), 1, order.escrow_account)], &[&janitor]);
assert!(svm.get_account(&order_pda(listing_key, buyer.pubkey(), 1)).is_none());
let janitor_after = svm.get_account(&janitor.pubkey()).unwrap().lamports;
assert!(janitor_after > janitor_before); // received rent
}
#[test]
fn close_stale_order_fails_if_escrow_still_active() {
let (mut svm, seller, buyer) = setup();
let listing_id = 31u64;
send(&mut svm, &[ix_create_listing(&seller.pubkey(), listing_id, Currency::Sol, 100_000_000, None, vec![], vec![], 5, "".to_string())], &[&seller]);
send(&mut svm, &[ix_create_order(&buyer.pubkey(), &seller.pubkey(), listing_id, 1, Currency::Sol, 0, None, None)], &[&buyer]);
let listing_key = listing_pda(&seller.pubkey(), listing_id);
let order = read_order(&svm, listing_key, buyer.pubkey(), 1);
// Don't cancel — escrow is still AwaitingSellerConfirm
let janitor = Keypair::new();
svm.airdrop(&janitor.pubkey(), 1_000_000).unwrap();
let result = try_send(&mut svm, &[ix_close_stale_order(&janitor.pubkey(), listing_key, buyer.pubkey(), 1, order.escrow_account)], &[&janitor]);
assert!(result.is_err());
}
- Step 2: Implement
close_stale_order.rs
use anchor_lang::prelude::*;
use crate::state::OrderAccount;
use crate::error::SolistingError;
#[derive(Accounts)]
pub struct CloseStaleOrder<'info> {
#[account(mut)]
pub caller: Signer<'info>,
#[account(
mut,
seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()],
bump = order_account.bump,
close = caller,
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: Descro EscrowAccount — must be in terminal state or closed
pub escrow_account: UncheckedAccount<'info>,
}
pub fn handler(ctx: Context<CloseStaleOrder>) -> Result<()> {
require!(
ctx.accounts.escrow_account.key() == ctx.accounts.order_account.escrow_account,
SolistingError::EscrowStateUnexpected
);
// Closed (zero data) escrow = already resolved = ok to clean up
if !ctx.accounts.escrow_account.data_is_empty() {
let data = ctx.accounts.escrow_account.try_borrow_data()?;
if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) {
require!(
escrow.state == descro::EscrowState::Cancelled
|| escrow.state == descro::EscrowState::Complete,
SolistingError::EscrowStateUnexpected
);
}
}
Ok(())
}
- Step 3: Run tests
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders -- close_stale 2>&1 | tail -10
Expected: PASS.
- Step 4: Commit
git add programs/solisting/src/instructions/close_stale_order.rs programs/solisting/tests/
git commit -m "feat(solisting): implement close_stale_order for orphaned order cleanup"
Task 8: Wire Up lib.rs and instructions.rs, Full Build + Test
- Step 1: Complete
instructions.rs
#![allow(ambiguous_glob_reexports)]
pub mod accept_order;
pub mod cancel_order;
pub mod close_listing;
pub mod close_stale_order;
pub mod create_listing;
pub mod create_order;
pub mod reject_order;
pub mod update_listing;
pub use accept_order::*;
pub use cancel_order::*;
pub use close_listing::*;
pub use close_stale_order::*;
pub use create_listing::*;
pub use create_order::*;
pub use reject_order::*;
pub use update_listing::*;
- Step 2: Complete
lib.rs
pub mod error;
pub mod instructions;
pub mod oracle;
pub mod state;
use anchor_lang::prelude::*;
pub use error::*;
pub use instructions::*;
pub use state::*;
declare_id!("So1istingProgramID11111111111111111111111111"); // replace with: solana-keygen grind or anchor keys list
#[program]
pub mod solisting {
use super::*;
pub fn create_listing(ctx: Context<CreateListing>, listing_id: u64, canonical_currency: state::Currency, price: u64, canonical_oracle: Option<Pubkey>, alt_currencies: Vec<state::AltCurrencyConfig>, accepted_resolvers: Vec<Pubkey>, quantity: u32, metadata_uri: String) -> Result<()> {
create_listing::handler(ctx, listing_id, canonical_currency, price, canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri)
}
pub fn update_listing(ctx: Context<UpdateListing>, canonical_currency: state::Currency, price: u64, canonical_oracle: Option<Pubkey>, alt_currencies: Vec<state::AltCurrencyConfig>, accepted_resolvers: Vec<Pubkey>, quantity: u32, metadata_uri: String) -> Result<()> {
update_listing::handler(ctx, canonical_currency, price, canonical_oracle, alt_currencies, accepted_resolvers, quantity, metadata_uri)
}
pub fn close_listing(ctx: Context<CloseListing>) -> Result<()> {
close_listing::handler(ctx)
}
pub fn create_order(ctx: Context<CreateOrder>, order_id: u64, escrow_id: u64, resolver: Pubkey, payment_currency: state::Currency, expected_amount: u64, max_slippage_bps: u16) -> Result<()> {
// canonical_oracle and target_oracle come from the account struct, not args
create_order::handler(ctx, order_id, escrow_id, resolver, payment_currency, expected_amount, max_slippage_bps)
}
pub fn accept_order(ctx: Context<AcceptOrder>) -> Result<()> {
accept_order::handler(ctx)
}
pub fn reject_order(ctx: Context<RejectOrder>) -> Result<()> {
reject_order::handler(ctx)
}
pub fn cancel_order(ctx: Context<CancelOrder>) -> Result<()> {
cancel_order::handler(ctx)
}
pub fn close_stale_order(ctx: Context<CloseStaleOrder>) -> Result<()> {
close_stale_order::handler(ctx)
}
}
- Step 3: Build all three programs
cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \
cargo build-sbf --manifest-path programs/descro/Cargo.toml && \
cargo build-sbf --manifest-path programs/solisting/Cargo.toml
Expected: all three compile to .so without errors.
- Step 4: Run full test suite
cargo test --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -5
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5
cargo test --manifest-path programs/solisting/Cargo.toml 2>&1 | tail -5
Expected: all green.
- Step 5: Final commit
git add programs/solisting/ Cargo.toml Cargo.lock
git commit -m "feat(solisting): complete listing + order flow with canonical price model and oracle conversion"
Done
Full buyer-to-escrow flow across three programs:
solisting= coordination and discovery; single canonical price per listing; oracle-based alt-currency conversion at order time; never holds vault fundsdescro= sole custodian of SOL vault; enforces AcceptancePolicydescro_ext_resolvers= resolver registry; read by descro at seller_confirm time
Remaining items for future phases:
- Implement
descro_splprogram → removeSplNotImplementedguard increate_order - Oracle LiteSVM mock: build a mock Pyth account data layout for
test_orders.rsoracle path tests - Event emission (
emit!()) for off-chain indexers - Guard against closing listing while pending orders exist (
quantity_reserved > 0) close_stale_ordershould also restorequantity_reservedon the listing (requires listing account to be passed in)