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:
@@ -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::*;
|
||||
|
||||
81
programs/descro/src/instructions/emergency_resolve.rs
Normal file
81
programs/descro/src/instructions/emergency_resolve.rs
Normal 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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
176
programs/descro/tests/test_emergency_resolve.rs
Normal file
176
programs/descro/tests/test_emergency_resolve.rs
Normal 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],
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user