20 KiB
Descro: Buyer-Initiated Escrow Flow
Prerequisite for
solisting. Thesolistingplan depends on these instructions existing beforeaccept_orderandcreate_ordercan 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
#[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_escrowintentionally 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_confirmin the buyer-initiated flow,create_escrowin the seller-initiated flow.buyer_create_escrowis 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_resolversas a dependency todescro'sCargo.toml(withoutcpifeature — read-only access to types). Adddescro_ext_resolvers = { path = "../descro_ext_resolvers" }to[dependencies]. Also requires theAcceptancePolicyprerequisite from the solisting plan to be implemented first.
Note: Add
ResolverSignatureRequiredtoEscrowErrorif 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_confirmis the seller's transaction in the buyer-initiated flow;create_escrowis 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
resolverandresolver_entryaccounts 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
AwaitingDepositorAwaitingSellerConfirm -
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
cancelinstruction had aseller: Signeraccount. It is nowcanceller: Signerwith 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
#[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)"