From 4a7af8dc37e2220977c55d730f4081583e55537d Mon Sep 17 00:00:00 2001 From: thesn10 <38666407+thesn10@users.noreply.github.com> Date: Mon, 25 May 2026 00:12:51 +0200 Subject: [PATCH] solisting rework --- .../plans/2026-05-19-descro-buyer-flow.md | 481 +++++ .../superpowers/plans/2026-05-19-solisting.md | 1921 ++++++----------- 2 files changed, 1120 insertions(+), 1282 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-19-descro-buyer-flow.md diff --git a/docs/superpowers/plans/2026-05-19-descro-buyer-flow.md b/docs/superpowers/plans/2026-05-19-descro-buyer-flow.md new file mode 100644 index 0000000..bc66034 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-descro-buyer-flow.md @@ -0,0 +1,481 @@ +# Descro: Buyer-Initiated Escrow Flow + +> **Prerequisite for `solisting`.** The `solisting` plan depends on these instructions existing before `accept_order` and `create_order` can be implemented. + +**Goal:** Extend the `descro` program with a parallel buyer-initiated escrow flow. The existing seller-initiated flow (`create_escrow` → `deposit` → `Active`) is unchanged. The new flow allows a buyer to atomically create and fund an escrow in a single transaction, landing in `AwaitingSellerConfirm` state, after which the seller confirms to make it `Active`. + +**Why:** `solisting` needs to CPI into descro at `create_order` time (buyer signs) to lock funds before the seller has accepted. This removes the need for an intermediate solisting vault — the funds live in descro's vault from the start. + +--- + +## State Machine After This Change + +``` +Seller-initiated (unchanged): + create_escrow (seller) → AwaitingDeposit + deposit (buyer) → Active + cancel (any) ← from AwaitingDeposit + +Buyer-initiated (new): + buyer_create_escrow (buyer) → AwaitingSellerConfirm [creates + funds vault atomically] + seller_confirm (seller) → Active [AcceptancePolicy checked here] + cancel (any) ← from AwaitingSellerConfirm + +From Active (unchanged): + complete → Completed + dispute → Disputed → resolve → Completed/Refunded +``` + +**Cancel is now symmetric:** either buyer or seller can cancel from either pre-Active state. Vault funds (if any) always return to buyer. EscrowAccount rent goes to the canceller. + +--- + +## File Map + +| Action | Path | +|---|---| +| Modify | `programs/descro/src/state.rs` | +| Create | `programs/descro/src/instructions/buyer_create_escrow.rs` | +| Create | `programs/descro/src/instructions/seller_confirm.rs` | +| Modify | `programs/descro/src/instructions/cancel.rs` | +| Modify | `programs/descro/src/lib.rs` | +| Delete | `programs/descro/src/instructions/create_escrow_prefunded.rs` | +| Modify | `programs/descro/src/instructions.rs` (or equivalent module file) | + +--- + +## Task 1: Add `AwaitingSellerConfirm` to `EscrowState` + +**File:** `programs/descro/src/state.rs` + +- [ ] **Step 1: Add the new variant** + +```rust +#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)] +pub enum EscrowState { + AwaitingDeposit, + AwaitingSellerConfirm, // new: buyer created + funded, waiting for seller + Active, + Disputed, + Complete, + Cancelled, +} +``` + +No other state changes needed — `EscrowAccount` struct is unchanged. + +- [ ] **Step 2: Build to verify no regressions** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 +``` + +- [ ] **Step 3: Run existing tests** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10 +``` + +--- + +## Task 2: `buyer_create_escrow` — Atomic Create + Fund + +**File:** `programs/descro/src/instructions/buyer_create_escrow.rs` + +The buyer creates the `EscrowAccount` PDA (paying rent) and transfers `amount` SOL into the vault in the same transaction. State lands at `AwaitingSellerConfirm`. + +The EscrowAccount uses the same PDA seeds as seller-initiated: `["escrow", seller_pubkey, escrow_id_le_bytes]`. The seller is passed as an `UncheckedAccount` — they don't need to sign at creation time. + +- [ ] **Step 1: Write the instruction** + +```rust +use anchor_lang::prelude::*; +use anchor_lang::system_program::{self, Transfer}; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +#[instruction(amount: u64, dispute_resolver: Option, escrow_id: u64)] +pub struct BuyerCreateEscrow<'info> { + #[account(mut)] + pub buyer: Signer<'info>, + + /// CHECK: Seller pubkey used as PDA seed — no signature needed at creation + pub seller: UncheckedAccount<'info>, + + #[account( + init, + payer = buyer, + space = 8 + EscrowAccount::INIT_SPACE, + seeds = [b"escrow", seller.key().as_ref(), &escrow_id.to_le_bytes()], + bump + )] + pub escrow_account: Account<'info, EscrowAccount>, + + /// CHECK: System-owned vault PDA; receives escrowed SOL + #[account( + mut, + seeds = [b"vault", escrow_account.key().as_ref()], + bump + )] + pub vault: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, +) -> Result<()> { + let rent_min = Rent::get()?.minimum_balance(0); + require!(amount >= rent_min, EscrowError::AmountBelowRentMinimum); + + system_program::transfer( + CpiContext::new( + ctx.accounts.system_program.to_account_info(), + Transfer { + from: ctx.accounts.buyer.to_account_info(), + to: ctx.accounts.vault.to_account_info(), + }, + ), + amount, + )?; + + let escrow = &mut ctx.accounts.escrow_account; + escrow.seller = ctx.accounts.seller.key(); + escrow.buyer = ctx.accounts.buyer.key(); + escrow.amount = amount; + escrow.dispute_resolver = dispute_resolver; + escrow.state = EscrowState::AwaitingSellerConfirm; + escrow.bump = ctx.bumps.escrow_account; + escrow.vault_bump = ctx.bumps.vault; + escrow.escrow_id = escrow_id; + escrow.dispute_raised_at = None; + Ok(()) +} +``` + +- [ ] **Step 2: Register in `lib.rs`** + +Add to the `#[program]` block: + +```rust +pub fn buyer_create_escrow( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, +) -> Result<()> { + buyer_create_escrow::handler(ctx, amount, dispute_resolver, escrow_id) +} +``` + +And add to the `cpi::accounts` module for CPI callers (solisting needs this): + +```rust +// In the cpi module (auto-generated by Anchor from the accounts struct, +// but verify the IDL exports BuyerCreateEscrow accounts correctly after build) +``` + +- [ ] **Step 3: Write test** + +In `programs/descro/tests/` (new file `test_buyer_flow.rs` or add to existing): + +```rust +#[test] +fn buyer_can_create_and_fund_escrow() { + // setup() from common + // Call buyer_create_escrow with amount = 1 SOL + // Assert: escrow exists, state == AwaitingSellerConfirm + // Assert: vault lamports == amount + // Assert: escrow.buyer == buyer, escrow.seller == seller +} + +#[test] +fn buyer_create_escrow_fails_below_rent_minimum() { + // amount = 0 → should fail with AmountBelowRentMinimum +} +``` + +- [ ] **Step 4: Build and test** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml && \ +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15 +``` + +--- + +## Task 3: `seller_confirm` — Confirm Buyer-Initiated Escrow + +**File:** `programs/descro/src/instructions/seller_confirm.rs` + +The seller signs to move `AwaitingSellerConfirm → Active`. The `AcceptancePolicy` check lives here — descro is the single point of truth for resolver policy, not solisting. + +- [ ] **Step 1: Write the instruction** + +```rust +use anchor_lang::prelude::*; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +pub struct SellerConfirm<'info> { + pub seller: Signer<'info>, + + /// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated + pub resolver: AccountInfo<'info>, + + #[account( + mut, + seeds = [b"escrow", seller.key().as_ref(), &escrow_account.escrow_id.to_le_bytes()], + bump = escrow_account.bump, + constraint = seller.key() == escrow_account.seller @ EscrowError::Unauthorized, + constraint = escrow_account.state == EscrowState::AwaitingSellerConfirm @ EscrowError::InvalidState, + )] + pub escrow_account: Account<'info, EscrowAccount>, + + /// CHECK: Optional resolver registry entry — read to determine AcceptancePolicy + pub resolver_entry: UncheckedAccount<'info>, +} + +pub fn handler(ctx: Context) -> Result<()> { + // AcceptancePolicy check — only if resolver is set on the escrow + if ctx.accounts.escrow_account.dispute_resolver.is_some() + && !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 { + descro_ext_resolvers::state::AcceptancePolicy::Open => {} + descro_ext_resolvers::state::AcceptancePolicy::SignatureGated => { + require!( + ctx.accounts.resolver.is_signer, + EscrowError::ResolverSignatureRequired + ); + } + descro_ext_resolvers::state::AcceptancePolicy::ProgramGated => { + // Future: CPI to resolver program's accept_escrow instruction + } + } + } + + ctx.accounts.escrow_account.state = EscrowState::Active; + Ok(()) +} +``` + +> **Note:** This requires adding `descro_ext_resolvers` as a dependency to `descro`'s `Cargo.toml` (without `cpi` feature — read-only access to types). Add `descro_ext_resolvers = { path = "../descro_ext_resolvers" }` to `[dependencies]`. Also requires the `AcceptancePolicy` prerequisite from the solisting plan to be implemented first. + +> **Note:** Add `ResolverSignatureRequired` to `EscrowError` if not already present. + +- [ ] **Step 2: Register in `lib.rs`** + +```rust +pub fn seller_confirm(ctx: Context) -> Result<()> { + seller_confirm::handler(ctx) +} +``` + +- [ ] **Step 3: Write tests** + +```rust +#[test] +fn seller_can_confirm_buyer_escrow() { + // buyer_create_escrow → AwaitingSellerConfirm + // seller_confirm → Active + // Assert: state == Active +} + +#[test] +fn seller_confirm_fails_from_wrong_state() { + // create_escrow (seller-initiated) → AwaitingDeposit + // seller_confirm → must fail (wrong state) +} + +#[test] +fn signature_gated_resolver_requires_co_sign() { + // Register resolver with AcceptancePolicy::SignatureGated + // buyer_create_escrow → AwaitingSellerConfirm + // seller_confirm WITHOUT resolver signing → must fail + // seller_confirm WITH resolver co-signing → Active +} +``` + +- [ ] **Step 4: Build and test** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml && \ +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15 +``` + +--- + +## Task 4: Extend `cancel` — Symmetric Pre-Active Cancellation + +**File:** `programs/descro/src/instructions/cancel.rs` + +**Current behavior:** Seller-only, only from `AwaitingDeposit`, vault is always empty (deposit hasn't happened). + +**New behavior:** +- Either buyer or seller can cancel +- Works from `AwaitingDeposit` or `AwaitingSellerConfirm` +- If vault has funds (always true in `AwaitingSellerConfirm`), drain to buyer first +- EscrowAccount rent goes to whoever called cancel (`close = canceller`) + +- [ ] **Step 1: Replace `cancel.rs`** + +```rust +use anchor_lang::prelude::*; +use anchor_lang::system_program::{self, Transfer}; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +pub struct Cancel<'info> { + #[account(mut)] + pub canceller: Signer<'info>, + + /// CHECK: Buyer receives vault refund if funds are present + #[account( + mut, + constraint = buyer.key() == escrow_account.buyer @ EscrowError::Unauthorized, + )] + pub buyer: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"escrow", escrow_account.seller.as_ref(), &escrow_account.escrow_id.to_le_bytes()], + bump = escrow_account.bump, + constraint = ( + canceller.key() == escrow_account.buyer || + canceller.key() == escrow_account.seller + ) @ EscrowError::Unauthorized, + constraint = ( + escrow_account.state == EscrowState::AwaitingDeposit || + escrow_account.state == EscrowState::AwaitingSellerConfirm + ) @ EscrowError::InvalidState, + close = canceller, + )] + pub escrow_account: Account<'info, EscrowAccount>, + + /// CHECK: Vault PDA — may hold funds (AwaitingSellerConfirm) or be empty (AwaitingDeposit) + #[account( + mut, + seeds = [b"vault", escrow_account.key().as_ref()], + bump = escrow_account.vault_bump, + )] + pub vault: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(ctx: Context) -> Result<()> { + let vault_lamports = ctx.accounts.vault.to_account_info().lamports(); + + if vault_lamports > 0 { + let escrow_key = ctx.accounts.escrow_account.key(); + let vault_bump = ctx.accounts.escrow_account.vault_bump; + + system_program::transfer( + CpiContext::new_with_signer( + ctx.accounts.system_program.to_account_info(), + Transfer { + from: ctx.accounts.vault.to_account_info(), + to: ctx.accounts.buyer.to_account_info(), + }, + &[&[b"vault", escrow_key.as_ref(), &[vault_bump]]], + ), + vault_lamports, + )?; + } + + Ok(()) +} +``` + +> **Breaking change:** The existing `cancel` instruction had a `seller: Signer` account. It is now `canceller: Signer` with a buyer account added. Fix all existing tests that call cancel. + +- [ ] **Step 2: Update existing tests that use `cancel`** + +Find and update any test that constructs a `Cancel` instruction — the accounts struct has changed (`seller` → `canceller`, new `buyer` account added). + +- [ ] **Step 3: Write new cancel tests** + +```rust +#[test] +fn seller_can_cancel_awaiting_deposit() { + // create_escrow → AwaitingDeposit + // seller calls cancel → EscrowAccount closed + // vault was empty → no refund needed +} + +#[test] +fn buyer_can_cancel_awaiting_deposit() { + // create_escrow → AwaitingDeposit + // buyer calls cancel → EscrowAccount closed (seller loses rent — acceptable) +} + +#[test] +fn seller_can_cancel_awaiting_seller_confirm() { + // buyer_create_escrow → AwaitingSellerConfirm (vault has funds) + // seller calls cancel → vault drained to buyer, EscrowAccount closed to seller + // Assert: buyer received vault lamports back +} + +#[test] +fn buyer_can_cancel_awaiting_seller_confirm() { + // buyer_create_escrow → AwaitingSellerConfirm + // buyer calls cancel → vault drained to buyer, EscrowAccount closed to buyer +} + +#[test] +fn cannot_cancel_active_escrow() { + // Full flow to Active + // cancel → must fail (InvalidState) +} +``` + +- [ ] **Step 4: Build and full test suite** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml && \ +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15 +``` + +--- + +## Task 5: Cleanup + +- [ ] **Step 1: Delete `create_escrow_prefunded.rs`** + +This instruction was planned but never needed now. Remove the file and its references from `lib.rs` and the instructions module. + +```bash +rm programs/descro/src/instructions/create_escrow_prefunded.rs +``` + +- [ ] **Step 2: Rebuild both programs** + +```bash +cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \ +cargo build-sbf --manifest-path programs/descro/Cargo.toml +``` + +- [ ] **Step 3: Full test suite** + +```bash +cargo test --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -5 +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 +``` + +- [ ] **Step 4: Commit** + +```bash +git add programs/descro/ +git commit -m "feat(descro): add buyer-initiated escrow flow (AwaitingSellerConfirm + seller_confirm + symmetric cancel)" +``` diff --git a/docs/superpowers/plans/2026-05-19-solisting.md b/docs/superpowers/plans/2026-05-19-solisting.md index e6aa223..1921eb7 100644 --- a/docs/superpowers/plans/2026-05-19-solisting.md +++ b/docs/superpowers/plans/2026-05-19-solisting.md @@ -2,182 +2,27 @@ > **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. +> ⚠️ **Reference plan — do not implement yet.** Two prerequisites must be completed first: +> 1. `2026-05-19-descro-buyer-flow.md` — adds `buyer_create_escrow`, `seller_confirm`, and symmetric `cancel` to descro +> 2. `AcceptancePolicy` prerequisite from the original solisting plan (adding `AcceptancePolicy` enum to `descro_ext_resolvers`) — already documented in the old plan's prerequisite section -**Goal:** Build the `solisting` Anchor program that handles product listings and buyer-seller order consent, orchestrating the full flow from buyer intent → bilateral consent → active `descro` escrow. +**Goal:** Build the `solisting` Anchor program — a coordination and discovery layer for product listings and bilateral order consent. It orchestrates the full flow from buyer intent → bilateral consent → active `descro` escrow, without ever touching the descro vault directly. -**Architecture:** `solisting` 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. +**Architecture:** +- `solisting` holds two account types: `ListingAccount` (seller's offer with payment options) and `OrderAccount` (pending bilateral consent record). +- Payment method is negotiated per-order: the seller declares supported options in the listing (`DescroPaymentOption` with accepted resolvers), the buyer picks one when creating an order. +- For Descro orders: `create_order` CPIs `descro.buyer_create_escrow` atomically — funds go directly into descro's vault. `accept_order` CPIs `descro.seller_confirm`. Solisting never holds or transfers escrow SOL. +- The `OrderAccount` has no state enum. Its existence means the order is pending. Closure means it is resolved. +- State drift (buyer cancelling directly on descro) is handled gracefully: all terminal solisting instructions read the descro escrow state first and skip the CPI if descro already resolved it. -**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`. +**Key design decisions:** +- No `OrderVault` — funds live in descro's vault from the moment `create_order` is called +- `AcceptancePolicy` is enforced by descro in `seller_confirm`, not by solisting +- Listing `quantity_reserved` tracks pending orders; decremented at `create_order`, restored at `reject_order`/`cancel_order` +- `escrow_id` is derived from the first 8 bytes of the `order_account` PDA to guarantee uniqueness without coordinator state +- `close_stale_order` can be called by anyone to clean up an OrderAccount whose descro escrow is already in a terminal state ---- - -## 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, - 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, - 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" -``` +**Tech Stack:** Rust, Anchor 1.0.x, LiteSVM 0.10.0. Depends on `descro` (CPI features) and `descro_ext_resolvers` (for `AcceptancePolicy` type). Build order: `descro_ext_resolvers` → `descro` → `solisting`. --- @@ -187,18 +32,18 @@ git commit -m "feat(registry): add AcceptancePolicy enum to ResolverEntry" |---|---|---| | 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/state.rs` | ListingAccount + OrderAccount + PaymentOption | | 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/src/instructions/create_listing.rs` | | +| Create | `programs/solisting/src/instructions/update_listing.rs` | | +| Create | `programs/solisting/src/instructions/close_listing.rs` | | +| Create | `programs/solisting/src/instructions/create_order.rs` | Core: validates payment option, CPIs buyer_create_escrow | +| Create | `programs/solisting/src/instructions/accept_order.rs` | CPIs seller_confirm | +| Create | `programs/solisting/src/instructions/reject_order.rs` | CPIs descro.cancel (defensive) | +| Create | `programs/solisting/src/instructions/cancel_order.rs` | CPIs descro.cancel (defensive) | +| Create | `programs/solisting/src/instructions/close_stale_order.rs` | Cleans up orphaned OrderAccounts | +| Create | `programs/solisting/tests/common/mod.rs` | LiteSVM setup + helpers | | Create | `programs/solisting/tests/test_listings.rs` | Listing instruction tests | | Create | `programs/solisting/tests/test_orders.rs` | Order flow tests | | Modify | `Cargo.toml` (workspace root) | Add solisting to workspace members | @@ -207,14 +52,9 @@ git commit -m "feat(registry): add AcceptancePolicy enum to ResolverEntry" ## 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 root `Cargo.toml`** -- [ ] **Step 1: Add `solisting` to workspace** - -In the root `Cargo.toml`, add `"programs/solisting"` to the `[workspace] members` array. +Add `"programs/solisting"` to the `[workspace] members` array. - [ ] **Step 2: Create `programs/solisting/Cargo.toml`** @@ -255,34 +95,13 @@ solana-keypair = "3.0.1" 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** +- [ ] **Step 3: Create directory structure** ```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/lib.rs touch programs/solisting/src/state.rs +touch programs/solisting/src/error.rs touch programs/solisting/src/instructions.rs touch programs/solisting/src/instructions/create_listing.rs touch programs/solisting/src/instructions/update_listing.rs @@ -291,9 +110,10 @@ touch programs/solisting/src/instructions/create_order.rs touch programs/solisting/src/instructions/accept_order.rs touch programs/solisting/src/instructions/reject_order.rs touch programs/solisting/src/instructions/cancel_order.rs +touch programs/solisting/src/instructions/close_stale_order.rs ``` -- [ ] **Step 5: Commit skeleton** +- [ ] **Step 4: Commit skeleton** ```bash git add programs/solisting/ Cargo.toml Cargo.lock @@ -302,12 +122,9 @@ git commit -m "chore(solisting): add crate skeleton and workspace entry" --- -## Task 2: State, Errors, Constants +## Task 2: State and Errors -**Files:** -- `programs/solisting/src/state.rs` -- `programs/solisting/src/error.rs` -- `programs/solisting/src/constants.rs` +**Files:** `state.rs`, `error.rs` - [ ] **Step 1: Write `state.rs`** @@ -319,19 +136,24 @@ use anchor_lang::prelude::*; pub struct ListingAccount { pub seller: Pubkey, pub price: u64, + /// Total units available (including reserved). pub quantity: u32, - pub resolver: Pubkey, + /// Units held by pending (AwaitingSellerAccept) orders. quantity - quantity_reserved = available. + pub quantity_reserved: u32, + /// Descro payment option; None means descro is not accepted for this listing. + pub descro_option: Option, #[max_len(256)] pub metadata_uri: String, pub listing_id: u64, - pub state: ListingState, + pub is_active: bool, pub bump: u8, } #[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)] -pub enum ListingState { - Active, - Closed, +pub struct DescroPaymentOption { + /// Resolvers the seller accepts. Empty = any resolver is acceptable. + #[max_len(4)] + pub accepted_resolvers: Vec, } #[account] @@ -340,24 +162,21 @@ pub struct OrderAccount { pub listing: Pubkey, pub buyer: Pubkey, pub seller: Pubkey, + /// The resolver agreed upon for this order (validated against listing's accepted_resolvers). pub resolver: Pubkey, pub amount: u64, + /// The descro EscrowAccount PDA for this order. + pub escrow_account: Pubkey, + /// Derived from order_account PDA bytes[0..8]; used as descro escrow_id seed. pub escrow_id: u64, - pub 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, } ``` +> **`escrow_id` derivation note:** At `create_order` time, the `order_account` PDA has been derived but not yet initialized. Solisting passes the `escrow_id` as an instruction argument. The frontend/SDK derives it as `u64::from_le_bytes(order_pda.to_bytes()[0..8])`. This ties the escrow_id to the order PDA, ensuring uniqueness as long as `order_id` is unique per buyer+listing. + - [ ] **Step 2: Write `error.rs`** ```rust @@ -371,55 +190,31 @@ pub enum SolistingError { 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, + #[msg("Listing does not support Descro payments")] + DescroNotSupported, + #[msg("Resolver is not accepted by this listing")] + ResolverNotAccepted, + #[msg("Descro escrow is not in expected state — order may have already been resolved")] + EscrowStateUnexpected, + #[msg("Descro program address mismatch")] + InvalidDescroProgram, } ``` -- [ ] **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` +**Files:** `create_listing.rs`, `update_listing.rs`, `close_listing.rs` - [ ] **Step 1: Write `create_listing.rs`** ```rust use anchor_lang::prelude::*; -use crate::state::{ListingAccount, ListingState}; +use crate::state::{DescroPaymentOption, ListingAccount}; #[derive(Accounts)] -#[instruction(price: u64, quantity: u32, resolver: Pubkey, metadata_uri: String, listing_id: u64)] +#[instruction(listing_id: u64)] pub struct CreateListing<'info> { #[account(mut)] pub seller: Signer<'info>, @@ -438,20 +233,21 @@ pub struct CreateListing<'info> { pub fn handler( ctx: Context, + listing_id: u64, price: u64, quantity: u32, - resolver: Pubkey, + descro_option: Option, 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.quantity_reserved = 0; + listing.descro_option = descro_option; listing.metadata_uri = metadata_uri; listing.listing_id = listing_id; - listing.state = ListingState::Active; + listing.is_active = true; listing.bump = ctx.bumps.listing_account; Ok(()) } @@ -461,7 +257,7 @@ pub fn handler( ```rust use anchor_lang::prelude::*; -use crate::state::{ListingAccount, ListingState}; +use crate::state::{DescroPaymentOption, ListingAccount}; use crate::error::SolistingError; #[derive(Accounts)] @@ -473,7 +269,7 @@ pub struct UpdateListing<'info> { 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, + constraint = listing_account.is_active @ SolistingError::ListingNotActive, )] pub listing_account: Account<'info, ListingAccount>, } @@ -482,11 +278,13 @@ pub fn handler( ctx: Context, price: u64, quantity: u32, + descro_option: Option, metadata_uri: String, ) -> Result<()> { let listing = &mut ctx.accounts.listing_account; listing.price = price; listing.quantity = quantity; + listing.descro_option = descro_option; listing.metadata_uri = metadata_uri; Ok(()) } @@ -496,7 +294,7 @@ pub fn handler( ```rust use anchor_lang::prelude::*; -use crate::state::{ListingAccount, ListingState}; +use crate::state::ListingAccount; use crate::error::SolistingError; #[derive(Accounts)] @@ -514,466 +312,48 @@ pub struct CloseListing<'info> { pub listing_account: Account<'info, ListingAccount>, } -pub fn handler(ctx: Context) -> Result<()> { - ctx.accounts.listing_account.state = ListingState::Closed; +pub fn handler(_ctx: Context) -> Result<()> { 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, - 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, - price: u64, - quantity: u32, - metadata_uri: String, - ) -> Result<()> { - update_listing::handler(ctx, price, quantity, metadata_uri) - } - - pub fn close_listing(ctx: Context) -> Result<()> { - close_listing::handler(ctx) - } - - pub fn create_order(ctx: Context, order_id: u64, escrow_id: u64) -> Result<()> { - create_order::handler(ctx, order_id, escrow_id) - } - - pub fn accept_order(ctx: Context) -> Result<()> { - accept_order::handler(ctx) - } - - pub fn reject_order(ctx: Context) -> Result<()> { - reject_order::handler(ctx) - } - - pub fn cancel_order(ctx: Context) -> 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, _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) -> 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) -> 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) -> 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 +## Task 4: `create_order` — Validate + CPI buyer_create_escrow -**Files:** -- `programs/solisting/src/instructions/create_order.rs` +This is the buyer's single transaction: it validates the order, reserves quantity on the listing, CPIs `descro.buyer_create_escrow` to atomically create and fund the escrow, and records the order. -- [ ] **Step 1: Write the failing test** (add to `test_listings.rs` or create `test_orders.rs`) +**File:** `programs/solisting/src/instructions/create_order.rs` -Create `programs/solisting/tests/test_orders.rs`: +- [ ] **Step 1: Write failing test** (in `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_descro() { + // setup listing with DescroPaymentOption (any resolver) + // buyer calls create_order with resolver keypair + // Assert: OrderAccount exists with correct fields + // Assert: descro EscrowAccount exists, state == AwaitingSellerConfirm + // Assert: descro vault has listing.price lamports + // Assert: listing.quantity_reserved == 1 } #[test] -fn buyer_can_create_order() { - let (mut svm, seller, buyer, resolver) = setup(); - setup_listing(&mut svm, &seller, &resolver); +fn create_order_fails_if_listing_inactive() { } - let listing = listing_pda(&seller.pubkey(), LISTING_ID); - send(&mut svm, ix_create_order(&buyer.pubkey(), &seller.pubkey(), &listing), &buyer); +#[test] +fn create_order_fails_if_out_of_stock() { } - 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_resolver_not_accepted() { + // listing has specific accepted_resolvers = [resolver_a] + // buyer tries with resolver_b → must fail } #[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, - )); +fn create_order_fails_if_descro_not_supported() { + // listing has descro_option = None + // buyer tries to create descro order → must fail } ``` @@ -981,25 +361,24 @@ fn create_order_fails_if_listing_inactive() { ```rust use anchor_lang::prelude::*; -use anchor_lang::system_program::{self, Transfer}; -use crate::state::{ListingAccount, ListingState, OrderAccount, OrderState}; +use crate::state::{ListingAccount, OrderAccount}; use crate::error::SolistingError; #[derive(Accounts)] -#[instruction(order_id: u64, escrow_id: u64)] +#[instruction(order_id: u64, escrow_id: u64, resolver: Pubkey)] pub struct CreateOrder<'info> { #[account(mut)] pub buyer: Signer<'info>, - /// CHECK: seller pubkey read from listing; stored in order + /// CHECK: Seller — verified against listing pub seller: UncheckedAccount<'info>, #[account( mut, seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], bump = listing_account.bump, - constraint = listing_account.state == ListingState::Active @ SolistingError::ListingNotActive, - constraint = listing_account.quantity > 0 @ SolistingError::OutOfStock, + constraint = listing_account.is_active @ SolistingError::ListingNotActive, + constraint = listing_account.quantity > listing_account.quantity_reserved @ SolistingError::OutOfStock, constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, )] pub listing_account: Account<'info, ListingAccount>, @@ -1013,491 +392,7 @@ pub struct CreateOrder<'info> { )] 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, 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) -> 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) -> 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 + /// CHECK: Descro EscrowAccount PDA — created by the CPI #[account( mut, seeds = [b"escrow", seller.key().as_ref(), &escrow_id.to_le_bytes()], @@ -1506,7 +401,7 @@ pub struct AcceptOrder<'info> { )] pub escrow_account: UncheckedAccount<'info>, - /// CHECK: Descro vault PDA — receives SOL from order_vault before CPI + /// CHECK: Descro vault PDA — funded by the CPI #[account( mut, seeds = [b"vault", escrow_account.key().as_ref()], @@ -1515,133 +410,595 @@ pub struct AcceptOrder<'info> { )] 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 + /// CHECK: Descro program — verified in handler pub descro_program: UncheckedAccount<'info>, pub system_program: Program<'info, System>, } -pub fn handler(ctx: Context, escrow_id: u64) -> Result<()> { +pub fn handler( + ctx: Context, + order_id: u64, + escrow_id: u64, + resolver: Pubkey, +) -> Result<()> { require!( ctx.accounts.descro_program.key() == descro::id(), - SolistingError::Unauthorized + SolistingError::InvalidDescroProgram ); - // 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) + let listing = &ctx.accounts.listing_account; + + // Validate descro payment option is supported + let descro_opt = listing.descro_option.as_ref() + .ok_or(SolistingError::DescroNotSupported)?; + + // Validate resolver is accepted (empty list = any resolver ok) + if !descro_opt.accepted_resolvers.is_empty() { require!( - ctx.accounts.resolver.is_signer, - SolistingError::ResolverSignatureRequired + descro_opt.accepted_resolvers.contains(&resolver), + SolistingError::ResolverNotAccepted ); } - let order = &ctx.accounts.order_account; - let amount = order.amount; - let order_key = order.key(); - let vault_bump = order.vault_bump; + let amount = listing.price; - // 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( + // CPI: descro.buyer_create_escrow — creates EscrowAccount + funds vault atomically + descro::cpi::buyer_create_escrow( CpiContext::new( ctx.accounts.descro_program.to_account_info(), - descro::cpi::accounts::CreateEscrowPrefunded { - seller: ctx.accounts.seller.to_account_info(), + descro::cpi::accounts::BuyerCreateEscrow { buyer: ctx.accounts.buyer.to_account_info(), + seller: ctx.accounts.seller.to_account_info(), escrow_account: ctx.accounts.escrow_account.to_account_info(), vault: ctx.accounts.descro_vault.to_account_info(), system_program: ctx.accounts.system_program.to_account_info(), }, ), amount, - Some(ctx.accounts.resolver.key()), + Some(resolver), escrow_id, )?; - // Decrement listing quantity - ctx.accounts.listing_account.quantity -= 1; + // Reserve quantity + ctx.accounts.listing_account.quantity_reserved += 1; + + // Record order + let order = &mut ctx.accounts.order_account; + order.listing = ctx.accounts.listing_account.key(); + order.buyer = ctx.accounts.buyer.key(); + order.seller = ctx.accounts.seller.key(); + order.resolver = resolver; + order.amount = amount; + order.escrow_account = ctx.accounts.escrow_account.key(); + order.escrow_id = escrow_id; + order.order_id = order_id; + order.created_at = Clock::get()?.unix_timestamp; + order.bump = ctx.bumps.order_account; + Ok(()) +} +``` + +--- + +## Task 5: `accept_order` — CPI seller_confirm + +The seller accepts: CPIs `descro.seller_confirm` to move the escrow to `Active`, then closes the `OrderAccount`. + +**File:** `programs/solisting/src/instructions/accept_order.rs` + +- [ ] **Step 1: Write failing test** + +```rust +#[test] +fn seller_accept_creates_active_descro_escrow() { + // create_order → AwaitingSellerConfirm + // seller calls accept_order + // Assert: descro escrow state == Active + // Assert: OrderAccount is closed + // Assert: listing.quantity_reserved == 0 + // Assert: listing.quantity == original - 1 +} +``` + +- [ ] **Step 2: Implement `accept_order.rs`** + +```rust +use anchor_lang::prelude::*; +use crate::state::{ListingAccount, OrderAccount}; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct AcceptOrder<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + /// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated (descro enforces this) + pub resolver: AccountInfo<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, + )] + pub listing_account: Account<'info, ListingAccount>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized, + close = seller, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — verified via seeds, state checked by descro.seller_confirm + #[account( + mut, + seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()], + bump, + seeds::program = descro::id(), + constraint = escrow_account.key() == order_account.escrow_account @ SolistingError::EscrowStateUnexpected, + )] + pub escrow_account: UncheckedAccount<'info>, + + /// CHECK: Optional resolver registry entry — passed through to descro.seller_confirm for AcceptancePolicy check + pub resolver_entry: UncheckedAccount<'info>, + + /// CHECK: Descro program + pub descro_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.descro_program.key() == descro::id(), + SolistingError::InvalidDescroProgram + ); + + // CPI: descro.seller_confirm — descro checks AcceptancePolicy internally + descro::cpi::seller_confirm( + CpiContext::new( + ctx.accounts.descro_program.to_account_info(), + descro::cpi::accounts::SellerConfirm { + seller: ctx.accounts.seller.to_account_info(), + resolver: ctx.accounts.resolver.to_account_info(), + escrow_account: ctx.accounts.escrow_account.to_account_info(), + resolver_entry: ctx.accounts.resolver_entry.to_account_info(), + }, + ), + )?; + + // Commit quantity: reserved → sold + let listing = &mut ctx.accounts.listing_account; + listing.quantity_reserved = listing.quantity_reserved.saturating_sub(1); + listing.quantity = listing.quantity.saturating_sub(1); Ok(()) } ``` -- [ ] **Step 3: Build** +--- -```bash -cargo build-sbf --manifest-path programs/solisting/Cargo.toml 2>&1 | tail -5 +## Task 6: `reject_order` and `cancel_order` — Defensive CPI to descro.cancel + +Both instructions read the descro escrow state before attempting the cancel CPI. If descro already resolved the escrow (buyer cancelled directly), the CPI is skipped and the `OrderAccount` is still closed. This prevents stuck accounts and failed transactions. + +**Files:** `reject_order.rs`, `cancel_order.rs` + +- [ ] **Step 1: Write failing tests** + +```rust +#[test] +fn seller_can_reject_order() { + // create_order → AwaitingSellerConfirm + // seller calls reject_order + // Assert: OrderAccount closed + // Assert: buyer received SOL back (descro vault drained) + // Assert: listing.quantity_reserved == 0 +} + +#[test] +fn buyer_can_cancel_order_anytime() { + // create_order → AwaitingSellerConfirm + // buyer calls cancel_order immediately (no timeout) + // Assert: same as reject — funds returned, order closed +} + +#[test] +fn reject_handles_already_cancelled_escrow() { + // buyer calls descro.cancel directly (bypassing solisting) + // seller calls solisting.reject_order + // Assert: no panic, OrderAccount is still closed cleanly +} + +#[test] +fn cancel_handles_already_cancelled_escrow() { + // buyer calls descro.cancel directly + // buyer calls solisting.cancel_order + // Assert: OrderAccount closed, no CPI error +} ``` -- [ ] **Step 4: Run all order tests** +- [ ] **Step 2: Implement `reject_order.rs`** -```bash -cargo test --manifest-path programs/solisting/Cargo.toml --test test_orders 2>&1 | tail -20 +```rust +use anchor_lang::prelude::*; +use crate::state::{ListingAccount, OrderAccount}; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct RejectOrder<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + /// CHECK: Buyer receives the vault refund from descro.cancel + #[account(mut)] + pub buyer: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + constraint = seller.key() == listing_account.seller @ SolistingError::Unauthorized, + )] + pub listing_account: Account<'info, ListingAccount>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + constraint = seller.key() == order_account.seller @ SolistingError::Unauthorized, + constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized, + close = seller, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — read to check current state + #[account( + mut, + seeds = [b"escrow", seller.key().as_ref(), &order_account.escrow_id.to_le_bytes()], + bump, + seeds::program = descro::id(), + )] + pub escrow_account: UncheckedAccount<'info>, + + /// CHECK: Descro vault — drained to buyer by descro.cancel + #[account( + mut, + seeds = [b"vault", escrow_account.key().as_ref()], + bump, + seeds::program = descro::id(), + )] + pub vault: UncheckedAccount<'info>, + + /// CHECK: Descro program + pub descro_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.descro_program.key() == descro::id(), + SolistingError::InvalidDescroProgram + ); + + // Defensive: only CPI if escrow is still in AwaitingSellerConfirm + // If buyer already cancelled directly, skip CPI — just close the order + if !ctx.accounts.escrow_account.data_is_empty() { + let data = ctx.accounts.escrow_account.try_borrow_data()?; + let escrow = descro::EscrowAccount::try_deserialize(&mut data.as_ref()); + if let Ok(e) = escrow { + if e.state == descro::EscrowState::AwaitingSellerConfirm { + drop(data); + descro::cpi::cancel( + CpiContext::new( + ctx.accounts.descro_program.to_account_info(), + descro::cpi::accounts::Cancel { + canceller: ctx.accounts.seller.to_account_info(), + buyer: ctx.accounts.buyer.to_account_info(), + escrow_account: ctx.accounts.escrow_account.to_account_info(), + vault: ctx.accounts.vault.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + }, + ), + )?; + } + } + } + + // Restore reserved quantity + ctx.accounts.listing_account.quantity_reserved = + ctx.accounts.listing_account.quantity_reserved.saturating_sub(1); + + Ok(()) +} ``` -Expected: all tests pass. +- [ ] **Step 3: Implement `cancel_order.rs`** -- [ ] **Step 5: Run full suite for all three programs** +Identical to `reject_order.rs` except: +- `buyer: Signer<'info>` instead of `seller: Signer<'info>` +- Constraint checks buyer == order.buyer +- CPI `canceller` = buyer +- `close = buyer` +- No timeout — buyer can cancel anytime pre-Active + +```rust +use anchor_lang::prelude::*; +use crate::state::{ListingAccount, OrderAccount}; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct CancelOrder<'info> { + #[account(mut)] + pub buyer: Signer<'info>, + + #[account( + mut, + seeds = [b"listing", listing_account.seller.as_ref(), &listing_account.listing_id.to_le_bytes()], + bump = listing_account.bump, + )] + pub listing_account: Account<'info, ListingAccount>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + constraint = buyer.key() == order_account.buyer @ SolistingError::Unauthorized, + close = buyer, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — read defensively + #[account( + mut, + seeds = [b"escrow", order_account.seller.as_ref(), &order_account.escrow_id.to_le_bytes()], + bump, + seeds::program = descro::id(), + )] + pub escrow_account: UncheckedAccount<'info>, + + /// CHECK: Descro vault + #[account( + mut, + seeds = [b"vault", escrow_account.key().as_ref()], + bump, + seeds::program = descro::id(), + )] + pub vault: UncheckedAccount<'info>, + + /// CHECK: Descro program + pub descro_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.descro_program.key() == descro::id(), + SolistingError::InvalidDescroProgram + ); + + if !ctx.accounts.escrow_account.data_is_empty() { + let data = ctx.accounts.escrow_account.try_borrow_data()?; + let escrow = descro::EscrowAccount::try_deserialize(&mut data.as_ref()); + if let Ok(e) = escrow { + if e.state == descro::EscrowState::AwaitingSellerConfirm { + drop(data); + descro::cpi::cancel( + CpiContext::new( + ctx.accounts.descro_program.to_account_info(), + descro::cpi::accounts::Cancel { + canceller: ctx.accounts.buyer.to_account_info(), + buyer: ctx.accounts.buyer.to_account_info(), + escrow_account: ctx.accounts.escrow_account.to_account_info(), + vault: ctx.accounts.vault.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + }, + ), + )?; + } + } + } + + ctx.accounts.listing_account.quantity_reserved = + ctx.accounts.listing_account.quantity_reserved.saturating_sub(1); + + Ok(()) +} +``` + +--- + +## Task 7: `close_stale_order` — Cleanup Orphaned Orders + +Anyone can call this to close an `OrderAccount` whose descro escrow is in a terminal state (`Cancelled` or `Complete`). Rent goes to the caller as incentive. + +**File:** `programs/solisting/src/instructions/close_stale_order.rs` + +```rust +use anchor_lang::prelude::*; +use crate::state::OrderAccount; +use crate::error::SolistingError; + +#[derive(Accounts)] +pub struct CloseStaleOrder<'info> { + #[account(mut)] + pub caller: Signer<'info>, + + #[account( + mut, + seeds = [b"order", order_account.listing.as_ref(), order_account.buyer.as_ref(), &order_account.order_id.to_le_bytes()], + bump = order_account.bump, + close = caller, + )] + pub order_account: Account<'info, OrderAccount>, + + /// CHECK: Descro EscrowAccount — must be in terminal state + pub escrow_account: UncheckedAccount<'info>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require!( + ctx.accounts.escrow_account.key() == ctx.accounts.order_account.escrow_account, + SolistingError::EscrowStateUnexpected + ); + + // Only allow close if escrow is terminal or gone (already closed) + if !ctx.accounts.escrow_account.data_is_empty() { + let data = ctx.accounts.escrow_account.try_borrow_data()?; + if let Ok(escrow) = descro::EscrowAccount::try_deserialize(&mut data.as_ref()) { + require!( + escrow.state == descro::EscrowState::Cancelled + || escrow.state == descro::EscrowState::Complete, + SolistingError::EscrowStateUnexpected + ); + } + } + + Ok(()) +} +``` + +--- + +## Task 8: Wire Up `lib.rs` and `instructions.rs`, Build, Test + +- [ ] **Step 1: Write `instructions.rs`** + +```rust +#![allow(ambiguous_glob_reexports)] + +pub mod accept_order; +pub mod cancel_order; +pub mod close_listing; +pub mod close_stale_order; +pub mod create_listing; +pub mod create_order; +pub mod reject_order; +pub mod update_listing; + +pub use accept_order::*; +pub use cancel_order::*; +pub use close_listing::*; +pub use close_stale_order::*; +pub use create_listing::*; +pub use create_order::*; +pub use reject_order::*; +pub use update_listing::*; +``` + +- [ ] **Step 2: Write `lib.rs`** + +```rust +pub mod error; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use error::*; +pub use instructions::*; +pub use state::*; + +declare_id!("So1istingProgramID11111111111111111111111111"); // replace after anchor keys list + +#[program] +pub mod solisting { + use super::*; + + pub fn create_listing( + ctx: Context, + listing_id: u64, + price: u64, + quantity: u32, + descro_option: Option, + metadata_uri: String, + ) -> Result<()> { + create_listing::handler(ctx, listing_id, price, quantity, descro_option, metadata_uri) + } + + pub fn update_listing( + ctx: Context, + price: u64, + quantity: u32, + descro_option: Option, + metadata_uri: String, + ) -> Result<()> { + update_listing::handler(ctx, price, quantity, descro_option, metadata_uri) + } + + pub fn close_listing(ctx: Context) -> Result<()> { + close_listing::handler(ctx) + } + + pub fn create_order( + ctx: Context, + order_id: u64, + escrow_id: u64, + resolver: Pubkey, + ) -> Result<()> { + create_order::handler(ctx, order_id, escrow_id, resolver) + } + + pub fn accept_order(ctx: Context) -> Result<()> { + accept_order::handler(ctx) + } + + pub fn reject_order(ctx: Context) -> Result<()> { + reject_order::handler(ctx) + } + + pub fn cancel_order(ctx: Context) -> Result<()> { + cancel_order::handler(ctx) + } + + pub fn close_stale_order(ctx: Context) -> Result<()> { + close_stale_order::handler(ctx) + } +} +``` + +- [ ] **Step 3: Build all three programs** ```bash cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \ cargo build-sbf --manifest-path programs/descro/Cargo.toml && \ -cargo build-sbf --manifest-path programs/solisting/Cargo.toml && \ -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 build-sbf --manifest-path programs/solisting/Cargo.toml +``` + +- [ ] **Step 4: Run full test suite** + +```bash +cargo test --manifest-path programs/descro_ext_resolvers/Cargo.toml 2>&1 | tail -5 +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 cargo test --manifest-path programs/solisting/Cargo.toml 2>&1 | tail -5 ``` Expected: all green. -- [ ] **Step 6: Commit** +- [ ] **Step 5: Final 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" +git add programs/solisting/ Cargo.toml Cargo.lock +git commit -m "feat(solisting): implement listing + order flow with buyer-initiated descro escrow" ``` --- ## Done -Full buyer-to-escrow flow 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) +Full buyer-to-escrow flow across three programs with clean separation: +- `solisting` = coordination and discovery only; never holds escrow SOL +- `descro` = sole custodian of vault; enforces AcceptancePolicy +- `descro_ext_resolvers` = resolver registry; read by descro at seller_confirm time + +Remaining items for future phases: +- USDC/SPL token payment option in `ListingAccount` +- `ProgramGated` resolver CPI in `descro.seller_confirm` - Event emission (`emit!()`) for off-chain indexers +- Guard against closing listing while pending orders exist (`quantity_reserved > 0`)