From dfbad1315fd40448d176b848adb01dacf9fc06ff Mon Sep 17 00:00:00 2001 From: thesn10 <38666407+thesn10@users.noreply.github.com> Date: Tue, 19 May 2026 17:41:54 +0200 Subject: [PATCH] add implementation plans for descro flow and solisting Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-19-descro-flow.md | 1109 +++++++++++ .../superpowers/plans/2026-05-19-solisting.md | 1647 +++++++++++++++++ 2 files changed, 2756 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-descro-flow.md create mode 100644 docs/superpowers/plans/2026-05-19-solisting.md diff --git a/docs/superpowers/plans/2026-05-19-descro-flow.md b/docs/superpowers/plans/2026-05-19-descro-flow.md new file mode 100644 index 0000000..fd2f81a --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-descro-flow.md @@ -0,0 +1,1109 @@ +# Descro Flow 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. + +**Goal:** Extend the existing `descro` escrow program with two new instructions (`create_escrow_prefunded`, `emergency_resolve`) and record a dispute timestamp in `EscrowAccount` to enable timeout-based bilateral resolution when a resolver disappears. + +**Architecture:** `create_escrow_prefunded` starts an escrow in `Active` state immediately, requiring the vault PDA to be pre-funded before the call — this allows external orchestrators (e.g. `solisting`) to fund the vault themselves via system transfer and then CPI into descro without descro needing to know anything about the caller. `emergency_resolve` lets both buyer and seller bypass a missing resolver after 14 days, requiring both signatures atomically. All other instructions remain unchanged except `dispute()` which now records `unix_timestamp` on the account. + +**Tech Stack:** Rust, Anchor 1.0.x, LiteSVM 0.10.0 for tests. Build: `cargo build-sbf`, test: `cargo test`. + +--- + +## File Map + +| Action | Path | Responsibility | +|---|---|---| +| Modify | `programs/descro/src/state.rs` | Add `dispute_raised_at: Option` | +| Modify | `programs/descro/src/constants.rs` | Add `DISPUTE_TIMEOUT_SECS` | +| Modify | `programs/descro/src/error.rs` | Add `DisputeTimeoutNotReached`, `InsufficientVaultBalance` | +| Modify | `programs/descro/src/instructions/create_escrow.rs` | Init `dispute_raised_at = None` | +| Modify | `programs/descro/src/instructions/dispute.rs` | Set `dispute_raised_at` via Clock | +| Create | `programs/descro/src/instructions/create_escrow_prefunded.rs` | New instruction | +| Create | `programs/descro/src/instructions/emergency_resolve.rs` | New instruction | +| Modify | `programs/descro/src/instructions.rs` | Register new modules | +| Modify | `programs/descro/src/lib.rs` | Register new entrypoints | +| Modify | `programs/descro/tests/common/mod.rs` | Add multi-signer helpers + new ix builders | +| Modify | `programs/descro/tests/test_dispute.rs` | Assert `dispute_raised_at` is set | +| Create | `programs/descro/tests/test_create_escrow_prefunded.rs` | Tests for new instruction | +| Create | `programs/descro/tests/test_emergency_resolve.rs` | Tests for new instruction | + +--- + +## Task 1: Add `dispute_raised_at` to state and constants + +**Files:** +- Modify: `programs/descro/src/state.rs` +- Modify: `programs/descro/src/constants.rs` + +- [ ] **Step 1: Add the field to `EscrowAccount`** + +Replace the entire `programs/descro/src/state.rs` with: + +```rust +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct EscrowAccount { + pub seller: Pubkey, + pub buyer: Pubkey, + pub amount: u64, + pub dispute_resolver: Option, + pub state: EscrowState, + pub bump: u8, + pub vault_bump: u8, + pub escrow_id: u64, + pub dispute_raised_at: Option, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, InitSpace, Debug)] +pub enum EscrowState { + AwaitingDeposit, + Active, + Disputed, + Complete, + Cancelled, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq)] +pub enum Winner { + Buyer, + Seller, +} +``` + +- [ ] **Step 2: Add the timeout constant** + +Replace `programs/descro/src/constants.rs` with: + +```rust +pub const DISPUTE_TIMEOUT_SECS: i64 = 14 * 24 * 60 * 60; // 14 days +``` + +- [ ] **Step 3: Verify it compiles (programs must be built before tests run)** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 +``` + +Expected: compile error in `create_escrow.rs` — field `dispute_raised_at` not initialized. This is expected; we fix it in Step 4. + +- [ ] **Step 4: Initialize `dispute_raised_at` in `create_escrow` handler** + +In `programs/descro/src/instructions/create_escrow.rs`, add one line at the end of the handler body (before `Ok(())`): + +```rust +pub fn handler( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, +) -> Result<()> { + 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::AwaitingDeposit; + escrow.bump = ctx.bumps.escrow_account; + escrow.vault_bump = ctx.bumps.vault; + escrow.escrow_id = escrow_id; + escrow.dispute_raised_at = None; + Ok(()) +} +``` + +- [ ] **Step 5: Build cleanly** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 +``` + +Expected: `Finished` with no errors. + +- [ ] **Step 6: Run existing tests to confirm nothing broke** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15 +``` + +Expected: all existing tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add programs/descro/src/state.rs programs/descro/src/constants.rs programs/descro/src/instructions/create_escrow.rs +git commit -m "feat(descro): add dispute_raised_at field and DISPUTE_TIMEOUT_SECS constant" +``` + +--- + +## Task 2: Add new errors + +**Files:** +- Modify: `programs/descro/src/error.rs` + +- [ ] **Step 1: Add the two new error variants** + +Replace `programs/descro/src/error.rs` with: + +```rust +use anchor_lang::prelude::*; + +#[error_code] +pub enum EscrowError { + #[msg("Invalid state for this instruction")] + InvalidState, + #[msg("Signer is not authorized")] + Unauthorized, + #[msg("No resolver configured for this escrow")] + NoResolverConfigured, + #[msg("Signer is not the configured resolver")] + UnauthorizedResolver, + #[msg("Escrow has expired")] + Expired, + #[msg("Dispute timeout period has not elapsed yet")] + DisputeTimeoutNotReached, + #[msg("Vault balance is insufficient for the requested escrow amount")] + InsufficientVaultBalance, +} +``` + +- [ ] **Step 2: Build to confirm no errors** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3 +``` + +Expected: `Finished`. + +- [ ] **Step 3: Commit** + +```bash +git add programs/descro/src/error.rs +git commit -m "feat(descro): add DisputeTimeoutNotReached and InsufficientVaultBalance errors" +``` + +--- + +## Task 3: Modify `dispute()` to record timestamp + +**Files:** +- Modify: `programs/descro/src/instructions/dispute.rs` +- Modify: `programs/descro/tests/test_dispute.rs` + +- [ ] **Step 1: Write the failing test** + +Add this test to `programs/descro/tests/test_dispute.rs` (after the existing tests): + +```rust +#[test] +fn dispute_records_timestamp() { + let (mut svm, seller, buyer, _resolver) = setup(); + send( + &mut svm, + ix_create_escrow(&seller.pubkey(), &buyer.pubkey(), AMOUNT, None, ESCROW_ID), + &seller, + ); + send(&mut svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + send(&mut svm, ix_dispute(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + + let escrow = read_escrow(&svm, &seller.pubkey(), ESCROW_ID); + assert!(escrow.dispute_raised_at.is_some()); + assert!(escrow.dispute_raised_at.unwrap() > 0); +} +``` + +- [ ] **Step 2: Build (required before tests run)** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3 +``` + +- [ ] **Step 3: Run the failing test** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml -- dispute_records_timestamp 2>&1 | tail -10 +``` + +Expected: FAIL — `dispute_raised_at` is `None` because the handler doesn't set it yet. + +- [ ] **Step 4: Implement — update dispute handler** + +Replace `programs/descro/src/instructions/dispute.rs` with: + +```rust +use anchor_lang::prelude::*; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +pub struct Dispute<'info> { + pub initiator: Signer<'info>, + + #[account( + mut, + seeds = [b"escrow", escrow_account.seller.as_ref(), &escrow_account.escrow_id.to_le_bytes()], + bump = escrow_account.bump, + constraint = ( + initiator.key() == escrow_account.buyer || + initiator.key() == escrow_account.seller + ) @ EscrowError::Unauthorized, + constraint = escrow_account.state == EscrowState::Active @ EscrowError::InvalidState, + )] + pub escrow_account: Account<'info, EscrowAccount>, +} + +pub fn handler(ctx: Context) -> Result<()> { + let escrow = &mut ctx.accounts.escrow_account; + escrow.state = EscrowState::Disputed; + escrow.dispute_raised_at = Some(Clock::get()?.unix_timestamp); + Ok(()) +} +``` + +- [ ] **Step 5: Rebuild** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3 +``` + +- [ ] **Step 6: Run all dispute tests** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml --test test_dispute 2>&1 | tail -10 +``` + +Expected: all tests pass including the new `dispute_records_timestamp`. + +- [ ] **Step 7: Commit** + +```bash +git add programs/descro/src/instructions/dispute.rs programs/descro/tests/test_dispute.rs +git commit -m "feat(descro): dispute() records unix_timestamp in dispute_raised_at" +``` + +--- + +## Task 4: Implement `create_escrow_prefunded` + +An escrow that starts `Active` immediately. The vault PDA must already hold ≥ `amount` lamports before this instruction is called (funded externally, e.g. by `solisting`). The seller signs to pay rent for the `EscrowAccount`. + +**Files:** +- Create: `programs/descro/src/instructions/create_escrow_prefunded.rs` +- Modify: `programs/descro/src/instructions.rs` +- Modify: `programs/descro/src/lib.rs` +- Modify: `programs/descro/tests/common/mod.rs` +- Create: `programs/descro/tests/test_create_escrow_prefunded.rs` + +- [ ] **Step 1: Write the failing tests** + +Create `programs/descro/tests/test_create_escrow_prefunded.rs`: + +```rust +mod common; +use common::*; +use solana_signer::Signer; + +#[test] +fn prefunded_creates_active_escrow() { + let (mut svm, seller, buyer, resolver) = setup(); + + // Pre-fund the vault PDA before the instruction + let escrow = escrow_pda(&seller.pubkey(), ESCROW_ID); + let vault = vault_pda(&escrow); + svm.airdrop(&vault, AMOUNT).unwrap(); + + send( + &mut svm, + ix_create_escrow_prefunded( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + ESCROW_ID, + ), + &seller, + ); + + let escrow_account = read_escrow(&svm, &seller.pubkey(), ESCROW_ID); + assert_eq!(escrow_account.state, EscrowState::Active); + assert_eq!(escrow_account.seller, seller.pubkey()); + assert_eq!(escrow_account.buyer, buyer.pubkey()); + assert_eq!(escrow_account.amount, AMOUNT); + assert_eq!(escrow_account.dispute_raised_at, None); +} + +#[test] +fn prefunded_fails_if_vault_underfunded() { + let (mut svm, seller, buyer, resolver) = setup(); + + // Fund vault with less than AMOUNT + let escrow = escrow_pda(&seller.pubkey(), ESCROW_ID); + let vault = vault_pda(&escrow); + svm.airdrop(&vault, AMOUNT - 1).unwrap(); + + assert!(!try_send( + &mut svm, + ix_create_escrow_prefunded( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + ESCROW_ID, + ), + &seller, + )); +} + +#[test] +fn prefunded_without_resolver_still_works() { + let (mut svm, seller, buyer, _resolver) = setup(); + + let escrow = escrow_pda(&seller.pubkey(), ESCROW_ID); + let vault = vault_pda(&escrow); + svm.airdrop(&vault, AMOUNT).unwrap(); + + send( + &mut svm, + ix_create_escrow_prefunded( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + None, + ESCROW_ID, + ), + &seller, + ); + + let escrow_account = read_escrow(&svm, &seller.pubkey(), ESCROW_ID); + assert_eq!(escrow_account.state, EscrowState::Active); + assert_eq!(escrow_account.dispute_resolver, None); +} + +#[test] +fn prefunded_escrow_can_be_completed() { + let (mut svm, seller, buyer, _resolver) = setup(); + + let escrow = escrow_pda(&seller.pubkey(), ESCROW_ID); + let vault = vault_pda(&escrow); + svm.airdrop(&vault, AMOUNT).unwrap(); + + send( + &mut svm, + ix_create_escrow_prefunded( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + None, + ESCROW_ID, + ), + &seller, + ); + + let seller_before = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + send(&mut svm, ix_complete(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + + let seller_after = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(seller_after > seller_before); + assert!(svm.get_account(&escrow_pda(&seller.pubkey(), ESCROW_ID)).is_none()); +} +``` + +- [ ] **Step 2: Add test helper `ix_create_escrow_prefunded` to `common/mod.rs`** + +Add at the end of `programs/descro/tests/common/mod.rs`: + +```rust +pub fn ix_create_escrow_prefunded( + seller: &Pubkey, + buyer: &Pubkey, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, +) -> Instruction { + let escrow = escrow_pda(seller, escrow_id); + let vault = vault_pda(&escrow); + Instruction::new_with_bytes( + descro::id(), + &descro::instruction::CreateEscrowPrefunded { + amount, + dispute_resolver, + escrow_id, + } + .data(), + descro::accounts::CreateEscrowPrefunded { + seller: *seller, + buyer: *buyer, + escrow_account: escrow, + vault, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} +``` + +- [ ] **Step 3: Attempt to build — confirm it fails because the instruction doesn't exist** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | grep "error" | head -5 +``` + +Expected: error about missing `CreateEscrowPrefunded` type. + +- [ ] **Step 4: Create the instruction file** + +Create `programs/descro/src/instructions/create_escrow_prefunded.rs`: + +```rust +use anchor_lang::prelude::*; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +#[instruction(amount: u64, dispute_resolver: Option, escrow_id: u64)] +pub struct CreateEscrowPrefunded<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + /// CHECK: Only stored as pubkey, no ownership check needed + pub buyer: UncheckedAccount<'info>, + + #[account( + init, + payer = seller, + 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: Pre-funded vault PDA — balance verified in handler + #[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 vault_lamports = ctx.accounts.vault.to_account_info().lamports(); + require!(vault_lamports >= amount, EscrowError::InsufficientVaultBalance); + + 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::Active; + escrow.bump = ctx.bumps.escrow_account; + escrow.vault_bump = ctx.bumps.vault; + escrow.escrow_id = escrow_id; + escrow.dispute_raised_at = None; + Ok(()) +} +``` + +- [ ] **Step 5: Register the module in `instructions.rs`** + +Replace `programs/descro/src/instructions.rs` with: + +```rust +#![allow(ambiguous_glob_reexports)] + +pub mod cancel; +pub mod complete; +pub mod create_escrow; +pub mod create_escrow_prefunded; +pub mod deposit; +pub mod dispute; +pub mod resolve; + +pub use cancel::*; +pub use complete::*; +pub use create_escrow::*; +pub use create_escrow_prefunded::*; +pub use deposit::*; +pub use dispute::*; +pub use resolve::*; +``` + +- [ ] **Step 6: Register the entrypoint in `lib.rs`** + +Add the new entrypoint inside the `#[program]` mod in `programs/descro/src/lib.rs`: + +```rust +#[program] +pub mod descro { + use super::*; + + pub fn create_escrow( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, + ) -> Result<()> { + create_escrow::handler(ctx, amount, dispute_resolver, escrow_id) + } + + pub fn create_escrow_prefunded( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, + ) -> Result<()> { + create_escrow_prefunded::handler(ctx, amount, dispute_resolver, escrow_id) + } + + pub fn deposit(ctx: Context) -> Result<()> { + deposit::handler(ctx) + } + + pub fn complete(ctx: Context) -> Result<()> { + complete::handler(ctx) + } + + pub fn dispute(ctx: Context) -> Result<()> { + dispute::handler(ctx) + } + + pub fn resolve(ctx: Context, winner: Winner) -> Result<()> { + resolve::handler(ctx, winner) + } + + pub fn cancel(ctx: Context) -> Result<()> { + cancel::handler(ctx) + } +} +``` + +- [ ] **Step 7: Build** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 +``` + +Expected: `Finished` with no errors. + +- [ ] **Step 8: Run the new tests** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml --test test_create_escrow_prefunded 2>&1 | tail -15 +``` + +Expected: all 4 tests pass. + +- [ ] **Step 9: Run full test suite to confirm no regressions** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add programs/descro/src/instructions/create_escrow_prefunded.rs \ + programs/descro/src/instructions.rs \ + programs/descro/src/lib.rs \ + programs/descro/tests/common/mod.rs \ + programs/descro/tests/test_create_escrow_prefunded.rs +git commit -m "feat(descro): add create_escrow_prefunded instruction" +``` + +--- + +## Task 5: Implement `emergency_resolve` + +Requires both buyer AND seller to sign. Only available after `DISPUTE_TIMEOUT_SECS` (14 days) have elapsed since `dispute_raised_at`. Sends vault funds to the declared winner and closes the escrow account (rent goes to seller). + +**Files:** +- Modify: `programs/descro/tests/common/mod.rs` +- Create: `programs/descro/src/instructions/emergency_resolve.rs` +- Modify: `programs/descro/src/instructions.rs` +- Modify: `programs/descro/src/lib.rs` +- Create: `programs/descro/tests/test_emergency_resolve.rs` + +- [ ] **Step 1: Add multi-signer helpers to `common/mod.rs`** + +Add these two functions at the end of `programs/descro/tests/common/mod.rs`: + +```rust +/// Send a transaction signed by multiple keypairs. `payer` is the fee payer and must be first. +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() +} +``` + +Also add `ix_emergency_resolve` builder at the end of `common/mod.rs`: + +```rust +pub fn ix_emergency_resolve( + buyer: &Pubkey, + seller: &Pubkey, + winner_pubkey: &Pubkey, + escrow_id: u64, + winner: Winner, +) -> Instruction { + let escrow = escrow_pda(seller, escrow_id); + let vault = vault_pda(&escrow); + Instruction::new_with_bytes( + descro::id(), + &descro::instruction::EmergencyResolve { winner }.data(), + descro::accounts::EmergencyResolve { + buyer: *buyer, + seller: *seller, + winner: *winner_pubkey, + escrow_account: escrow, + vault, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} +``` + +- [ ] **Step 2: Write the failing tests** + +Create `programs/descro/tests/test_emergency_resolve.rs`: + +```rust +mod common; +use common::*; +use anchor_lang::solana_program::clock::Clock; +use solana_signer::Signer; + +const TIMEOUT: i64 = 14 * 24 * 60 * 60; // matches DISPUTE_TIMEOUT_SECS + +fn setup_disputed( + svm: &mut litesvm::LiteSVM, + seller: &solana_keypair::Keypair, + buyer: &solana_keypair::Keypair, + resolver: &solana_keypair::Keypair, +) { + send( + svm, + ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + ESCROW_ID, + ), + seller, + ); + send(svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), buyer); + send(svm, ix_dispute(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), buyer); +} + +fn warp_past_timeout(svm: &mut litesvm::LiteSVM) { + svm.set_sysvar(&Clock { + slot: 1_000_000, + epoch_start_timestamp: 0, + epoch: 0, + leader_schedule_epoch: 0, + unix_timestamp: TIMEOUT + 1, + }); +} + +#[test] +fn emergency_resolve_before_timeout_fails() { + let (mut svm, seller, buyer, resolver) = setup(); + setup_disputed(&mut svm, &seller, &buyer, &resolver); + + // Do NOT advance clock — timeout not reached + assert!(!try_send_multi( + &mut svm, + ix_emergency_resolve( + &buyer.pubkey(), + &seller.pubkey(), + &buyer.pubkey(), + ESCROW_ID, + EscrowWinner::Buyer, + ), + &buyer, + &[&seller], + )); +} + +#[test] +fn emergency_resolve_after_timeout_buyer_wins() { + let (mut svm, seller, buyer, resolver) = setup(); + setup_disputed(&mut svm, &seller, &buyer, &resolver); + warp_past_timeout(&mut svm); + + let buyer_before = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0); + + send_multi( + &mut svm, + ix_emergency_resolve( + &buyer.pubkey(), + &seller.pubkey(), + &buyer.pubkey(), + ESCROW_ID, + EscrowWinner::Buyer, + ), + &buyer, + &[&seller], + ); + + let buyer_after = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(buyer_after > buyer_before); + assert!(svm.get_account(&escrow_pda(&seller.pubkey(), ESCROW_ID)).is_none()); +} + +#[test] +fn emergency_resolve_after_timeout_seller_wins() { + let (mut svm, seller, buyer, resolver) = setup(); + setup_disputed(&mut svm, &seller, &buyer, &resolver); + warp_past_timeout(&mut svm); + + let seller_before = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + + send_multi( + &mut svm, + ix_emergency_resolve( + &buyer.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + EscrowWinner::Seller, + ), + &buyer, + &[&seller], + ); + + let seller_after = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(seller_after > seller_before); +} + +#[test] +fn emergency_resolve_only_buyer_signs_fails() { + let (mut svm, seller, buyer, resolver) = setup(); + setup_disputed(&mut svm, &seller, &buyer, &resolver); + warp_past_timeout(&mut svm); + + // Only buyer signs — seller missing + assert!(!try_send( + &mut svm, + ix_emergency_resolve( + &buyer.pubkey(), + &seller.pubkey(), + &buyer.pubkey(), + ESCROW_ID, + EscrowWinner::Buyer, + ), + &buyer, + )); +} + +#[test] +fn emergency_resolve_wrong_winner_pubkey_fails() { + let (mut svm, seller, buyer, resolver) = setup(); + setup_disputed(&mut svm, &seller, &buyer, &resolver); + warp_past_timeout(&mut svm); + + // Pass resolver as winner (not buyer or seller) + assert!(!try_send_multi( + &mut svm, + ix_emergency_resolve( + &buyer.pubkey(), + &seller.pubkey(), + &resolver.pubkey(), // neither buyer nor seller + ESCROW_ID, + EscrowWinner::Buyer, // claims buyer wins but gives wrong address + ), + &buyer, + &[&seller], + )); +} + +#[test] +fn emergency_resolve_before_dispute_fails() { + let (mut svm, seller, buyer, _resolver) = setup(); + + // Only create + deposit, no dispute + send( + &mut svm, + ix_create_escrow(&seller.pubkey(), &buyer.pubkey(), AMOUNT, None, ESCROW_ID), + &seller, + ); + send(&mut svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + warp_past_timeout(&mut svm); + + assert!(!try_send_multi( + &mut svm, + ix_emergency_resolve( + &buyer.pubkey(), + &seller.pubkey(), + &buyer.pubkey(), + ESCROW_ID, + EscrowWinner::Buyer, + ), + &buyer, + &[&seller], + )); +} +``` + +- [ ] **Step 3: Build to confirm tests don't yet compile** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | grep "error" | head -5 +``` + +Expected: error about missing `EmergencyResolve` type. + +- [ ] **Step 4: Create the instruction file** + +Create `programs/descro/src/instructions/emergency_resolve.rs`: + +```rust +use anchor_lang::prelude::*; +use anchor_lang::system_program::{self, Transfer}; +use crate::state::{EscrowAccount, EscrowState, Winner}; +use crate::error::EscrowError; +use crate::constants::DISPUTE_TIMEOUT_SECS; + +#[derive(Accounts)] +pub struct EmergencyResolve<'info> { + pub buyer: Signer<'info>, + + #[account(mut)] + pub seller: Signer<'info>, + + /// CHECK: Winner is validated against escrow.buyer or escrow.seller in handler + #[account(mut)] + pub winner: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"escrow", escrow_account.seller.as_ref(), &escrow_account.escrow_id.to_le_bytes()], + bump = escrow_account.bump, + constraint = buyer.key() == escrow_account.buyer @ EscrowError::Unauthorized, + constraint = seller.key() == escrow_account.seller @ EscrowError::Unauthorized, + constraint = escrow_account.state == EscrowState::Disputed @ EscrowError::InvalidState, + close = seller, + )] + pub escrow_account: Account<'info, EscrowAccount>, + + /// CHECK: PDA vault from which SOL is released to the winner + #[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, winner: Winner) -> Result<()> { + let escrow = &ctx.accounts.escrow_account; + + let dispute_raised_at = escrow + .dispute_raised_at + .ok_or(EscrowError::InvalidState)?; + + let now = Clock::get()?.unix_timestamp; + require!( + now >= dispute_raised_at + DISPUTE_TIMEOUT_SECS, + EscrowError::DisputeTimeoutNotReached + ); + + let expected_winner = match &winner { + Winner::Buyer => escrow.buyer, + Winner::Seller => escrow.seller, + }; + require!( + ctx.accounts.winner.key() == expected_winner, + EscrowError::Unauthorized + ); + + ctx.accounts.escrow_account.state = EscrowState::Complete; + + let escrow_key = ctx.accounts.escrow_account.key(); + let vault_bump = ctx.accounts.escrow_account.vault_bump; + let vault_balance = ctx.accounts.vault.to_account_info().lamports(); + + system_program::transfer( + CpiContext::new_with_signer( + system_program::ID, + Transfer { + from: ctx.accounts.vault.to_account_info(), + to: ctx.accounts.winner.to_account_info(), + }, + &[&[b"vault", escrow_key.as_ref(), &[vault_bump]]], + ), + vault_balance, + )?; + + Ok(()) +} +``` + +- [ ] **Step 5: Register module in `instructions.rs`** + +Replace `programs/descro/src/instructions.rs` with: + +```rust +#![allow(ambiguous_glob_reexports)] + +pub mod cancel; +pub mod complete; +pub mod create_escrow; +pub mod create_escrow_prefunded; +pub mod deposit; +pub mod dispute; +pub mod emergency_resolve; +pub mod resolve; + +pub use cancel::*; +pub use complete::*; +pub use create_escrow::*; +pub use create_escrow_prefunded::*; +pub use deposit::*; +pub use dispute::*; +pub use emergency_resolve::*; +pub use resolve::*; +``` + +- [ ] **Step 6: Register entrypoint in `lib.rs`** + +Add `emergency_resolve` inside the `#[program]` mod (full file): + +```rust +#![allow(clippy::diverging_sub_expression)] + +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!("DjVR4EuYV6USMJFfsGZwhZ3y8rtWsmG8EvDY96GTqqi3"); + +#[program] +pub mod descro { + use super::*; + + pub fn create_escrow( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, + ) -> Result<()> { + create_escrow::handler(ctx, amount, dispute_resolver, escrow_id) + } + + pub fn create_escrow_prefunded( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, + ) -> Result<()> { + create_escrow_prefunded::handler(ctx, amount, dispute_resolver, escrow_id) + } + + pub fn deposit(ctx: Context) -> Result<()> { + deposit::handler(ctx) + } + + pub fn complete(ctx: Context) -> Result<()> { + complete::handler(ctx) + } + + pub fn dispute(ctx: Context) -> Result<()> { + dispute::handler(ctx) + } + + pub fn resolve(ctx: Context, winner: Winner) -> Result<()> { + resolve::handler(ctx, winner) + } + + pub fn cancel(ctx: Context) -> Result<()> { + cancel::handler(ctx) + } + + pub fn emergency_resolve(ctx: Context, winner: Winner) -> Result<()> { + emergency_resolve::handler(ctx, winner) + } +} +``` + +- [ ] **Step 7: Build** + +```bash +cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 +``` + +Expected: `Finished`. + +- [ ] **Step 8: Run the new tests** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml --test test_emergency_resolve 2>&1 | tail -15 +``` + +Expected: all 6 tests pass. + +- [ ] **Step 9: Run full test suite** + +```bash +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10 +``` + +Expected: all tests pass, no regressions. + +- [ ] **Step 10: Commit** + +```bash +git add programs/descro/src/instructions/emergency_resolve.rs \ + programs/descro/src/instructions.rs \ + programs/descro/src/lib.rs \ + programs/descro/tests/common/mod.rs \ + programs/descro/tests/test_emergency_resolve.rs +git commit -m "feat(descro): add emergency_resolve instruction with 14-day timeout" +``` + +--- + +## Done + +Run the full suite one final time to confirm everything is green: + +```bash +cargo build-sbf --manifest-path programs/descro_ext_resolvers/Cargo.toml && \ +cargo build-sbf --manifest-path programs/descro/Cargo.toml && \ +cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -20 +``` diff --git a/docs/superpowers/plans/2026-05-19-solisting.md b/docs/superpowers/plans/2026-05-19-solisting.md new file mode 100644 index 0000000..e6aa223 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-solisting.md @@ -0,0 +1,1647 @@ +# 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, + 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" +``` + +--- + +## 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, + 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, + 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) -> 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, + 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 + +**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, 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 + #[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, 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