1648 lines
53 KiB
Markdown
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
|