# 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/create_escrow.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) ``` > **Note on AcceptancePolicy:** `buyer_create_escrow` intentionally has no AcceptancePolicy check. The resolver's primary relationship is with the seller (the seller selects and configures the resolver). The check therefore happens on the seller's transaction: `seller_confirm` in the buyer-initiated flow, `create_escrow` in the seller-initiated flow. `buyer_create_escrow` is the buyer's transaction — the seller is not yet involved and a resolver co-sign here would be premature. See Task 3 and Task 3b. - [ ] **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. > **Why here and not at `buyer_create_escrow`:** The resolver co-signs the seller's transaction because the seller holds the primary relationship with the resolver (the seller chose and configured them). This is symmetric across both flows: `seller_confirm` is the seller's transaction in the buyer-initiated flow; `create_escrow` is the seller's transaction in the seller-initiated flow. See Task 3b for the seller-initiated counterpart. - [ ] **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 3b: Extend `create_escrow` — Add AcceptancePolicy Check **File:** `programs/descro/src/instructions/create_escrow.rs` **Why here:** `create_escrow` is the seller's transaction in the seller-initiated flow — symmetric to `seller_confirm` in the buyer-initiated flow. The resolver co-signs alongside the seller, the party who chose and configured the resolver. - [ ] **Step 1: Add `resolver` and `resolver_entry` accounts and policy check** ```rust use anchor_lang::prelude::*; use crate::state::{EscrowAccount, EscrowState}; use crate::EscrowError; #[derive(Accounts)] #[instruction(amount: u64, dispute_resolver: Option, escrow_id: u64)] pub struct CreateEscrow<'info> { #[account(mut)] pub seller: Signer<'info>, /// CHECK: Only stored as pubkey, no ownership check needed pub buyer: UncheckedAccount<'info>, /// CHECK: Resolver — may need to co-sign if AcceptancePolicy is SignatureGated pub resolver: AccountInfo<'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: PDA vault for holding escrow SOL; created implicitly on deposit #[account( mut, seeds = [b"vault", escrow_account.key().as_ref()], bump )] pub vault: UncheckedAccount<'info>, /// CHECK: Optional resolver registry entry — read to determine AcceptancePolicy pub resolver_entry: 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); // AcceptancePolicy check — only if resolver is set and has a registry entry if 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 } } } 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 2: Update existing tests that call `create_escrow`** The accounts struct now requires `resolver` and `resolver_entry`. For tests without a registered resolver, pass a dummy account for `resolver` (no signer needed for `Open` or absent resolver) and an empty/system account for `resolver_entry`. - [ ] **Step 3: Add policy tests** ```rust #[test] fn create_escrow_open_resolver_no_cosign_needed() { // Register resolver with AcceptancePolicy::Open // create_escrow without resolver signing → should succeed } #[test] fn create_escrow_signature_gated_requires_resolver_cosign() { // Register resolver with AcceptancePolicy::SignatureGated // create_escrow WITHOUT resolver signing → must fail with ResolverSignatureRequired // create_escrow WITH resolver co-signing → AwaitingDeposit } ``` - [ ] **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)" ```