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 create_escrow_prefunded;
|
||||||
pub mod deposit;
|
pub mod deposit;
|
||||||
pub mod dispute;
|
pub mod dispute;
|
||||||
|
pub mod emergency_resolve;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
|
|
||||||
pub use cancel::*;
|
pub use cancel::*;
|
||||||
@@ -14,4 +15,5 @@ pub use create_escrow::*;
|
|||||||
pub use create_escrow_prefunded::*;
|
pub use create_escrow_prefunded::*;
|
||||||
pub use deposit::*;
|
pub use deposit::*;
|
||||||
pub use dispute::*;
|
pub use dispute::*;
|
||||||
|
pub use emergency_resolve::*;
|
||||||
pub use 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<()> {
|
pub fn cancel(ctx: Context<Cancel>) -> Result<()> {
|
||||||
cancel::handler(ctx)
|
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 {
|
pub fn try_send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) -> bool {
|
||||||
let blockhash = svm.latest_blockhash();
|
let blockhash = svm.latest_blockhash();
|
||||||
let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &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()
|
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()
|
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(
|
pub fn ix_create_escrow_prefunded(
|
||||||
seller: &Pubkey,
|
seller: &Pubkey,
|
||||||
buyer: &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