Files
descro/docs/superpowers/plans/2026-05-19-solisting.md
2026-05-19 17:41:54 +02:00

1648 lines
53 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.** The `descro` plan (`2026-05-19-descro-flow.md`) must be fully implemented first. `solisting` CPIs into `descro` and depends on its `create_escrow_prefunded` instruction being available.
**Goal:** Build the `solisting` Anchor program that handles product listings and buyer-seller order consent, orchestrating the full flow from buyer intent → bilateral consent → active `descro` escrow.
**Architecture:** `solisting` is a standalone Anchor program with no reverse dependency on `descro`. It holds two account types: `ListingAccount` (seller's offer) and `OrderAccount` + `OrderVault` (buyer's pending purchase). On `accept_order`, solisting performs three CPI/transfers atomically: (1) checks resolver acceptance policy, (2) transfers SOL from `OrderVault` to the deterministic `descro` vault PDA, (3) CPIs into `descro.create_escrow_prefunded` to create an Active escrow. Descro does not know solisting exists.
**Tech Stack:** Rust, Anchor 1.0.x, LiteSVM 0.10.0. Depends on `descro` (CPI features) and `descro_ext_resolvers` (for `AcceptancePolicy` enum + `ResolverEntry` type). Build order: `descro_ext_resolvers``descro``solisting`.
---
## Prerequisites
**Before starting Task 1, complete this prerequisite in `descro_ext_resolvers`.**
`solisting`'s `accept_order` must read the resolver's `AcceptancePolicy` from the registry. This enum doesn't exist yet and must be added to `descro_ext_resolvers` first.
### Prerequisite: Add `AcceptancePolicy` to `descro_ext_resolvers`
**Files:**
- Modify: `programs/descro_ext_resolvers/src/state.rs`
- Modify: `programs/descro_ext_resolvers/src/instructions/register.rs`
- Modify: `programs/descro_ext_resolvers/src/lib.rs` (register updated instruction signature)
- [ ] **P1: Add `AcceptancePolicy` enum and field to `ResolverEntry`**
Replace `programs/descro_ext_resolvers/src/state.rs` with:
```rust
use anchor_lang::prelude::*;
#[account]
#[derive(InitSpace)]
pub struct ResolverEntry {
pub authority: Pubkey,
pub resolver_type: ResolverType,
pub acceptance_policy: AcceptancePolicy,
#[max_len(64)]
pub name: String,
#[max_len(256)]
pub description: String,
pub fee_bps: u16,
pub fee_recipient: Pubkey,
#[max_len(256)]
pub metadata_uri: String,
pub total_resolved: u64,
pub ruled_for_buyer: u64,
pub ruled_for_seller: u64,
pub registered_at: i64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)]
pub enum ResolverType {
CentralAuthority,
JuryDAO,
MAD,
Algorithmic,
Multisig,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)]
pub enum AcceptancePolicy {
/// Anyone can use this resolver — no signature needed at escrow creation (JuryDAO, MAD)
Open,
/// Resolver must co-sign accept_order; their backend controls access (CentralAuthority)
SignatureGated,
/// Resolver is a program that validates via CPI to its own accept_escrow instruction
ProgramGated,
}
/// Passed to update_stats; mirrors the Escrow program's Winner enum.
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq)]
pub enum Ruling {
Buyer,
Seller,
}
```
- [ ] **P2: Update `register_resolver` to accept `acceptance_policy`**
Replace `programs/descro_ext_resolvers/src/instructions/register.rs` with:
```rust
use anchor_lang::prelude::*;
use crate::state::{AcceptancePolicy, ResolverEntry, ResolverType};
use crate::error::RegistryError;
#[derive(Accounts)]
pub struct RegisterResolver<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(
init,
payer = authority,
space = 8 + ResolverEntry::INIT_SPACE,
seeds = [b"resolver", authority.key().as_ref()],
bump
)]
pub resolver_entry: Account<'info, ResolverEntry>,
pub system_program: Program<'info, System>,
}
pub fn handler(
ctx: Context<RegisterResolver>,
resolver_type: ResolverType,
acceptance_policy: AcceptancePolicy,
name: String,
description: String,
fee_bps: u16,
fee_recipient: Pubkey,
metadata_uri: String,
) -> Result<()> {
require!(!name.is_empty(), RegistryError::EmptyName);
require!(fee_bps <= 10_000, RegistryError::InvalidFeeBps);
let entry = &mut ctx.accounts.resolver_entry;
entry.authority = ctx.accounts.authority.key();
entry.resolver_type = resolver_type;
entry.acceptance_policy = acceptance_policy;
entry.name = name;
entry.description = description;
entry.fee_bps = fee_bps;
entry.fee_recipient = fee_recipient;
entry.metadata_uri = metadata_uri;
entry.total_resolved = 0;
entry.ruled_for_buyer = 0;
entry.ruled_for_seller = 0;
entry.registered_at = Clock::get()?.unix_timestamp;
Ok(())
}
```
- [ ] **P3: Update `lib.rs` entrypoint signature for `register_resolver`**
In `programs/descro_ext_resolvers/src/lib.rs`, update the `register_resolver` entrypoint:
```rust
pub fn register_resolver(
ctx: Context<RegisterResolver>,
resolver_type: ResolverType,
acceptance_policy: AcceptancePolicy,
name: String,
description: String,
fee_bps: u16,
fee_recipient: Pubkey,
metadata_uri: String,
) -> Result<()> {
register::handler(ctx, resolver_type, acceptance_policy, name, description, fee_bps, fee_recipient, metadata_uri)
}
```
- [ ] **P4: Build both programs to confirm no regressions**
```bash
cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -3
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3
```
Fix any test breakage caused by the changed `RegisterResolver` instruction signature (tests pass `ResolverType` positionally — add `AcceptancePolicy` as second argument).
- [ ] **P5: Run full test suites**
```bash
cargo test --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -10
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10
```
- [ ] **P6: Commit prerequisite**
```bash
git add programs/descro_ext_resolvers/src/state.rs \
programs/descro_ext_resolvers/src/instructions/register.rs \
programs/descro_ext_resolvers/src/lib.rs
git commit -m "feat(registry): add AcceptancePolicy enum to ResolverEntry"
```
---
## 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 |
| Create | `programs/solisting/src/error.rs` | SolistingError enum |
| Create | `programs/solisting/src/constants.rs` | ORDER_TIMEOUT_SECS |
| Create | `programs/solisting/src/instructions.rs` | Module re-exports |
| Create | `programs/solisting/src/instructions/create_listing.rs` | Instruction |
| Create | `programs/solisting/src/instructions/update_listing.rs` | Instruction |
| Create | `programs/solisting/src/instructions/close_listing.rs` | Instruction |
| Create | `programs/solisting/src/instructions/create_order.rs` | Instruction |
| Create | `programs/solisting/src/instructions/accept_order.rs` | Core orchestration instruction |
| Create | `programs/solisting/src/instructions/reject_order.rs` | Instruction |
| Create | `programs/solisting/src/instructions/cancel_order.rs` | Instruction |
| Create | `programs/solisting/tests/common/mod.rs` | LiteSVM setup + PDA helpers + ix builders |
| 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
**Files:**
- Modify: `Cargo.toml` (workspace root)
- Create: `programs/solisting/Cargo.toml`
- Create: `programs/solisting/src/lib.rs` (stub)
- [ ] **Step 1: Add `solisting` to workspace**
In the 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 stub `lib.rs`**
Create `programs/solisting/src/lib.rs`:
```rust
pub mod constants;
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 with actual after `anchor keys list`
```
> Note: Run `solana-keygen grind --starts-with Sol:1` to generate a vanity address, or just use `anchor keys list` after `anchor build` to get the auto-generated ID. Update both `declare_id!` and `Anchor.toml` with this ID.
- [ ] **Step 4: Create all empty module files**
```bash
mkdir -p programs/solisting/src/instructions programs/solisting/tests/common
touch programs/solisting/src/constants.rs
touch programs/solisting/src/error.rs
touch programs/solisting/src/state.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
```
- [ ] **Step 5: 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, Constants
**Files:**
- `programs/solisting/src/state.rs`
- `programs/solisting/src/error.rs`
- `programs/solisting/src/constants.rs`
- [ ] **Step 1: Write `state.rs`**
```rust
use anchor_lang::prelude::*;
#[account]
#[derive(InitSpace)]
pub struct ListingAccount {
pub seller: Pubkey,
pub price: u64,
pub quantity: u32,
pub resolver: Pubkey,
#[max_len(256)]
pub metadata_uri: String,
pub listing_id: u64,
pub state: ListingState,
pub bump: u8,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)]
pub enum ListingState {
Active,
Closed,
}
#[account]
#[derive(InitSpace)]
pub struct OrderAccount {
pub listing: Pubkey,
pub buyer: Pubkey,
pub seller: Pubkey,
pub resolver: Pubkey,
pub amount: u64,
pub escrow_id: u64,
pub state: OrderState,
pub order_id: u64,
pub created_at: i64,
pub bump: u8,
pub vault_bump: u8,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)]
pub enum OrderState {
AwaitingSellerAccept,
Accepted,
Rejected,
}
```
- [ ] **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("Order is not in AwaitingSellerAccept state")]
InvalidOrderState,
#[msg("Order cancellation timeout not reached")]
CancelTimeoutNotReached,
#[msg("Resolver requires a signature to accept this escrow")]
ResolverSignatureRequired,
}
```
- [ ] **Step 3: Write `constants.rs`**
```rust
/// Seconds a buyer must wait before cancelling an unresponded order.
pub const ORDER_TIMEOUT_SECS: i64 = 3 * 24 * 60 * 60; // 3 days
```
- [ ] **Step 4: Build (will fail until instructions are filled in — that's fine)**
```bash
cargo build-sbf --manifest-path programs/solisting/Cargo.toml 2>&1 | grep "error\[" | head -10
```
Expected: errors about empty instruction files. Proceed.
- [ ] **Step 5: Commit**
```bash
git add programs/solisting/src/state.rs programs/solisting/src/error.rs programs/solisting/src/constants.rs
git commit -m "feat(solisting): add state, errors, and constants"
```
---
## Task 3: Listing Instructions
**Files:**
- `programs/solisting/src/instructions/create_listing.rs`
- `programs/solisting/src/instructions/update_listing.rs`
- `programs/solisting/src/instructions/close_listing.rs`
- `programs/solisting/src/instructions.rs`
- [ ] **Step 1: Write `create_listing.rs`**
```rust
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, ListingState};
#[derive(Accounts)]
#[instruction(price: u64, quantity: u32, resolver: Pubkey, metadata_uri: String, 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>,
price: u64,
quantity: u32,
resolver: Pubkey,
metadata_uri: String,
listing_id: u64,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
listing.seller = ctx.accounts.seller.key();
listing.price = price;
listing.quantity = quantity;
listing.resolver = resolver;
listing.metadata_uri = metadata_uri;
listing.listing_id = listing_id;
listing.state = ListingState::Active;
listing.bump = ctx.bumps.listing_account;
Ok(())
}
```
- [ ] **Step 2: Write `update_listing.rs`**
```rust
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, ListingState};
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.state == ListingState::Active @ SolistingError::ListingNotActive,
)]
pub listing_account: Account<'info, ListingAccount>,
}
pub fn handler(
ctx: Context<UpdateListing>,
price: u64,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
let listing = &mut ctx.accounts.listing_account;
listing.price = price;
listing.quantity = quantity;
listing.metadata_uri = metadata_uri;
Ok(())
}
```
- [ ] **Step 3: Write `close_listing.rs`**
```rust
use anchor_lang::prelude::*;
use crate::state::{ListingAccount, ListingState};
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<()> {
ctx.accounts.listing_account.state = ListingState::Closed;
Ok(())
}
```
- [ ] **Step 4: Write `instructions.rs`** (partial — listing only for now)
```rust
#![allow(ambiguous_glob_reexports)]
pub mod accept_order;
pub mod cancel_order;
pub mod close_listing;
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 create_listing::*;
pub use create_order::*;
pub use reject_order::*;
pub use update_listing::*;
```
- [ ] **Step 5: Write stub `lib.rs` with listing entrypoints**
```rust
pub mod constants;
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");
#[program]
pub mod solisting {
use super::*;
pub fn create_listing(
ctx: Context<CreateListing>,
price: u64,
quantity: u32,
resolver: Pubkey,
metadata_uri: String,
listing_id: u64,
) -> Result<()> {
create_listing::handler(ctx, price, quantity, resolver, metadata_uri, listing_id)
}
pub fn update_listing(
ctx: Context<UpdateListing>,
price: u64,
quantity: u32,
metadata_uri: String,
) -> Result<()> {
update_listing::handler(ctx, price, 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) -> Result<()> {
create_order::handler(ctx, order_id, escrow_id)
}
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)
}
}
```
- [ ] **Step 6: Stub out the remaining instruction files** so the program compiles:
`programs/solisting/src/instructions/create_order.rs` (stub):
```rust
use anchor_lang::prelude::*;
pub struct CreateOrder<'info> { pub system_program: Program<'info, System> }
pub fn handler(_ctx: Context<CreateOrder>, _order_id: u64, _escrow_id: u64) -> Result<()> { Ok(()) }
```
`programs/solisting/src/instructions/accept_order.rs` (stub):
```rust
use anchor_lang::prelude::*;
pub struct AcceptOrder<'info> { pub system_program: Program<'info, System> }
pub fn handler(_ctx: Context<AcceptOrder>) -> Result<()> { Ok(()) }
```
`programs/solisting/src/instructions/reject_order.rs` (stub):
```rust
use anchor_lang::prelude::*;
pub struct RejectOrder<'info> { pub system_program: Program<'info, System> }
pub fn handler(_ctx: Context<RejectOrder>) -> Result<()> { Ok(()) }
```
`programs/solisting/src/instructions/cancel_order.rs` (stub):
```rust
use anchor_lang::prelude::*;
pub struct CancelOrder<'info> { pub system_program: Program<'info, System> }
pub fn handler(_ctx: Context<CancelOrder>) -> Result<()> { Ok(()) }
```
- [ ] **Step 7: Build**
```bash
cargo build-sbf --manifest-path programs/solisting/Cargo.toml 2>&1 | tail -5
```
Expected: `Finished`.
- [ ] **Step 8: Write listing tests**
Create `programs/solisting/tests/common/mod.rs`:
```rust
#![allow(dead_code, unused_imports)]
pub use anchor_lang::prelude::Pubkey;
pub use solisting::{ListingState, OrderState};
use {
anchor_lang::{
solana_program::{instruction::Instruction, system_program},
AccountDeserialize, InstructionData, ToAccountMetas,
},
solisting::{ListingAccount, OrderAccount},
litesvm::LiteSVM,
solana_keypair::Keypair,
solana_message::{Message, VersionedMessage},
solana_signer::Signer,
solana_transaction::versioned::VersionedTransaction,
};
pub const PRICE: u64 = 1_000_000_000;
pub const LISTING_ID: u64 = 1;
pub const ORDER_ID: u64 = 1;
pub const ESCROW_ID: u64 = 42;
pub fn setup() -> (LiteSVM, Keypair, Keypair, Keypair) {
let program_id = solisting::id();
let mut svm = LiteSVM::new();
let solisting_bytes = include_bytes!("../../../../target/deploy/solisting.so");
svm.add_program(program_id, solisting_bytes).unwrap();
let descro_bytes = include_bytes!("../../../../target/deploy/descro.so");
svm.add_program(descro::id(), descro_bytes).unwrap();
let registry_bytes = include_bytes!("../../../../target/deploy/descro_ext_resolvers.so");
svm.add_program(descro_ext_resolvers::id(), registry_bytes).unwrap();
let seller = Keypair::new();
let buyer = Keypair::new();
let resolver = Keypair::new();
svm.airdrop(&seller.pubkey(), 10_000_000_000).unwrap();
svm.airdrop(&buyer.pubkey(), 10_000_000_000).unwrap();
svm.airdrop(&resolver.pubkey(), 5_000_000_000).unwrap();
(svm, seller, buyer, resolver)
}
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 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 order_vault_pda(order: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[b"order_vault", order.as_ref()], &solisting::id()).0
}
pub fn escrow_pda(seller: &Pubkey, escrow_id: u64) -> Pubkey {
Pubkey::find_program_address(
&[b"escrow", seller.as_ref(), &escrow_id.to_le_bytes()],
&descro::id(),
).0
}
pub fn send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) {
let blockhash = svm.latest_blockhash();
let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash);
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]).unwrap();
svm.send_transaction(tx).expect("transaction failed");
}
pub fn try_send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) -> bool {
let blockhash = svm.latest_blockhash();
let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash);
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]).unwrap();
svm.send_transaction(tx).is_ok()
}
pub fn send_multi(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair, extra: &[&Keypair]) {
let blockhash = svm.latest_blockhash();
let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash);
let mut signers: Vec<&Keypair> = vec![payer];
signers.extend_from_slice(extra);
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &signers).unwrap();
svm.send_transaction(tx).expect("multi-signer transaction failed");
}
pub fn try_send_multi(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair, extra: &[&Keypair]) -> bool {
let blockhash = svm.latest_blockhash();
let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash);
let mut signers: Vec<&Keypair> = vec![payer];
signers.extend_from_slice(extra);
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &signers).unwrap();
svm.send_transaction(tx).is_ok()
}
pub fn ix_create_listing(
seller: &Pubkey,
price: u64,
quantity: u32,
resolver: &Pubkey,
metadata_uri: &str,
listing_id: u64,
) -> Instruction {
let listing = listing_pda(seller, listing_id);
Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::CreateListing {
price,
quantity,
resolver: *resolver,
metadata_uri: metadata_uri.to_string(),
listing_id,
}.data(),
solisting::accounts::CreateListing {
seller: *seller,
listing_account: listing,
system_program: system_program::ID,
}.to_account_metas(None),
)
}
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).expect("listing not found");
ListingAccount::try_deserialize(&mut account.data.as_slice()).unwrap()
}
pub fn read_order(svm: &LiteSVM, listing: &Pubkey, buyer: &Pubkey, order_id: u64) -> OrderAccount {
let pda = order_pda(listing, buyer, order_id);
let account = svm.get_account(&pda).expect("order not found");
OrderAccount::try_deserialize(&mut account.data.as_slice()).unwrap()
}
```
Create `programs/solisting/tests/test_listings.rs`:
```rust
mod common;
use common::*;
use solana_signer::Signer;
#[test]
fn seller_can_create_listing() {
let (mut svm, seller, _buyer, resolver) = setup();
send(
&mut svm,
ix_create_listing(&seller.pubkey(), PRICE, 5, &resolver.pubkey(), "ipfs://test", LISTING_ID),
&seller,
);
let listing = read_listing(&svm, &seller.pubkey(), LISTING_ID);
assert_eq!(listing.seller, seller.pubkey());
assert_eq!(listing.price, PRICE);
assert_eq!(listing.quantity, 5);
assert_eq!(listing.state, ListingState::Active);
}
#[test]
fn seller_can_update_listing() {
let (mut svm, seller, _buyer, resolver) = setup();
send(
&mut svm,
ix_create_listing(&seller.pubkey(), PRICE, 5, &resolver.pubkey(), "ipfs://test", LISTING_ID),
&seller,
);
let listing_pda_addr = listing_pda(&seller.pubkey(), LISTING_ID);
let ix = anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::UpdateListing {
price: PRICE * 2,
quantity: 3,
metadata_uri: "ipfs://updated".to_string(),
}.data(),
solisting::accounts::UpdateListing {
seller: seller.pubkey(),
listing_account: listing_pda_addr,
}.to_account_metas(None),
);
send(&mut svm, ix, &seller);
let listing = read_listing(&svm, &seller.pubkey(), LISTING_ID);
assert_eq!(listing.price, PRICE * 2);
assert_eq!(listing.quantity, 3);
}
#[test]
fn stranger_cannot_update_listing() {
let (mut svm, seller, buyer, resolver) = setup();
send(
&mut svm,
ix_create_listing(&seller.pubkey(), PRICE, 5, &resolver.pubkey(), "ipfs://test", LISTING_ID),
&seller,
);
let listing_pda_addr = listing_pda(&seller.pubkey(), LISTING_ID);
let ix = anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::UpdateListing {
price: 1,
quantity: 99,
metadata_uri: "".to_string(),
}.data(),
solisting::accounts::UpdateListing {
seller: buyer.pubkey(), // wrong
listing_account: listing_pda_addr,
}.to_account_metas(None),
);
assert!(!try_send(&mut svm, ix, &buyer));
}
```
- [ ] **Step 9: Run listing tests (will fail — stubs are empty)**
```bash
cargo build-sbf --manifest-path programs/solisting/Cargo.toml && \
cargo test --manifest-path programs/solisting/Cargo.toml --test test_listings 2>&1 | tail -15
```
Tests fail because stubs have empty `Accounts` structs. This is expected — proceed to Task 4.
- [ ] **Step 10: Commit**
```bash
git add programs/solisting/
git commit -m "feat(solisting): add listing instructions and stub order instructions"
```
---
## Task 4: `create_order` — Buyer deposits into OrderVault
**Files:**
- `programs/solisting/src/instructions/create_order.rs`
- [ ] **Step 1: Write the failing test** (add to `test_listings.rs` or create `test_orders.rs`)
Create `programs/solisting/tests/test_orders.rs`:
```rust
mod common;
use common::*;
use solana_signer::Signer;
fn setup_listing(svm: &mut litesvm::LiteSVM, seller: &solana_keypair::Keypair, resolver: &solana_keypair::Keypair) {
send(
svm,
ix_create_listing(&seller.pubkey(), PRICE, 5, &resolver.pubkey(), "ipfs://item", LISTING_ID),
seller,
);
}
fn ix_create_order(
buyer: &anchor_lang::prelude::Pubkey,
seller: &anchor_lang::prelude::Pubkey,
listing: &anchor_lang::prelude::Pubkey,
) -> anchor_lang::solana_program::instruction::Instruction {
let order = order_pda(listing, buyer, ORDER_ID);
let vault = order_vault_pda(&order);
anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::CreateOrder {
order_id: ORDER_ID,
escrow_id: ESCROW_ID,
}.data(),
solisting::accounts::CreateOrder {
buyer: *buyer,
seller: *seller,
listing_account: *listing,
order_account: order,
order_vault: vault,
system_program: anchor_lang::solana_program::system_program::ID,
}.to_account_metas(None),
)
}
#[test]
fn buyer_can_create_order() {
let (mut svm, seller, buyer, resolver) = setup();
setup_listing(&mut svm, &seller, &resolver);
let listing = listing_pda(&seller.pubkey(), LISTING_ID);
send(&mut svm, ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing), &buyer);
let order = read_order(&svm, &listing, &buyer.pubkey(), ORDER_ID);
assert_eq!(order.buyer, buyer.pubkey());
assert_eq!(order.seller, seller.pubkey());
assert_eq!(order.amount, PRICE);
assert_eq!(order.state, OrderState::AwaitingSellerAccept);
// Vault has the funds
let vault = order_vault_pda(&order_pda(&listing, &buyer.pubkey(), ORDER_ID));
let vault_lamports = svm.get_account(&vault).map(|a| a.lamports).unwrap_or(0);
assert_eq!(vault_lamports, PRICE);
}
#[test]
fn create_order_fails_if_listing_inactive() {
let (mut svm, seller, buyer, resolver) = setup();
setup_listing(&mut svm, &seller, &resolver);
// Close the listing first
let listing_pda_addr = listing_pda(&seller.pubkey(), LISTING_ID);
let ix_close = anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::CloseListing {}.data(),
solisting::accounts::CloseListing {
seller: seller.pubkey(),
listing_account: listing_pda_addr,
}.to_account_metas(None),
);
send(&mut svm, ix_close, &seller);
assert!(!try_send(
&mut svm,
ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing_pda_addr),
&buyer,
));
}
```
- [ ] **Step 2: Implement `create_order.rs`**
```rust
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, Transfer};
use crate::state::{ListingAccount, ListingState, OrderAccount, OrderState};
use crate::error::SolistingError;
#[derive(Accounts)]
#[instruction(order_id: u64, escrow_id: u64)]
pub struct CreateOrder<'info> {
#[account(mut)]
pub buyer: Signer<'info>,
/// CHECK: seller pubkey read from listing; stored in order
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.state == ListingState::Active @ SolistingError::ListingNotActive,
constraint = listing_account.quantity > 0 @ 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: System-owned vault PDA; receives buyer's SOL
#[account(
mut,
seeds = [b"order_vault", order_account.key().as_ref()],
bump
)]
pub order_vault: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(ctx: Context<CreateOrder>, order_id: u64, escrow_id: u64) -> Result<()> {
let listing = &ctx.accounts.listing_account;
let amount = listing.price;
system_program::transfer(
CpiContext::new(
system_program::ID,
Transfer {
from: ctx.accounts.buyer.to_account_info(),
to: ctx.accounts.order_vault.to_account_info(),
},
),
amount,
)?;
let order = &mut ctx.accounts.order_account;
order.listing = ctx.accounts.listing_account.key();
order.buyer = ctx.accounts.buyer.key();
order.seller = listing.seller;
order.resolver = listing.resolver;
order.amount = amount;
order.escrow_id = escrow_id;
order.state = OrderState::AwaitingSellerAccept;
order.order_id = order_id;
order.created_at = Clock::get()?.unix_timestamp;
order.bump = ctx.bumps.order_account;
order.vault_bump = ctx.bumps.order_vault;
Ok(())
}
```
- [ ] **Step 3: Build and 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 create_order_fails_if_listing_inactive 2>&1 | tail -15
```
Expected: both tests pass.
- [ ] **Step 4: Commit**
```bash
git add programs/solisting/src/instructions/create_order.rs
git commit -m "feat(solisting): implement create_order — buyer deposits into OrderVault"
```
---
## Task 5: `reject_order` and `cancel_order`
**Files:**
- `programs/solisting/src/instructions/reject_order.rs`
- `programs/solisting/src/instructions/cancel_order.rs`
- [ ] **Step 1: Write failing tests** (add to `test_orders.rs`)
```rust
// Add to test_orders.rs (inside the file, after existing tests)
fn ix_reject_order(
seller: &anchor_lang::prelude::Pubkey,
listing: &anchor_lang::prelude::Pubkey,
buyer: &anchor_lang::prelude::Pubkey,
) -> anchor_lang::solana_program::instruction::Instruction {
let order = order_pda(listing, buyer, ORDER_ID);
let vault = order_vault_pda(&order);
anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::RejectOrder {}.data(),
solisting::accounts::RejectOrder {
seller: *seller,
buyer: *buyer,
order_account: order,
order_vault: vault,
system_program: anchor_lang::solana_program::system_program::ID,
}.to_account_metas(None),
)
}
#[test]
fn seller_can_reject_order() {
let (mut svm, seller, buyer, resolver) = setup();
setup_listing(&mut svm, &seller, &resolver);
let listing = listing_pda(&seller.pubkey(), LISTING_ID);
send(&mut svm, ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing), &buyer);
let buyer_before = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
send(&mut svm, ix_reject_order(&seller.pubkey(), &listing, &buyer.pubkey()), &seller);
let buyer_after = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
assert!(buyer_after > buyer_before); // SOL returned
assert!(svm.get_account(&order_pda(&listing, &buyer.pubkey(), ORDER_ID)).is_none());
}
#[test]
fn buyer_can_cancel_after_timeout() {
let (mut svm, seller, buyer, resolver) = setup();
setup_listing(&mut svm, &seller, &resolver);
let listing = listing_pda(&seller.pubkey(), LISTING_ID);
send(&mut svm, ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing), &buyer);
// Warp past ORDER_TIMEOUT_SECS (3 days)
use anchor_lang::solana_program::clock::Clock;
svm.set_sysvar(&Clock {
slot: 1_000_000,
epoch_start_timestamp: 0,
epoch: 0,
leader_schedule_epoch: 0,
unix_timestamp: 3 * 24 * 60 * 60 + 1,
});
let buyer_before = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
let order = order_pda(&listing, &buyer.pubkey(), ORDER_ID);
let vault = order_vault_pda(&order);
let ix = anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::CancelOrder {}.data(),
solisting::accounts::CancelOrder {
buyer: buyer.pubkey(),
order_account: order,
order_vault: vault,
system_program: anchor_lang::solana_program::system_program::ID,
}.to_account_metas(None),
);
send(&mut svm, ix, &buyer);
let buyer_after = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0);
assert!(buyer_after > buyer_before);
}
#[test]
fn buyer_cannot_cancel_before_timeout() {
let (mut svm, seller, buyer, resolver) = setup();
setup_listing(&mut svm, &seller, &resolver);
let listing = listing_pda(&seller.pubkey(), LISTING_ID);
send(&mut svm, ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing), &buyer);
let order = order_pda(&listing, &buyer.pubkey(), ORDER_ID);
let vault = order_vault_pda(&order);
let ix = anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::CancelOrder {}.data(),
solisting::accounts::CancelOrder {
buyer: buyer.pubkey(),
order_account: order,
order_vault: vault,
system_program: anchor_lang::solana_program::system_program::ID,
}.to_account_metas(None),
);
assert!(!try_send(&mut svm, ix, &buyer));
}
```
- [ ] **Step 2: Implement `reject_order.rs`**
```rust
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, Transfer};
use crate::state::{OrderAccount, OrderState};
use crate::error::SolistingError;
#[derive(Accounts)]
pub struct RejectOrder<'info> {
pub seller: Signer<'info>,
/// CHECK: Buyer receives their SOL back
#[account(mut)]
pub buyer: UncheckedAccount<'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,
constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized,
constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized,
constraint = order_account.state == OrderState::AwaitingSellerAccept @ SolistingError::InvalidOrderState,
close = seller,
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: OrderVault PDA — SOL transferred back to buyer then this closes implicitly
#[account(
mut,
seeds = [b"order_vault", order_account.key().as_ref()],
bump = order_account.vault_bump,
)]
pub order_vault: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(ctx: Context<RejectOrder>) -> Result<()> {
let order = &ctx.accounts.order_account;
let vault_balance = ctx.accounts.order_vault.to_account_info().lamports();
let order_key = order.key();
let vault_bump = order.vault_bump;
system_program::transfer(
CpiContext::new_with_signer(
system_program::ID,
Transfer {
from: ctx.accounts.order_vault.to_account_info(),
to: ctx.accounts.buyer.to_account_info(),
},
&[&[b"order_vault", order_key.as_ref(), &[vault_bump]]],
),
vault_balance,
)?;
Ok(())
}
```
- [ ] **Step 3: Implement `cancel_order.rs`**
```rust
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, Transfer};
use crate::state::{OrderAccount, OrderState};
use crate::error::SolistingError;
use crate::constants::ORDER_TIMEOUT_SECS;
#[derive(Accounts)]
pub struct CancelOrder<'info> {
#[account(mut)]
pub buyer: 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,
constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized,
constraint = order_account.state == OrderState::AwaitingSellerAccept @ SolistingError::InvalidOrderState,
close = buyer,
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: OrderVault PDA — SOL returned to buyer
#[account(
mut,
seeds = [b"order_vault", order_account.key().as_ref()],
bump = order_account.vault_bump,
)]
pub order_vault: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(ctx: Context<CancelOrder>) -> Result<()> {
let now = Clock::get()?.unix_timestamp;
require!(
now >= ctx.accounts.order_account.created_at + ORDER_TIMEOUT_SECS,
SolistingError::CancelTimeoutNotReached
);
let order = &ctx.accounts.order_account;
let vault_balance = ctx.accounts.order_vault.to_account_info().lamports();
let order_key = order.key();
let vault_bump = order.vault_bump;
system_program::transfer(
CpiContext::new_with_signer(
system_program::ID,
Transfer {
from: ctx.accounts.order_vault.to_account_info(),
to: ctx.accounts.buyer.to_account_info(),
},
&[&[b"order_vault", order_key.as_ref(), &[vault_bump]]],
),
vault_balance,
)?;
Ok(())
}
```
- [ ] **Step 4: Build and run 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 tests pass.
- [ ] **Step 5: Commit**
```bash
git add programs/solisting/src/instructions/reject_order.rs \
programs/solisting/src/instructions/cancel_order.rs \
programs/solisting/tests/test_orders.rs
git commit -m "feat(solisting): implement reject_order and cancel_order"
```
---
## Task 6: `accept_order` — Core Orchestration
This is the most complex instruction. It:
1. Checks resolver acceptance policy (`Open` → no-op, `SignatureGated``resolver.is_signer`, `ProgramGated` → CPI)
2. Transfers SOL from `OrderVault` to the deterministic `descro` vault PDA address
3. CPIs into `descro.create_escrow_prefunded` to create an Active escrow
4. Decrements `listing.quantity`
5. Closes `OrderAccount` (rent → seller)
**Files:**
- `programs/solisting/src/instructions/accept_order.rs`
- [ ] **Step 1: Write failing tests** (add to `test_orders.rs`)
```rust
// Helper — build accept_order ix (Open resolver, no registry entry needed)
fn ix_accept_order_open(
seller: &anchor_lang::prelude::Pubkey,
buyer: &anchor_lang::prelude::Pubkey,
resolver: &anchor_lang::prelude::Pubkey,
listing: &anchor_lang::prelude::Pubkey,
) -> anchor_lang::solana_program::instruction::Instruction {
use anchor_lang::solana_program::system_program;
let order = order_pda(listing, buyer, ORDER_ID);
let order_vault = order_vault_pda(&order);
let escrow_pda_addr = escrow_pda(seller, ESCROW_ID);
let descro_vault = anchor_lang::prelude::Pubkey::find_program_address(
&[b"vault", escrow_pda_addr.as_ref()],
&descro::id(),
).0;
let resolver_entry = anchor_lang::prelude::Pubkey::find_program_address(
&[b"resolver", resolver.as_ref()],
&descro_ext_resolvers::id(),
).0;
anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
solisting::id(),
&solisting::instruction::AcceptOrder { escrow_id: ESCROW_ID }.data(),
solisting::accounts::AcceptOrder {
seller: *seller,
buyer: *buyer,
resolver: *resolver,
listing_account: *listing,
order_account: order,
order_vault,
escrow_account: escrow_pda_addr,
descro_vault,
resolver_entry,
descro_program: descro::id(),
system_program: system_program::ID,
}.to_account_metas(None),
)
}
#[test]
fn seller_accept_creates_active_descro_escrow() {
let (mut svm, seller, buyer, resolver) = setup();
setup_listing(&mut svm, &seller, &resolver);
let listing = listing_pda(&seller.pubkey(), LISTING_ID);
send(&mut svm, ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing), &buyer);
send(
&mut svm,
ix_accept_order_open(&seller.pubkey(), &buyer.pubkey(), &resolver.pubkey(), &listing),
&seller,
);
// Descro escrow exists and is Active
let escrow_addr = escrow_pda(&seller.pubkey(), ESCROW_ID);
let account = svm.get_account(&escrow_addr).expect("escrow not found");
let escrow = descro::EscrowAccount::try_deserialize(&mut account.data.as_slice()).unwrap();
assert_eq!(escrow.state, descro::EscrowState::Active);
assert_eq!(escrow.buyer, buyer.pubkey());
assert_eq!(escrow.seller, seller.pubkey());
// OrderAccount is closed
assert!(svm.get_account(&order_pda(&listing, &buyer.pubkey(), ORDER_ID)).is_none());
// Listing quantity decremented
let listing_acc = read_listing(&svm, &seller.pubkey(), LISTING_ID);
assert_eq!(listing_acc.quantity, 4);
}
#[test]
fn signature_gated_resolver_without_signature_fails() {
let (mut svm, seller, buyer, resolver) = setup();
setup_listing(&mut svm, &seller, &resolver);
// Register the resolver as SignatureGated in the registry
let resolver_entry_pda = anchor_lang::prelude::Pubkey::find_program_address(
&[b"resolver", resolver.pubkey().as_ref()],
&descro_ext_resolvers::id(),
).0;
let ix_register = anchor_lang::solana_program::instruction::Instruction::new_with_bytes(
descro_ext_resolvers::id(),
&descro_ext_resolvers::instruction::RegisterResolver {
resolver_type: descro_ext_resolvers::ResolverType::CentralAuthority,
acceptance_policy: descro_ext_resolvers::state::AcceptancePolicy::SignatureGated,
name: "Gated Resolver".to_string(),
description: "Test".to_string(),
fee_bps: 100,
fee_recipient: resolver.pubkey(),
metadata_uri: "ipfs://test".to_string(),
}.data(),
descro_ext_resolvers::accounts::RegisterResolver {
authority: resolver.pubkey(),
resolver_entry: resolver_entry_pda,
system_program: anchor_lang::solana_program::system_program::ID,
}.to_account_metas(None),
);
send(&mut svm, ix_register, &resolver);
let listing = listing_pda(&seller.pubkey(), LISTING_ID);
send(&mut svm, ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing), &buyer);
// accept_order called with ONLY seller signing — resolver (SignatureGated) does not sign → must fail
assert!(!try_send(
&mut svm,
ix_accept_order_open(&seller.pubkey(), &buyer.pubkey(), &resolver.pubkey(), &listing),
&seller,
));
}
```
- [ ] **Step 2: Implement `accept_order.rs`**
```rust
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, Transfer};
use crate::state::{ListingAccount, ListingState, OrderAccount, OrderState};
use crate::error::SolistingError;
use descro_ext_resolvers::state::AcceptancePolicy;
#[derive(Accounts)]
#[instruction(escrow_id: u64)]
pub struct AcceptOrder<'info> {
#[account(mut)]
pub seller: Signer<'info>,
/// CHECK: Buyer pubkey verified against order_account
pub buyer: UncheckedAccount<'info>,
/// CHECK: Resolver — may or may not need to sign depending on AcceptancePolicy
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,
constraint = listing_account.state == ListingState::Active @ SolistingError::ListingNotActive,
)]
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,
constraint = order_account.state == OrderState::AwaitingSellerAccept @ SolistingError::InvalidOrderState,
close = seller,
)]
pub order_account: Account<'info, OrderAccount>,
/// CHECK: OrderVault PDA — drained into descro vault
#[account(
mut,
seeds = [b"order_vault", order_account.key().as_ref()],
bump = order_account.vault_bump,
)]
pub order_vault: UncheckedAccount<'info>,
/// CHECK: Descro EscrowAccount PDA — created by create_escrow_prefunded 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 — receives SOL from order_vault before CPI
#[account(
mut,
seeds = [b"vault", escrow_account.key().as_ref()],
bump,
seeds::program = descro::id(),
)]
pub descro_vault: UncheckedAccount<'info>,
/// CHECK: Optional resolver registry entry — used for policy check
pub resolver_entry: UncheckedAccount<'info>,
/// CHECK: Descro program — verified against descro::id() in handler
pub descro_program: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub fn handler(ctx: Context<AcceptOrder>, escrow_id: u64) -> Result<()> {
require!(
ctx.accounts.descro_program.key() == descro::id(),
SolistingError::Unauthorized
);
// Check resolver acceptance policy
if !ctx.accounts.resolver_entry.data_is_empty() {
let entry_data = ctx.accounts.resolver_entry.try_borrow_data()?;
let entry = descro_ext_resolvers::state::ResolverEntry::try_deserialize(
&mut entry_data.as_ref(),
)?;
match entry.acceptance_policy {
AcceptancePolicy::Open => {}
AcceptancePolicy::SignatureGated => {
require!(
ctx.accounts.resolver.is_signer,
SolistingError::ResolverSignatureRequired
);
}
AcceptancePolicy::ProgramGated => {
// For ProgramGated, a CPI to the resolver program's accept_escrow
// would go here. Resolver program address is the resolver pubkey itself.
// Left as future extension — ProgramGated resolvers are out of MVP scope.
}
}
} else {
// No registry entry → treat as SignatureGated (must have signed)
require!(
ctx.accounts.resolver.is_signer,
SolistingError::ResolverSignatureRequired
);
}
let order = &ctx.accounts.order_account;
let amount = order.amount;
let order_key = order.key();
let vault_bump = order.vault_bump;
// Transfer SOL from OrderVault → descro vault PDA
// The descro vault doesn't exist yet but can receive lamports via system transfer
system_program::transfer(
CpiContext::new_with_signer(
system_program::ID,
Transfer {
from: ctx.accounts.order_vault.to_account_info(),
to: ctx.accounts.descro_vault.to_account_info(),
},
&[&[b"order_vault", order_key.as_ref(), &[vault_bump]]],
),
amount,
)?;
// CPI: descro.create_escrow_prefunded
descro::cpi::create_escrow_prefunded(
CpiContext::new(
ctx.accounts.descro_program.to_account_info(),
descro::cpi::accounts::CreateEscrowPrefunded {
seller: 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.descro_vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
),
amount,
Some(ctx.accounts.resolver.key()),
escrow_id,
)?;
// Decrement listing quantity
ctx.accounts.listing_account.quantity -= 1;
Ok(())
}
```
- [ ] **Step 3: Build**
```bash
cargo build-sbf --manifest-path programs/solisting/Cargo.toml 2>&1 | tail -5
```
- [ ] **Step 4: Run all order tests**
```bash
cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders 2>&1 | tail -20
```
Expected: all tests pass.
- [ ] **Step 5: Run full suite for 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 && \
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 6: Commit**
```bash
git add programs/solisting/src/instructions/accept_order.rs \
programs/solisting/tests/test_orders.rs
git commit -m "feat(solisting): implement accept_order — orchestrates SOL transfer + descro CPI"
```
---
## Done
Full buyer-to-escrow flow is now working across three programs. Remaining items for future phases:
- `ProgramGated` resolver CPI in `accept_order`
- USDC vault support (SPL Token)
- Listing quantity validation when closing (no open orders guard)
- Event emission (`emit!()`) for off-chain indexers