33 KiB
Solisting Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
⚠️ Reference plan — do not implement yet. Two prerequisites must be completed first:
2026-05-19-descro-buyer-flow.md— addsbuyer_create_escrow,seller_confirm, and symmetriccancelto descroAcceptancePolicyprerequisite from the original solisting plan (addingAcceptancePolicyenum todescro_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:
solistingholds two account types:ListingAccount(seller's offer with payment options) andOrderAccount(pending bilateral consent record).- Payment method is negotiated per-order: the seller declares supported options in the listing (
DescroPaymentOptionwith accepted resolvers), the buyer picks one when creating an order. - For Descro orders:
create_orderCPIsdescro.buyer_create_escrowatomically — funds go directly into descro's vault.accept_orderCPIsdescro.seller_confirm. Solisting never holds or transfers escrow SOL. - The
OrderAccounthas 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 momentcreate_orderis called AcceptancePolicyis enforced by descro inseller_confirm, not by solisting- Listing
quantity_reservedtracks pending orders; decremented atcreate_order, restored atreject_order/cancel_order escrow_idis derived from the first 8 bytes of theorder_accountPDA to guarantee uniqueness without coordinator stateclose_stale_ordercan be called by anyone to clean up an OrderAccount whose descro escrow is already 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
solistingto workspace rootCargo.toml
Add "programs/solisting" to the [workspace] members array.
- Step 2: Create
programs/solisting/Cargo.toml
[package]
name = "solisting"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "solisting"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build"]
anchor-debug = []
custom-heap = []
custom-panic = []
[dependencies]
anchor-lang = "1.0.2"
descro = { path = "../descro", features = ["cpi"] }
descro_ext_resolvers = { path = "../descro_ext_resolvers" }
[dev-dependencies]
litesvm = "0.10.0"
solana-message = "3.0.1"
solana-transaction = "3.0.2"
solana-signer = "3.0.0"
solana-keypair = "3.0.1"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
- Step 3: Create directory structure
mkdir -p programs/solisting/src/instructions programs/solisting/tests/common
touch programs/solisting/src/lib.rs
touch programs/solisting/src/state.rs
touch programs/solisting/src/error.rs
touch programs/solisting/src/instructions.rs
touch programs/solisting/src/instructions/create_listing.rs
touch programs/solisting/src/instructions/update_listing.rs
touch programs/solisting/src/instructions/close_listing.rs
touch programs/solisting/src/instructions/create_order.rs
touch programs/solisting/src/instructions/accept_order.rs
touch programs/solisting/src/instructions/reject_order.rs
touch programs/solisting/src/instructions/cancel_order.rs
touch programs/solisting/src/instructions/close_stale_order.rs
- Step 4: Commit skeleton
git add programs/solisting/ Cargo.toml Cargo.lock
git commit -m "chore(solisting): add crate skeleton and workspace entry"
Task 2: State and Errors
Files: state.rs, error.rs
- Step 1: Write
state.rs
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_idderivation note: Atcreate_ordertime, theorder_accountPDA has been derived but not yet initialized. Solisting passes theescrow_idas an instruction argument. The frontend/SDK derives it asu64::from_le_bytes(order_pda.to_bytes()[0..8]). This ties the escrow_id to the order PDA, ensuring uniqueness as long asorder_idis unique per buyer+listing.
- Step 2: Write
error.rs
use anchor_lang::prelude::*;
#[error_code]
pub enum SolistingError {
#[msg("Listing is not active")]
ListingNotActive,
#[msg("No quantity available")]
OutOfStock,
#[msg("Signer is not authorized")]
Unauthorized,
#[msg("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
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
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
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)
#[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
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
#[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
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
#[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
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 ofseller: Signer<'info>- Constraint checks buyer == order.buyer
- CPI
canceller= buyer close = buyer- No timeout — buyer can cancel anytime pre-Active
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
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
#![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
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
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
cargo test --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -5
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5
cargo test --manifest-path programs/solisting/Cargo.toml 2>&1 | tail -5
Expected: all green.
- Step 5: Final commit
git add programs/solisting/ Cargo.toml Cargo.lock
git commit -m "feat(solisting): 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 SOLdescro= sole custodian of vault; enforces AcceptancePolicydescro_ext_resolvers= resolver registry; read by descro at seller_confirm time
Remaining items for future phases:
- USDC/SPL token payment option in
ListingAccount ProgramGatedresolver CPI indescro.seller_confirm- Event emission (
emit!()) for off-chain indexers - Guard against closing listing while pending orders exist (
quantity_reserved > 0)