Files
descro/docs/superpowers/plans/2026-05-19-descro-flow.md
2026-05-19 17:41:54 +02:00

31 KiB

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<i64>
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:

use anchor_lang::prelude::*;

#[account]
#[derive(InitSpace)]
pub struct EscrowAccount {
    pub seller: Pubkey,
    pub buyer: Pubkey,
    pub amount: u64,
    pub dispute_resolver: Option<Pubkey>,
    pub state: EscrowState,
    pub bump: u8,
    pub vault_bump: u8,
    pub escrow_id: u64,
    pub dispute_raised_at: Option<i64>,
}

#[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:

pub const DISPUTE_TIMEOUT_SECS: i64 = 14 * 24 * 60 * 60; // 14 days
  • Step 3: Verify it compiles (programs must be built before tests run)
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(())):

pub fn handler(
    ctx: Context<CreateEscrow>,
    amount: u64,
    dispute_resolver: Option<Pubkey>,
    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
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
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15

Expected: all existing tests pass.

  • Step 7: Commit
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:

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
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3

Expected: Finished.

  • Step 3: Commit
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):

#[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)
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3
  • Step 3: Run the failing test
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:

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<Dispute>) -> 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
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3
  • Step 6: Run all dispute tests
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
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:

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:

pub fn ix_create_escrow_prefunded(
    seller: &Pubkey,
    buyer: &Pubkey,
    amount: u64,
    dispute_resolver: Option<Pubkey>,
    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
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:

use anchor_lang::prelude::*;
use crate::state::{EscrowAccount, EscrowState};
use crate::error::EscrowError;

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

#![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:

#[program]
pub mod descro {
    use super::*;

    pub fn create_escrow(
        ctx: Context<CreateEscrow>,
        amount: u64,
        dispute_resolver: Option<Pubkey>,
        escrow_id: u64,
    ) -> Result<()> {
        create_escrow::handler(ctx, amount, dispute_resolver, escrow_id)
    }

    pub fn create_escrow_prefunded(
        ctx: Context<CreateEscrowPrefunded>,
        amount: u64,
        dispute_resolver: Option<Pubkey>,
        escrow_id: u64,
    ) -> Result<()> {
        create_escrow_prefunded::handler(ctx, amount, dispute_resolver, escrow_id)
    }

    pub fn deposit(ctx: Context<Deposit>) -> Result<()> {
        deposit::handler(ctx)
    }

    pub fn complete(ctx: Context<Complete>) -> Result<()> {
        complete::handler(ctx)
    }

    pub fn dispute(ctx: Context<Dispute>) -> Result<()> {
        dispute::handler(ctx)
    }

    pub fn resolve(ctx: Context<Resolve>, winner: Winner) -> Result<()> {
        resolve::handler(ctx, winner)
    }

    pub fn cancel(ctx: Context<Cancel>) -> Result<()> {
        cancel::handler(ctx)
    }
}
  • Step 7: Build
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5

Expected: Finished with no errors.

  • Step 8: Run the new tests
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
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10

Expected: all tests pass.

  • Step 10: Commit
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:

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

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:

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

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<EmergencyResolve>, 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:

#![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):

#![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<CreateEscrow>,
        amount: u64,
        dispute_resolver: Option<Pubkey>,
        escrow_id: u64,
    ) -> Result<()> {
        create_escrow::handler(ctx, amount, dispute_resolver, escrow_id)
    }

    pub fn create_escrow_prefunded(
        ctx: Context<CreateEscrowPrefunded>,
        amount: u64,
        dispute_resolver: Option<Pubkey>,
        escrow_id: u64,
    ) -> Result<()> {
        create_escrow_prefunded::handler(ctx, amount, dispute_resolver, escrow_id)
    }

    pub fn deposit(ctx: Context<Deposit>) -> Result<()> {
        deposit::handler(ctx)
    }

    pub fn complete(ctx: Context<Complete>) -> Result<()> {
        complete::handler(ctx)
    }

    pub fn dispute(ctx: Context<Dispute>) -> Result<()> {
        dispute::handler(ctx)
    }

    pub fn resolve(ctx: Context<Resolve>, winner: Winner) -> Result<()> {
        resolve::handler(ctx, winner)
    }

    pub fn cancel(ctx: Context<Cancel>) -> Result<()> {
        cancel::handler(ctx)
    }

    pub fn emergency_resolve(ctx: Context<EmergencyResolve>, winner: Winner) -> Result<()> {
        emergency_resolve::handler(ctx, winner)
    }
}
  • Step 7: Build
cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5

Expected: Finished.

  • Step 8: Run the new tests
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
cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10

Expected: all tests pass, no regressions.

  • Step 10: Commit
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:

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