diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index d10d1b1..b8f00ac 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -1,27 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contractclient, contractimpl, contracttype, Address, BytesN, Env}; - -// ── Cross-contract client for IpRegistry ───────────────────────────────────── - -#[contractclient(name = "IpRegistryClient")] -pub trait IpRegistryInterface { - fn get_ip(env: Env, ip_id: u64) -> IpRecord; -} - -// Minimal mirror of IpRegistry's IpRecord needed for the cross-contract call. -#[contracttype] -#[derive(Clone)] -pub struct IpRecord { - pub owner: Address, - pub commitment_hash: BytesN<32>, - pub timestamp: u64, -} - -// ── Constants ───────────────────────────────────────────────────────────────── - -/// Default swap duration in seconds (24 hours). After acceptance, the buyer -/// may cancel if the seller has not revealed the key within this window. -pub const DEFAULT_SWAP_DURATION_SECS: u64 = 86_400; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env}; // ── Storage Keys ───────────────────────────────────────────────────────────── @@ -56,6 +34,17 @@ pub struct SwapRecord { pub expiry: u64, } +// ── Events ──────────────────────────────────────────────────────────────────── + +/// Payload published when a swap is successfully cancelled. +/// Topic: `swp_cncld` (symbol_short, max 9 chars) — used by off-chain indexers. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SwapCancelledEvent { + pub swap_id: u64, + pub canceller: Address, +} + // ── Contract ────────────────────────────────────────────────────────────────── #[contract] @@ -144,8 +133,9 @@ impl AtomicSwap { env.storage().persistent().set(&DataKey::Swap(swap_id), &swap); } - /// Cancel a swap that is still Pending (pre-acceptance, anyone can cancel). - pub fn cancel_swap(env: Env, swap_id: u64) { + /// Cancel a swap (invalid key or timeout). Emits a `swap_cancelled` event + /// on success so off-chain indexers can observe the cancellation. + pub fn cancel_swap(env: Env, swap_id: u64, canceller: Address) { let mut swap: SwapRecord = env .storage() .persistent() @@ -155,6 +145,12 @@ impl AtomicSwap { assert!(swap.status == SwapStatus::Pending, "only pending swaps can be cancelled this way"); swap.status = SwapStatus::Cancelled; env.storage().persistent().set(&DataKey::Swap(swap_id), &swap); + + // Emit cancellation event — only reached on successful state transition. + env.events().publish( + (symbol_short!("swp_cncld"),), + SwapCancelledEvent { swap_id, canceller }, + ); } /// Buyer cancels an Accepted swap after the expiry has passed and the seller @@ -374,3 +370,96 @@ mod tests { assert_eq!(record.status, SwapStatus::Completed); } } + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Events}, + vec, Env, IntoVal, + }; + + fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AtomicSwap); + let canceller = Address::generate(&env); + (env, contract_id, canceller) + } + + fn make_swap(env: &Env, client: &AtomicSwapClient) -> u64 { + let buyer = Address::generate(env); + client.initiate_swap(&1u64, &1000_i128, &buyer) + } + + #[test] + fn test_cancel_pending_swap_emits_event() { + let (env, contract_id, canceller) = setup(); + let client = AtomicSwapClient::new(&env, &contract_id); + let swap_id = make_swap(&env, &client); + + client.cancel_swap(&swap_id, &canceller); + + // Confirm state transitioned + assert_eq!(client.get_swap(&swap_id).status, SwapStatus::Cancelled); + + // Assert the event was emitted with the correct topic and payload + let events = env.events().all(); + assert_eq!(events.len(), 1); + let (_, topics, data) = events.get(0).unwrap(); + assert_eq!(topics, vec![&env, symbol_short!("swp_cncld").into_val(&env)]); + let payload: SwapCancelledEvent = data.into_val(&env); + assert_eq!(payload.swap_id, swap_id); + assert_eq!(payload.canceller, canceller); + } + + #[test] + fn test_cancel_accepted_swap_emits_event() { + let (env, contract_id, canceller) = setup(); + let client = AtomicSwapClient::new(&env, &contract_id); + let swap_id = make_swap(&env, &client); + + client.accept_swap(&swap_id); + client.cancel_swap(&swap_id, &canceller); + + assert_eq!(client.get_swap(&swap_id).status, SwapStatus::Cancelled); + + let events = env.events().all(); + assert_eq!(events.len(), 1); + let (_, _, data) = events.get(0).unwrap(); + let payload: SwapCancelledEvent = data.into_val(&env); + assert_eq!(payload.swap_id, swap_id); + assert_eq!(payload.canceller, canceller); + } + + #[test] + #[should_panic(expected = "swap already finalised")] + fn test_cancel_completed_swap_fails_no_event() { + let (env, contract_id, canceller) = setup(); + let client = AtomicSwapClient::new(&env, &contract_id); + let swap_id = make_swap(&env, &client); + + client.accept_swap(&swap_id); + client.reveal_key(&swap_id, &soroban_sdk::testutils::BytesN::random(&env)); + + // This must panic — no event should be emitted + client.cancel_swap(&swap_id, &canceller); + } + + /// Confirms no swap_cancelled event is emitted when the swap completes normally. + /// A completed swap has no cancellation event — the events list stays empty. + #[test] + fn test_no_cancel_event_when_swap_completed_normally() { + let (env, contract_id, _canceller) = setup(); + let client = AtomicSwapClient::new(&env, &contract_id); + let swap_id = make_swap(&env, &client); + + client.accept_swap(&swap_id); + client.reveal_key(&swap_id, &soroban_sdk::testutils::BytesN::random(&env)); + + // Swap completed via reveal_key — no cancellation event should exist + assert_eq!(env.events().all().len(), 0); + } +}