diff --git a/programs/descro/src/constants.rs b/programs/descro/src/constants.rs index fae4535..be6ed13 100644 --- a/programs/descro/src/constants.rs +++ b/programs/descro/src/constants.rs @@ -1,4 +1 @@ -use anchor_lang::prelude::*; - -#[constant] -pub const SEED: &str = "anchor"; +// reserved for future constants diff --git a/programs/descro/src/error.rs b/programs/descro/src/error.rs index c37199a..27fad4f 100644 --- a/programs/descro/src/error.rs +++ b/programs/descro/src/error.rs @@ -1,7 +1,15 @@ use anchor_lang::prelude::*; #[error_code] -pub enum ErrorCode { - #[msg("Custom error message")] - CustomError, +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, } diff --git a/programs/descro/src/instructions.rs b/programs/descro/src/instructions.rs index b5a1d31..b1a7831 100644 --- a/programs/descro/src/instructions.rs +++ b/programs/descro/src/instructions.rs @@ -1,3 +1,13 @@ -pub mod initialize; +pub mod cancel; +pub mod complete; +pub mod create_escrow; +pub mod deposit; +pub mod dispute; +pub mod resolve; -pub use initialize::*; +pub use cancel::*; +pub use complete::*; +pub use create_escrow::*; +pub use deposit::*; +pub use dispute::*; +pub use resolve::*; diff --git a/programs/descro/src/instructions/cancel.rs b/programs/descro/src/instructions/cancel.rs new file mode 100644 index 0000000..540d6f3 --- /dev/null +++ b/programs/descro/src/instructions/cancel.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +pub struct Cancel<'info> { + #[account(mut)] + pub seller: Signer<'info>, + + #[account( + mut, + seeds = [b"escrow", escrow_account.seller.as_ref(), &escrow_account.escrow_id.to_le_bytes()], + bump = escrow_account.bump, + constraint = seller.key() == escrow_account.seller @ EscrowError::Unauthorized, + constraint = escrow_account.state == EscrowState::AwaitingDeposit @ EscrowError::InvalidState, + close = seller, + )] + pub escrow_account: Account<'info, EscrowAccount>, + + pub system_program: Program<'info, System>, +} + +pub fn handler(_ctx: Context) -> Result<()> { + Ok(()) +} diff --git a/programs/descro/src/instructions/complete.rs b/programs/descro/src/instructions/complete.rs new file mode 100644 index 0000000..7b7d814 --- /dev/null +++ b/programs/descro/src/instructions/complete.rs @@ -0,0 +1,59 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{program::invoke_signed, system_instruction}; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +pub struct Complete<'info> { + pub buyer: Signer<'info>, + + /// CHECK: Seller receives vault funds and escrow rent + #[account( + mut, + constraint = seller.key() == escrow_account.seller @ EscrowError::Unauthorized, + )] + pub seller: 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 = escrow_account.state == EscrowState::Active @ EscrowError::InvalidState, + close = seller, + )] + pub escrow_account: Account<'info, EscrowAccount>, + + /// CHECK: PDA vault from which SOL is released + #[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) -> Result<()> { + 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(); + + invoke_signed( + &system_instruction::transfer( + ctx.accounts.vault.to_account_info().key, + ctx.accounts.seller.to_account_info().key, + vault_balance, + ), + &[ + ctx.accounts.vault.to_account_info(), + ctx.accounts.seller.to_account_info(), + ], + &[&[b"vault", escrow_key.as_ref(), &[vault_bump]]], + )?; + + Ok(()) +} diff --git a/programs/descro/src/instructions/create_escrow.rs b/programs/descro/src/instructions/create_escrow.rs new file mode 100644 index 0000000..b262324 --- /dev/null +++ b/programs/descro/src/instructions/create_escrow.rs @@ -0,0 +1,49 @@ +use anchor_lang::prelude::*; +use crate::state::{EscrowAccount, EscrowState}; + +#[derive(Accounts)] +#[instruction(amount: u64, dispute_resolver: Option, escrow_id: u64)] +pub struct CreateEscrow<'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: PDA vault for holding escrow SOL; created implicitly on deposit + #[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, + amount: u64, + dispute_resolver: Option, + 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; + Ok(()) +} diff --git a/programs/descro/src/instructions/deposit.rs b/programs/descro/src/instructions/deposit.rs new file mode 100644 index 0000000..f92b449 --- /dev/null +++ b/programs/descro/src/instructions/deposit.rs @@ -0,0 +1,48 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{program::invoke, system_instruction}; +use crate::state::{EscrowAccount, EscrowState}; +use crate::error::EscrowError; + +#[derive(Accounts)] +pub struct Deposit<'info> { + #[account(mut)] + pub buyer: Signer<'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 = escrow_account.state == EscrowState::AwaitingDeposit @ EscrowError::InvalidState, + )] + pub escrow_account: Account<'info, EscrowAccount>, + + /// CHECK: PDA vault receiving the escrowed SOL + #[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) -> Result<()> { + let amount = ctx.accounts.escrow_account.amount; + + invoke( + &system_instruction::transfer( + ctx.accounts.buyer.to_account_info().key, + ctx.accounts.vault.to_account_info().key, + amount, + ), + &[ + ctx.accounts.buyer.to_account_info(), + ctx.accounts.vault.to_account_info(), + ], + )?; + + ctx.accounts.escrow_account.state = EscrowState::Active; + Ok(()) +} diff --git a/programs/descro/src/instructions/dispute.rs b/programs/descro/src/instructions/dispute.rs new file mode 100644 index 0000000..b5db3ca --- /dev/null +++ b/programs/descro/src/instructions/dispute.rs @@ -0,0 +1,25 @@ +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) -> Result<()> { + ctx.accounts.escrow_account.state = EscrowState::Disputed; + Ok(()) +} diff --git a/programs/descro/src/instructions/initialize.rs b/programs/descro/src/instructions/initialize.rs deleted file mode 100644 index a6e0f38..0000000 --- a/programs/descro/src/instructions/initialize.rs +++ /dev/null @@ -1,9 +0,0 @@ -use anchor_lang::prelude::*; - -#[derive(Accounts)] -pub struct Initialize {} - -pub fn handler(ctx: Context) -> Result<()> { - msg!("Greetings from: {:?}", ctx.program_id); - Ok(()) -} diff --git a/programs/descro/src/instructions/resolve.rs b/programs/descro/src/instructions/resolve.rs new file mode 100644 index 0000000..0819b5b --- /dev/null +++ b/programs/descro/src/instructions/resolve.rs @@ -0,0 +1,79 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{program::invoke_signed, system_instruction}; +use crate::state::{EscrowAccount, EscrowState, Winner}; +use crate::error::EscrowError; + +#[derive(Accounts)] +pub struct Resolve<'info> { + pub resolver: Signer<'info>, + + /// CHECK: Winner of the dispute, receives vault funds + #[account(mut)] + pub winner: UncheckedAccount<'info>, + + /// CHECK: Seller receives escrow account rent on close + #[account( + mut, + constraint = seller.key() == escrow_account.seller @ EscrowError::Unauthorized, + )] + pub seller: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"escrow", escrow_account.seller.as_ref(), &escrow_account.escrow_id.to_le_bytes()], + bump = escrow_account.bump, + 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; + + require!(escrow.dispute_resolver.is_some(), EscrowError::NoResolverConfigured); + require!( + ctx.accounts.resolver.key() == escrow.dispute_resolver.unwrap(), + EscrowError::UnauthorizedResolver + ); + + 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(); + + invoke_signed( + &system_instruction::transfer( + ctx.accounts.vault.to_account_info().key, + ctx.accounts.winner.to_account_info().key, + vault_balance, + ), + &[ + ctx.accounts.vault.to_account_info(), + ctx.accounts.winner.to_account_info(), + ], + &[&[b"vault", escrow_key.as_ref(), &[vault_bump]]], + )?; + + Ok(()) +} diff --git a/programs/descro/src/lib.rs b/programs/descro/src/lib.rs index 4bf704a..8b59419 100644 --- a/programs/descro/src/lib.rs +++ b/programs/descro/src/lib.rs @@ -5,7 +5,7 @@ pub mod state; use anchor_lang::prelude::*; -pub use constants::*; +pub use error::*; pub use instructions::*; pub use state::*; @@ -15,7 +15,32 @@ declare_id!("DjVR4EuYV6USMJFfsGZwhZ3y8rtWsmG8EvDY96GTqqi3"); pub mod descro { use super::*; - pub fn initialize(ctx: Context) -> Result<()> { - initialize::handler(ctx) + pub fn create_escrow( + ctx: Context, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, + ) -> Result<()> { + create_escrow::handler(ctx, amount, dispute_resolver, escrow_id) + } + + pub fn deposit(ctx: Context) -> Result<()> { + deposit::handler(ctx) + } + + pub fn complete(ctx: Context) -> Result<()> { + complete::handler(ctx) + } + + pub fn dispute(ctx: Context) -> Result<()> { + dispute::handler(ctx) + } + + pub fn resolve(ctx: Context, winner: Winner) -> Result<()> { + resolve::handler(ctx, winner) + } + + pub fn cancel(ctx: Context) -> Result<()> { + cancel::handler(ctx) } } diff --git a/programs/descro/src/state.rs b/programs/descro/src/state.rs index e69de29..aa227ad 100644 --- a/programs/descro/src/state.rs +++ b/programs/descro/src/state.rs @@ -0,0 +1,29 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct EscrowAccount { + pub seller: Pubkey, + pub buyer: Pubkey, + pub amount: u64, + pub dispute_resolver: Option, + pub state: EscrowState, + pub bump: u8, + pub vault_bump: u8, + pub escrow_id: u64, +} + +#[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, +} diff --git a/programs/descro/tests/escrow_tests.rs b/programs/descro/tests/escrow_tests.rs new file mode 100644 index 0000000..03c9fc0 --- /dev/null +++ b/programs/descro/tests/escrow_tests.rs @@ -0,0 +1,525 @@ +use { + anchor_lang::{ + solana_program::{instruction::Instruction, system_program}, + AccountDeserialize, InstructionData, ToAccountMetas, + }, + descro::{EscrowAccount, EscrowState, Winner}, + litesvm::LiteSVM, + solana_keypair::Keypair, + solana_message::{Message, VersionedMessage}, + solana_signer::Signer, + solana_transaction::versioned::VersionedTransaction, +}; +use anchor_lang::prelude::Pubkey; + +const AMOUNT: u64 = 1_000_000_000; // 1 SOL +const ESCROW_ID: u64 = 1; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn setup() -> (LiteSVM, Keypair, Keypair, Keypair) { + let program_id = descro::id(); + let mut svm = LiteSVM::new(); + let bytes = include_bytes!("../../../target/deploy/descro.so"); + svm.add_program(program_id, bytes).unwrap(); + + let seller = Keypair::new(); + let buyer = Keypair::new(); + let resolver = Keypair::new(); + + svm.airdrop(&seller.pubkey(), 10_000_000_000).unwrap(); + svm.airdrop(&buyer.pubkey(), 10_000_000_000).unwrap(); + svm.airdrop(&resolver.pubkey(), 1_000_000_000).unwrap(); + + (svm, seller, buyer, resolver) +} + +fn escrow_pda(seller: &Pubkey, escrow_id: u64) -> Pubkey { + Pubkey::find_program_address( + &[b"escrow", seller.as_ref(), &escrow_id.to_le_bytes()], + &descro::id(), + ) + .0 +} + +fn vault_pda(escrow: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[b"vault", escrow.as_ref()], &descro::id()).0 +} + +fn send(svm: &mut LiteSVM, ix: Instruction, payer: &Keypair) { + 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(); + svm.send_transaction(tx).expect("transaction failed"); +} + +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(); + svm.send_transaction(tx).is_ok() +} + +fn ix_create_escrow( + seller: &Pubkey, + buyer: &Pubkey, + amount: u64, + dispute_resolver: Option, + escrow_id: u64, +) -> Instruction { + let escrow = escrow_pda(seller, escrow_id); + let vault = vault_pda(&escrow); + Instruction::new_with_bytes( + descro::id(), + &descro::instruction::CreateEscrow { amount, dispute_resolver, escrow_id }.data(), + descro::accounts::CreateEscrow { + seller: *seller, + buyer: *buyer, + escrow_account: escrow, + vault, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +fn ix_deposit(buyer: &Pubkey, seller: &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::Deposit {}.data(), + descro::accounts::Deposit { + buyer: *buyer, + escrow_account: escrow, + vault, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +fn ix_complete(buyer: &Pubkey, seller: &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::Complete {}.data(), + descro::accounts::Complete { + buyer: *buyer, + seller: *seller, + escrow_account: escrow, + vault, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +fn ix_dispute(initiator: &Pubkey, seller: &Pubkey, escrow_id: u64) -> Instruction { + let escrow = escrow_pda(seller, escrow_id); + Instruction::new_with_bytes( + descro::id(), + &descro::instruction::Dispute {}.data(), + descro::accounts::Dispute { + initiator: *initiator, + escrow_account: escrow, + } + .to_account_metas(None), + ) +} + +fn ix_resolve( + resolver: &Pubkey, + winner_pubkey: &Pubkey, + seller: &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::Resolve { winner }.data(), + descro::accounts::Resolve { + resolver: *resolver, + winner: *winner_pubkey, + seller: *seller, + escrow_account: escrow, + vault, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +fn ix_cancel(seller: &Pubkey, escrow_id: u64) -> Instruction { + let escrow = escrow_pda(seller, escrow_id); + Instruction::new_with_bytes( + descro::id(), + &descro::instruction::Cancel {}.data(), + descro::accounts::Cancel { + seller: *seller, + escrow_account: escrow, + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +fn read_escrow(svm: &LiteSVM, seller: &Pubkey, escrow_id: u64) -> EscrowAccount { + let escrow = escrow_pda(seller, escrow_id); + let account = svm.get_account(&escrow).expect("escrow account not found"); + EscrowAccount::try_deserialize(&mut account.data.as_slice()).unwrap() +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_create_escrow() { + let (mut svm, seller, buyer, _resolver) = setup(); + + let ix = ix_create_escrow(&seller.pubkey(), &buyer.pubkey(), AMOUNT, None, ESCROW_ID); + send(&mut svm, ix, &seller); + + let escrow = read_escrow(&svm, &seller.pubkey(), ESCROW_ID); + assert_eq!(escrow.seller, seller.pubkey()); + assert_eq!(escrow.buyer, buyer.pubkey()); + assert_eq!(escrow.amount, AMOUNT); + assert_eq!(escrow.state, EscrowState::AwaitingDeposit); + assert!(escrow.dispute_resolver.is_none()); +} + +#[test] +fn test_create_escrow_with_resolver() { + let (mut svm, seller, buyer, resolver) = setup(); + + let ix = ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + ESCROW_ID, + ); + send(&mut svm, ix, &seller); + + let escrow = read_escrow(&svm, &seller.pubkey(), ESCROW_ID); + assert_eq!(escrow.dispute_resolver, Some(resolver.pubkey())); +} + +#[test] +fn test_deposit() { + let (mut svm, seller, buyer, _resolver) = setup(); + + send( + &mut svm, + ix_create_escrow(&seller.pubkey(), &buyer.pubkey(), AMOUNT, None, ESCROW_ID), + &seller, + ); + let vault = vault_pda(&escrow_pda(&seller.pubkey(), ESCROW_ID)); + let vault_before = svm.get_account(&vault).map(|a| a.lamports).unwrap_or(0); + + send(&mut svm, ix_deposit(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID), &buyer); + + let escrow = read_escrow(&svm, &seller.pubkey(), ESCROW_ID); + assert_eq!(escrow.state, EscrowState::Active); + + let vault_after = svm.get_account(&vault).map(|a| a.lamports).unwrap_or(0); + assert_eq!(vault_after - vault_before, AMOUNT); +} + +#[test] +fn test_complete_flow() { + 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); + + 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, + ); + + // Escrow account should be closed + assert!(svm.get_account(&escrow_pda(&seller.pubkey(), ESCROW_ID)).is_none()); + // Seller should have received the funds + let seller_after = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(seller_after > seller_before); +} + +#[test] +fn test_dispute_by_buyer() { + 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_eq!(escrow.state, EscrowState::Disputed); +} + +#[test] +fn test_dispute_by_seller() { + 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(&seller.pubkey(), &seller.pubkey(), ESCROW_ID), &seller); + + let escrow = read_escrow(&svm, &seller.pubkey(), ESCROW_ID); + assert_eq!(escrow.state, EscrowState::Disputed); +} + +#[test] +fn test_resolve_seller_wins() { + let (mut svm, seller, buyer, resolver) = setup(); + + send( + &mut svm, + ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + 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 seller_before = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + + send( + &mut svm, + ix_resolve( + &resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + Winner::Seller, + ), + &resolver, + ); + + assert!(svm.get_account(&escrow_pda(&seller.pubkey(), ESCROW_ID)).is_none()); + let seller_after = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(seller_after > seller_before); +} + +#[test] +fn test_resolve_buyer_wins() { + let (mut svm, seller, buyer, resolver) = setup(); + + send( + &mut svm, + ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + 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 buyer_before = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0); + + send( + &mut svm, + ix_resolve( + &resolver.pubkey(), + &buyer.pubkey(), + &seller.pubkey(), + ESCROW_ID, + Winner::Buyer, + ), + &resolver, + ); + + let buyer_after = svm.get_account(&buyer.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(buyer_after > buyer_before); +} + +#[test] +fn test_cancel() { + let (mut svm, seller, buyer, _resolver) = setup(); + + send( + &mut svm, + ix_create_escrow(&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_cancel(&seller.pubkey(), ESCROW_ID), &seller); + + // Escrow account closed, rent returned + assert!(svm.get_account(&escrow_pda(&seller.pubkey(), ESCROW_ID)).is_none()); + let seller_after = svm.get_account(&seller.pubkey()).map(|a| a.lamports).unwrap_or(0); + assert!(seller_after > seller_before); +} + +// ── error cases ─────────────────────────────────────────────────────────────── + +#[test] +fn test_deposit_wrong_buyer_fails() { + let (mut svm, seller, buyer, resolver) = setup(); + + send( + &mut svm, + ix_create_escrow(&seller.pubkey(), &buyer.pubkey(), AMOUNT, None, ESCROW_ID), + &seller, + ); + + // resolver is NOT the buyer — should fail + let bad_ix = ix_deposit(&resolver.pubkey(), &seller.pubkey(), ESCROW_ID); + assert!(!try_send(&mut svm, bad_ix, &resolver)); +} + +#[test] +fn test_complete_wrong_state_fails() { + let (mut svm, seller, buyer, _resolver) = setup(); + + send( + &mut svm, + ix_create_escrow(&seller.pubkey(), &buyer.pubkey(), AMOUNT, None, ESCROW_ID), + &seller, + ); + // Not deposited yet — state is AwaitingDeposit, not Active + let bad_ix = ix_complete(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID); + assert!(!try_send(&mut svm, bad_ix, &buyer)); +} + +#[test] +fn test_complete_wrong_buyer_fails() { + 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); + + // resolver is not the buyer + let bad_ix = ix_complete(&resolver.pubkey(), &seller.pubkey(), ESCROW_ID); + assert!(!try_send(&mut svm, bad_ix, &resolver)); +} + +#[test] +fn test_resolve_no_resolver_fails() { + let (mut svm, seller, buyer, resolver) = setup(); + + // Create escrow WITHOUT a resolver + 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 bad_ix = ix_resolve( + &resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + Winner::Seller, + ); + assert!(!try_send(&mut svm, bad_ix, &resolver)); +} + +#[test] +fn test_resolve_wrong_resolver_fails() { + let (mut svm, seller, buyer, resolver) = setup(); + let wrong_resolver = Keypair::new(); + svm.airdrop(&wrong_resolver.pubkey(), 1_000_000_000).unwrap(); + + send( + &mut svm, + ix_create_escrow( + &seller.pubkey(), + &buyer.pubkey(), + AMOUNT, + Some(resolver.pubkey()), + 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 bad_ix = ix_resolve( + &wrong_resolver.pubkey(), + &seller.pubkey(), + &seller.pubkey(), + ESCROW_ID, + Winner::Seller, + ); + assert!(!try_send(&mut svm, bad_ix, &wrong_resolver)); +} + +#[test] +fn test_cancel_after_deposit_fails() { + 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); + + // State is now Active, cancel requires AwaitingDeposit + let bad_ix = ix_cancel(&seller.pubkey(), ESCROW_ID); + assert!(!try_send(&mut svm, bad_ix, &seller)); +} + +#[test] +fn test_dispute_wrong_state_fails() { + let (mut svm, seller, buyer, _resolver) = setup(); + + send( + &mut svm, + ix_create_escrow(&seller.pubkey(), &buyer.pubkey(), AMOUNT, None, ESCROW_ID), + &seller, + ); + // No deposit — state is AwaitingDeposit, dispute requires Active + let bad_ix = ix_dispute(&buyer.pubkey(), &seller.pubkey(), ESCROW_ID); + assert!(!try_send(&mut svm, bad_ix, &buyer)); +} + +#[test] +fn test_dispute_third_party_fails() { + 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); + + // Resolver is neither buyer nor seller + let bad_ix = ix_dispute(&resolver.pubkey(), &seller.pubkey(), ESCROW_ID); + assert!(!try_send(&mut svm, bad_ix, &resolver)); +} diff --git a/programs/descro/tests/test_initialize.rs b/programs/descro/tests/test_initialize.rs deleted file mode 100644 index 1b09f7b..0000000 --- a/programs/descro/tests/test_initialize.rs +++ /dev/null @@ -1,32 +0,0 @@ - -use { - anchor_lang::{solana_program::instruction::Instruction, InstructionData, ToAccountMetas}, - litesvm::LiteSVM, - solana_message::{Message, VersionedMessage}, - solana_signer::Signer, - solana_keypair::Keypair, - solana_transaction::versioned::VersionedTransaction, -}; - -#[test] -fn test_initialize() { - let program_id = descro::id(); - let payer = Keypair::new(); - let mut svm = LiteSVM::new(); - let bytes = include_bytes!("../../../target/deploy/descro.so"); - svm.add_program(program_id, bytes).unwrap(); - svm.airdrop(&payer.pubkey(), 1_000_000_000).unwrap(); - - let instruction = Instruction::new_with_bytes( - program_id, - &descro::instruction::Initialize {}.data(), - descro::accounts::Initialize {}.to_account_metas(None), - ); - - let blockhash = svm.latest_blockhash(); - let msg = Message::new_with_blockhash(&[instruction], Some(&payer.pubkey()), &blockhash); - let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]).unwrap(); - - let res = svm.send_transaction(tx); - assert!(res.is_ok()); -}