1110 lines
31 KiB
Markdown
1110 lines
31 KiB
Markdown
# 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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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:
|
|
|
|
```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<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**
|
|
|
|
```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<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:
|
|
|
|
```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<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**
|
|
|
|
```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
|
|
```
|