Files
descro/docs/superpowers/plans/2026-05-19-descro-buyer-flow.md
2026-05-25 19:59:04 +02:00

20 KiB

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_escrowdepositActive) 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
#[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
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5
  • Step 3: Run existing tests
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
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<Pubkey>, 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<BuyerCreateEscrow>,
    amount: u64,
    dispute_resolver: Option<Pubkey>,
    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:

pub fn buyer_create_escrow(
    ctx: Context<BuyerCreateEscrow>,
    amount: u64,
    dispute_resolver: Option<Pubkey>,
    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):

// 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):

#[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
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
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<SellerConfirm>) -> 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
pub fn seller_confirm(ctx: Context<SellerConfirm>) -> Result<()> {
    seller_confirm::handler(ctx)
}
  • Step 3: Write tests
#[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
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
use anchor_lang::prelude::*;
use crate::state::{EscrowAccount, EscrowState};
use crate::EscrowError;

#[derive(Accounts)]
#[instruction(amount: u64, dispute_resolver: Option<Pubkey>, 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<CreateEscrow>,
    amount: u64,
    dispute_resolver: Option<Pubkey>,
    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
#[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
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

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<Cancel>) -> 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 (sellercanceller, new buyer account added).

  • Step 3: Write new cancel tests
#[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
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.

rm programs/descro/src/instructions/create_escrow_prefunded.rs
  • Step 2: Rebuild both programs
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
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
git add programs/descro/
git commit -m "feat(descro): add buyer-initiated escrow flow (AwaitingSellerConfirm + seller_confirm + symmetric cancel)"