1826 lines
67 KiB
Markdown
1826 lines
67 KiB
Markdown
# 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)
|