15 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/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)
- 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.
- 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 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)"