diff --git a/programs/descro/src/instructions.rs b/programs/descro/src/instructions.rs index 23ec34f..ac0dd20 100644 --- a/programs/descro/src/instructions.rs +++ b/programs/descro/src/instructions.rs @@ -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::*; diff --git a/programs/descro/src/instructions/emergency_resolve.rs b/programs/descro/src/instructions/emergency_resolve.rs new file mode 100644 index 0000000..594abee --- /dev/null +++ b/programs/descro/src/instructions/emergency_resolve.rs @@ -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, 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(()) +} diff --git a/programs/descro/src/lib.rs b/programs/descro/src/lib.rs index 0fbd11e..5cb209c 100644 --- a/programs/descro/src/lib.rs +++ b/programs/descro/src/lib.rs @@ -54,4 +54,8 @@ pub mod descro { pub fn cancel(ctx: Context) -> Result<()> { cancel::handler(ctx) } + + pub fn emergency_resolve(ctx: Context, winner: Winner) -> Result<()> { + emergency_resolve::handler(ctx, winner) + } } diff --git a/programs/descro/tests/common/mod.rs b/programs/descro/tests/common/mod.rs index db0c1ab..72934fa 100644 --- a/programs/descro/tests/common/mod.rs +++ b/programs/descro/tests/common/mod.rs @@ -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, diff --git a/programs/descro/tests/test_emergency_resolve.rs b/programs/descro/tests/test_emergency_resolve.rs new file mode 100644 index 0000000..52e3a4b --- /dev/null +++ b/programs/descro/tests/test_emergency_resolve.rs @@ -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], + )); +}