# 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` | | 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: ```rust use anchor_lang::prelude::*; #[account] #[derive(InitSpace)] pub struct EscrowAccount { pub seller: Pubkey, pub buyer: Pubkey, pub amount: u64, pub dispute_resolver: Option, pub state: EscrowState, pub bump: u8, pub vault_bump: u8, pub escrow_id: u64, pub dispute_raised_at: Option, } #[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: ```rust pub const DISPUTE_TIMEOUT_SECS: i64 = 14 * 24 * 60 * 60; // 14 days ``` - [ ] **Step 3: Verify it compiles (programs must be built before tests run)** ```bash 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(())`): ```rust pub fn handler( ctx: Context, amount: u64, dispute_resolver: Option, 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** ```bash 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** ```bash cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -15 ``` Expected: all existing tests pass. - [ ] **Step 7: Commit** ```bash 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: ```rust 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** ```bash cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3 ``` Expected: `Finished`. - [ ] **Step 3: Commit** ```bash 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): ```rust #[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)** ```bash cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3 ``` - [ ] **Step 3: Run the failing test** ```bash 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: ```rust 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) -> 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** ```bash cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -3 ``` - [ ] **Step 6: Run all dispute tests** ```bash 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** ```bash 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`: ```rust 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`: ```rust pub fn ix_create_escrow_prefunded( seller: &Pubkey, buyer: &Pubkey, amount: u64, dispute_resolver: Option, 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** ```bash 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`: ```rust use anchor_lang::prelude::*; use crate::state::{EscrowAccount, EscrowState}; use crate::error::EscrowError; #[derive(Accounts)] #[instruction(amount: u64, dispute_resolver: Option, 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, amount: u64, dispute_resolver: Option, 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: ```rust #![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`: ```rust #[program] pub mod descro { use super::*; pub fn create_escrow( ctx: Context, amount: u64, dispute_resolver: Option, escrow_id: u64, ) -> Result<()> { create_escrow::handler(ctx, amount, dispute_resolver, escrow_id) } pub fn create_escrow_prefunded( ctx: Context, amount: u64, dispute_resolver: Option, escrow_id: u64, ) -> Result<()> { create_escrow_prefunded::handler(ctx, amount, dispute_resolver, escrow_id) } pub fn deposit(ctx: Context) -> Result<()> { deposit::handler(ctx) } pub fn complete(ctx: Context) -> Result<()> { complete::handler(ctx) } pub fn dispute(ctx: Context) -> Result<()> { dispute::handler(ctx) } pub fn resolve(ctx: Context, winner: Winner) -> Result<()> { resolve::handler(ctx, winner) } pub fn cancel(ctx: Context) -> Result<()> { cancel::handler(ctx) } } ``` - [ ] **Step 7: Build** ```bash cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 ``` Expected: `Finished` with no errors. - [ ] **Step 8: Run the new tests** ```bash 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** ```bash cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10 ``` Expected: all tests pass. - [ ] **Step 10: Commit** ```bash 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`: ```rust /// 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`: ```rust 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`: ```rust 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** ```bash 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`: ```rust 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, 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: ```rust #![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): ```rust #![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, amount: u64, dispute_resolver: Option, escrow_id: u64, ) -> Result<()> { create_escrow::handler(ctx, amount, dispute_resolver, escrow_id) } pub fn create_escrow_prefunded( ctx: Context, amount: u64, dispute_resolver: Option, escrow_id: u64, ) -> Result<()> { create_escrow_prefunded::handler(ctx, amount, dispute_resolver, escrow_id) } pub fn deposit(ctx: Context) -> Result<()> { deposit::handler(ctx) } pub fn complete(ctx: Context) -> Result<()> { complete::handler(ctx) } pub fn dispute(ctx: Context) -> Result<()> { dispute::handler(ctx) } pub fn resolve(ctx: Context, winner: Winner) -> Result<()> { resolve::handler(ctx, winner) } pub fn cancel(ctx: Context) -> Result<()> { cancel::handler(ctx) } pub fn emergency_resolve(ctx: Context, winner: Winner) -> Result<()> { emergency_resolve::handler(ctx, winner) } } ``` - [ ] **Step 7: Build** ```bash cargo build-sbf --manifest-path programs/descro/Cargo.toml 2>&1 | tail -5 ``` Expected: `Finished`. - [ ] **Step 8: Run the new tests** ```bash 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** ```bash cargo test --manifest-path programs/descro/Cargo.toml 2>&1 | tail -10 ``` Expected: all tests pass, no regressions. - [ ] **Step 10: Commit** ```bash 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: ```bash 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 ```