1005 lines
33 KiB
Markdown
1005 lines
33 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 from the original solisting plan (adding `AcceptancePolicy` enum to `descro_ext_resolvers`) — already documented in the old plan's prerequisite section
|
|
|
|
**Goal:** Build the `solisting` Anchor program — a coordination and discovery layer for product listings and bilateral order consent. It orchestrates the full flow from buyer intent → bilateral consent → active `descro` escrow, without ever touching the descro vault directly.
|
|
|
|
**Architecture:**
|
|
- `solisting` holds two account types: `ListingAccount` (seller's offer with payment options) and `OrderAccount` (pending bilateral consent record).
|
|
- Payment method is negotiated per-order: the seller declares supported options in the listing (`DescroPaymentOption` with accepted resolvers), the buyer picks one when creating an order.
|
|
- For Descro orders: `create_order` CPIs `descro.buyer_create_escrow` atomically — funds go directly into descro's vault. `accept_order` CPIs `descro.seller_confirm`. Solisting never holds or transfers escrow SOL.
|
|
- The `OrderAccount` has no state enum. Its existence means the order is pending. Closure means it is resolved.
|
|
- State drift (buyer cancelling directly on descro) is handled gracefully: all terminal solisting instructions read the descro escrow state first and skip the CPI if descro already resolved it.
|
|
|
|
**Key design decisions:**
|
|
- No `OrderVault` — funds live in descro's vault from the moment `create_order` is called
|
|
- `AcceptancePolicy` is enforced by descro in `seller_confirm`, not by solisting
|
|
- Listing `quantity_reserved` tracks pending orders; decremented at `create_order`, restored at `reject_order`/`cancel_order`
|
|
- `escrow_id` is derived from the first 8 bytes of the `order_account` PDA to guarantee uniqueness without coordinator state
|
|
- `close_stale_order` can be called by anyone to clean up an OrderAccount whose descro escrow is already in a terminal state
|
|
|
|
**Tech Stack:** Rust, Anchor 1.0.x, LiteSVM 0.10.0. Depends on `descro` (CPI features) and `descro_ext_resolvers` (for `AcceptancePolicy` type). Build order: `descro_ext_resolvers` → `descro` → `solisting`.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| Action | Path | Responsibility |
|
|
|---|---|---|
|
|
| Create | `programs/solisting/Cargo.toml` | Crate definition + dependencies |
|
|
| Create | `programs/solisting/src/lib.rs` | Program entrypoints + declare_id! |
|
|
| Create | `programs/solisting/src/state.rs` | ListingAccount + OrderAccount + PaymentOption |
|
|
| Create | `programs/solisting/src/error.rs` | SolistingError enum |
|
|
| Create | `programs/solisting/src/instructions.rs` | Module re-exports |
|
|
| Create | `programs/solisting/src/instructions/create_listing.rs` | |
|
|
| Create | `programs/solisting/src/instructions/update_listing.rs` | |
|
|
| Create | `programs/solisting/src/instructions/close_listing.rs` | |
|
|
| Create | `programs/solisting/src/instructions/create_order.rs` | Core: validates payment option, CPIs buyer_create_escrow |
|
|
| Create | `programs/solisting/src/instructions/accept_order.rs` | CPIs seller_confirm |
|
|
| Create | `programs/solisting/src/instructions/reject_order.rs` | CPIs descro.cancel (defensive) |
|
|
| Create | `programs/solisting/src/instructions/cancel_order.rs` | CPIs descro.cancel (defensive) |
|
|
| Create | `programs/solisting/src/instructions/close_stale_order.rs` | Cleans up orphaned OrderAccounts |
|
|
| Create | `programs/solisting/tests/common/mod.rs` | LiteSVM setup + helpers |
|
|
| Create | `programs/solisting/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" }
|
|
|
|
[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/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 and Errors
|
|
|
|
**Files:** `state.rs`, `error.rs`
|
|
|
|
- [ ] **Step 1: Write `state.rs`**
|
|
|
|
```rust
|
|
use anchor_lang::prelude::*;
|
|
|
|
#[account]
|
|
#[derive(InitSpace)]
|
|
pub struct ListingAccount {
|
|
pub seller: Pubkey,
|
|
pub price: u64,
|
|
/// Total units available (including reserved).
|
|
pub quantity: u32,
|
|
/// Units held by pending (AwaitingSellerAccept) orders. quantity - quantity_reserved = available.
|
|
pub quantity_reserved: u32,
|
|
/// Descro payment option; None means descro is not accepted for this listing.
|
|
pub descro_option: Option<DescroPaymentOption>,
|
|
#[max_len(256)]
|
|
pub metadata_uri: String,
|
|
pub listing_id: u64,
|
|
pub is_active: bool,
|
|
pub bump: u8,
|
|
}
|
|
|
|
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)]
|
|
pub struct DescroPaymentOption {
|
|
/// Resolvers the seller accepts. Empty = any resolver is acceptable.
|
|
#[max_len(4)]
|
|
pub accepted_resolvers: Vec<Pubkey>,
|
|
}
|
|
|
|
#[account]
|
|
#[derive(InitSpace)]
|
|
pub struct OrderAccount {
|
|
pub listing: Pubkey,
|
|
pub buyer: Pubkey,
|
|
pub seller: Pubkey,
|
|
/// The resolver agreed upon for this order (validated against listing's accepted_resolvers).
|
|
pub resolver: Pubkey,
|
|
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,
|
|
}
|
|
```
|
|
|
|
> **`escrow_id` derivation note:** At `create_order` time, the `order_account` PDA has been derived but not yet initialized. Solisting passes the `escrow_id` as an instruction argument. The frontend/SDK derives it as `u64::from_le_bytes(order_pda.to_bytes()[0..8])`. This ties the escrow_id to the order PDA, ensuring uniqueness as long as `order_id` is unique per buyer+listing.
|
|
|
|
- [ ] **Step 2: Write `error.rs`**
|
|
|
|
```rust
|
|
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("Listing does not support Descro payments")]
|
|
DescroNotSupported,
|
|
#[msg("Resolver is not accepted by this listing")]
|
|
ResolverNotAccepted,
|
|
#[msg("Descro escrow is not in expected state — order may have already been resolved")]
|
|
EscrowStateUnexpected,
|
|
#[msg("Descro program address mismatch")]
|
|
InvalidDescroProgram,
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Listing Instructions
|
|
|
|
**Files:** `create_listing.rs`, `update_listing.rs`, `close_listing.rs`
|
|
|
|
- [ ] **Step 1: Write `create_listing.rs`**
|
|
|
|
```rust
|
|
use anchor_lang::prelude::*;
|
|
use crate::state::{DescroPaymentOption, ListingAccount};
|
|
|
|
#[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,
|
|
price: u64,
|
|
quantity: u32,
|
|
descro_option: Option<DescroPaymentOption>,
|
|
metadata_uri: String,
|
|
) -> Result<()> {
|
|
let listing = &mut ctx.accounts.listing_account;
|
|
listing.seller = ctx.accounts.seller.key();
|
|
listing.price = price;
|
|
listing.quantity = quantity;
|
|
listing.quantity_reserved = 0;
|
|
listing.descro_option = descro_option;
|
|
listing.metadata_uri = metadata_uri;
|
|
listing.listing_id = listing_id;
|
|
listing.is_active = true;
|
|
listing.bump = ctx.bumps.listing_account;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `update_listing.rs`**
|
|
|
|
```rust
|
|
use anchor_lang::prelude::*;
|
|
use crate::state::{DescroPaymentOption, 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>,
|
|
price: u64,
|
|
quantity: u32,
|
|
descro_option: Option<DescroPaymentOption>,
|
|
metadata_uri: String,
|
|
) -> Result<()> {
|
|
let listing = &mut ctx.accounts.listing_account;
|
|
listing.price = price;
|
|
listing.quantity = quantity;
|
|
listing.descro_option = descro_option;
|
|
listing.metadata_uri = metadata_uri;
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 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(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: `create_order` — Validate + CPI buyer_create_escrow
|
|
|
|
This is the buyer's single transaction: it validates the order, reserves quantity on the listing, CPIs `descro.buyer_create_escrow` to atomically create and fund the escrow, and records the order.
|
|
|
|
**File:** `programs/solisting/src/instructions/create_order.rs`
|
|
|
|
- [ ] **Step 1: Write failing test** (in `tests/test_orders.rs`)
|
|
|
|
```rust
|
|
#[test]
|
|
fn buyer_can_create_order_descro() {
|
|
// setup listing with DescroPaymentOption (any resolver)
|
|
// buyer calls create_order with resolver keypair
|
|
// Assert: OrderAccount exists with correct fields
|
|
// Assert: descro EscrowAccount exists, state == AwaitingSellerConfirm
|
|
// Assert: descro vault has listing.price lamports
|
|
// Assert: listing.quantity_reserved == 1
|
|
}
|
|
|
|
#[test]
|
|
fn create_order_fails_if_listing_inactive() { }
|
|
|
|
#[test]
|
|
fn create_order_fails_if_out_of_stock() { }
|
|
|
|
#[test]
|
|
fn create_order_fails_if_resolver_not_accepted() {
|
|
// listing has specific accepted_resolvers = [resolver_a]
|
|
// buyer tries with resolver_b → must fail
|
|
}
|
|
|
|
#[test]
|
|
fn create_order_fails_if_descro_not_supported() {
|
|
// listing has descro_option = None
|
|
// buyer tries to create descro order → must fail
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement `create_order.rs`**
|
|
|
|
```rust
|
|
use anchor_lang::prelude::*;
|
|
use crate::state::{ListingAccount, OrderAccount};
|
|
use crate::error::SolistingError;
|
|
|
|
#[derive(Accounts)]
|
|
#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey)]
|
|
pub struct CreateOrder<'info> {
|
|
#[account(mut)]
|
|
pub buyer: Signer<'info>,
|
|
|
|
/// CHECK: Seller — verified against listing
|
|
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: 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,
|
|
) -> Result<()> {
|
|
require!(
|
|
ctx.accounts.descro_program.key() == descro::id(),
|
|
SolistingError::InvalidDescroProgram
|
|
);
|
|
|
|
let listing = &ctx.accounts.listing_account;
|
|
|
|
// Validate descro payment option is supported
|
|
let descro_opt = listing.descro_option.as_ref()
|
|
.ok_or(SolistingError::DescroNotSupported)?;
|
|
|
|
// Validate resolver is accepted (empty list = any resolver ok)
|
|
if !descro_opt.accepted_resolvers.is_empty() {
|
|
require!(
|
|
descro_opt.accepted_resolvers.contains(&resolver),
|
|
SolistingError::ResolverNotAccepted
|
|
);
|
|
}
|
|
|
|
let amount = listing.price;
|
|
|
|
// 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,
|
|
)?;
|
|
|
|
// Reserve quantity
|
|
ctx.accounts.listing_account.quantity_reserved += 1;
|
|
|
|
// Record order
|
|
let order = &mut ctx.accounts.order_account;
|
|
order.listing = ctx.accounts.listing_account.key();
|
|
order.buyer = ctx.accounts.buyer.key();
|
|
order.seller = ctx.accounts.seller.key();
|
|
order.resolver = resolver;
|
|
order.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(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `accept_order` — CPI seller_confirm
|
|
|
|
The seller accepts: CPIs `descro.seller_confirm` to move the escrow to `Active`, then 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() {
|
|
// create_order → AwaitingSellerConfirm
|
|
// seller calls accept_order
|
|
// Assert: descro escrow state == Active
|
|
// Assert: OrderAccount is closed
|
|
// Assert: listing.quantity_reserved == 0
|
|
// Assert: listing.quantity == original - 1
|
|
}
|
|
```
|
|
|
|
- [ ] **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: AccountInfo<'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 — verified via seeds, 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 for AcceptancePolicy check
|
|
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
|
|
);
|
|
|
|
// CPI: descro.seller_confirm — descro checks AcceptancePolicy internally
|
|
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(),
|
|
},
|
|
),
|
|
)?;
|
|
|
|
// Commit quantity: reserved → sold
|
|
let listing = &mut ctx.accounts.listing_account;
|
|
listing.quantity_reserved = listing.quantity_reserved.saturating_sub(1);
|
|
listing.quantity = listing.quantity.saturating_sub(1);
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: `reject_order` and `cancel_order` — Defensive CPI to descro.cancel
|
|
|
|
Both instructions read the descro escrow state before attempting the cancel CPI. If descro already resolved the escrow (buyer cancelled directly), the CPI is skipped and the `OrderAccount` is still closed. This prevents stuck accounts and failed transactions.
|
|
|
|
**Files:** `reject_order.rs`, `cancel_order.rs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```rust
|
|
#[test]
|
|
fn seller_can_reject_order() {
|
|
// create_order → AwaitingSellerConfirm
|
|
// seller calls reject_order
|
|
// Assert: OrderAccount closed
|
|
// Assert: buyer received SOL back (descro vault drained)
|
|
// Assert: listing.quantity_reserved == 0
|
|
}
|
|
|
|
#[test]
|
|
fn buyer_can_cancel_order_anytime() {
|
|
// create_order → AwaitingSellerConfirm
|
|
// buyer calls cancel_order immediately (no timeout)
|
|
// Assert: same as reject — funds returned, order closed
|
|
}
|
|
|
|
#[test]
|
|
fn reject_handles_already_cancelled_escrow() {
|
|
// buyer calls descro.cancel directly (bypassing solisting)
|
|
// seller calls solisting.reject_order
|
|
// Assert: no panic, OrderAccount is still closed cleanly
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_handles_already_cancelled_escrow() {
|
|
// buyer calls descro.cancel directly
|
|
// buyer calls solisting.cancel_order
|
|
// Assert: OrderAccount closed, no CPI error
|
|
}
|
|
```
|
|
|
|
- [ ] **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 the 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
|
|
#[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: only CPI if escrow is still in AwaitingSellerConfirm
|
|
// If buyer already cancelled directly, skip CPI — just close the order
|
|
if !ctx.accounts.escrow_account.data_is_empty() {
|
|
let data = ctx.accounts.escrow_account.try_borrow_data()?;
|
|
let escrow = descro::EscrowAccount::try_deserialize(&mut data.as_ref());
|
|
if let Ok(e) = escrow {
|
|
if e.state == descro::EscrowState::AwaitingSellerConfirm {
|
|
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(),
|
|
},
|
|
),
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore reserved quantity
|
|
ctx.accounts.listing_account.quantity_reserved =
|
|
ctx.accounts.listing_account.quantity_reserved.saturating_sub(1);
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `cancel_order.rs`**
|
|
|
|
Identical to `reject_order.rs` except:
|
|
- `buyer: Signer<'info>` instead of `seller: Signer<'info>`
|
|
- Constraint checks buyer == order.buyer
|
|
- CPI `canceller` = buyer
|
|
- `close = buyer`
|
|
- No timeout — buyer can cancel anytime pre-Active
|
|
|
|
```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()?;
|
|
let escrow = descro::EscrowAccount::try_deserialize(&mut data.as_ref());
|
|
if let Ok(e) = escrow {
|
|
if e.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(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: `close_stale_order` — Cleanup Orphaned Orders
|
|
|
|
Anyone can call this to close an `OrderAccount` whose descro escrow is in a terminal state (`Cancelled` or `Complete`). Rent goes to the caller as incentive.
|
|
|
|
**File:** `programs/solisting/src/instructions/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
|
|
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
|
|
);
|
|
|
|
// Only allow close if escrow is terminal or gone (already closed)
|
|
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(())
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Wire Up `lib.rs` and `instructions.rs`, Build, Test
|
|
|
|
- [ ] **Step 1: Write `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: Write `lib.rs`**
|
|
|
|
```rust
|
|
pub mod error;
|
|
pub mod instructions;
|
|
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,
|
|
price: u64,
|
|
quantity: u32,
|
|
descro_option: Option<state::DescroPaymentOption>,
|
|
metadata_uri: String,
|
|
) -> Result<()> {
|
|
create_listing::handler(ctx, listing_id, price, quantity, descro_option, metadata_uri)
|
|
}
|
|
|
|
pub fn update_listing(
|
|
ctx: Context<UpdateListing>,
|
|
price: u64,
|
|
quantity: u32,
|
|
descro_option: Option<state::DescroPaymentOption>,
|
|
metadata_uri: String,
|
|
) -> Result<()> {
|
|
update_listing::handler(ctx, price, quantity, descro_option, 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,
|
|
) -> Result<()> {
|
|
create_order::handler(ctx, order_id, escrow_id, resolver)
|
|
}
|
|
|
|
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
|
|
```
|
|
|
|
- [ ] **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): implement listing + order flow with buyer-initiated descro escrow"
|
|
```
|
|
|
|
---
|
|
|
|
## Done
|
|
|
|
Full buyer-to-escrow flow across three programs with clean separation:
|
|
- `solisting` = coordination and discovery only; never holds escrow SOL
|
|
- `descro` = sole custodian of vault; enforces AcceptancePolicy
|
|
- `descro_ext_resolvers` = resolver registry; read by descro at seller_confirm time
|
|
|
|
Remaining items for future phases:
|
|
- USDC/SPL token payment option in `ListingAccount`
|
|
- `ProgramGated` resolver CPI in `descro.seller_confirm`
|
|
- Event emission (`emit!()`) for off-chain indexers
|
|
- Guard against closing listing while pending orders exist (`quantity_reserved > 0`)
|