feat(descro): add emergency_resolve instruction with 14-day timeout

Allows both buyer and seller to co-sign and bypass a missing resolver after
DISPUTE_TIMEOUT_SECS (14 days), transferring vault funds to the chosen winner
and closing the escrow account. Also hardens try_send() to return false on
NotEnoughSigners rather than panicking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thesn10
2026-05-19 22:33:09 +02:00
parent 0290cbb141
commit 12c165f589
5 changed files with 309 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ 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::*;
@@ -14,4 +15,5 @@ pub use create_escrow::*;
pub use create_escrow_prefunded::*;
pub use deposit::*;
pub use dispute::*;
pub use emergency_resolve::*;
pub use resolve::*;

View File

@@ -0,0 +1,81 @@
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(())
}

View File

@@ -54,4 +54,8 @@ pub mod descro {
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)
}
}

View File

@@ -69,7 +69,9 @@ pub fn send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) {
pub fn try_send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) -> bool {
let blockhash = svm.latest_blockhash();
let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash);
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]).unwrap();
let Ok(tx) = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]) else {
return false;
};
svm.send_transaction(tx).is_ok()
}
@@ -200,6 +202,49 @@ pub fn read_escrow(svm: &LiteSVM, seller: &Pubkey, escrow_id: u64) -> EscrowAcco
EscrowAccount::try_deserialize(&mut account.data.as_slice()).unwrap()
}
/// 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()
}
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),
)
}
pub fn ix_create_escrow_prefunded(
seller: &Pubkey,
buyer: &Pubkey,

View File

@@ -0,0 +1,176 @@
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(),
ESCROW_ID,
EscrowWinner::Buyer,
),
&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],
));
}