diff --git a/Cargo.toml b/Cargo.toml index 45afb3a..a9d1689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,7 @@ members = [ "contracts/query_engine", "contracts/collateral_lending", + "contracts/atomic_swap", ] [workspace.dependencies] diff --git a/contracts/atomic_swap/Cargo.toml b/contracts/atomic_swap/Cargo.toml new file mode 100644 index 0000000..c825f83 --- /dev/null +++ b/contracts/atomic_swap/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "atomic-swap" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs new file mode 100644 index 0000000..92732d0 --- /dev/null +++ b/contracts/atomic_swap/src/lib.rs @@ -0,0 +1,257 @@ +#![no_std] + +#[cfg(test)] +mod test; + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Bytes, + BytesN, Env, Symbol, +}; + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SwapStatus { + Initiated = 1, + Withdrawn = 2, + Refunded = 3, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Swap { + pub id: BytesN<32>, + pub depositor: Address, + pub claimer: Address, + pub token: Address, + pub amount: i128, + pub hashlock: BytesN<32>, + pub secret: Option>, + pub timelock: u64, + pub status: SwapStatus, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyExists = 1, + NotFound = 2, + InvalidStatus = 3, + HashMismatch = 4, + TimelockNotExpired = 5, + TimelockExpired = 6, + Unauthorized = 7, + InvalidAmount = 8, + InvalidTimelock = 9, +} + +#[contracttype] +pub enum DataKey { + Swap(BytesN<32>), +} + +const SWAP_CREATED: Symbol = symbol_short!("created"); +const SWAP_WITHDRAWN: Symbol = symbol_short!("withdrawn"); +const SWAP_REFUNDED: Symbol = symbol_short!("refunded"); + +#[contract] +pub struct AtomicSwapContract; + +// Helper to calculate hash +fn verify_hashlock(env: &Env, secret: &BytesN<32>, hashlock: &BytesN<32>) -> bool { + let secret_bytes = Bytes::from_slice(env, secret.to_array().as_slice()); + let hash = env.crypto().sha256(&secret_bytes); + let hash_bytes: BytesN<32> = hash.into(); + &hash_bytes == hashlock +} + +#[contractimpl] +impl AtomicSwapContract { + /// Phase 3 - Initiator: Swap Creation + pub fn create_swap( + env: Env, + id: BytesN<32>, + depositor: Address, + claimer: Address, + token: Address, + amount: i128, + hashlock: BytesN<32>, + timelock: u64, + ) -> Result<(), Error> { + depositor.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + if timelock <= env.ledger().timestamp() { + return Err(Error::InvalidTimelock); + } + + if env.storage().persistent().has(&DataKey::Swap(id.clone())) { + return Err(Error::AlreadyExists); + } + + let swap = Swap { + id: id.clone(), + depositor: depositor.clone(), + claimer: claimer.clone(), + token: token.clone(), + amount, + hashlock: hashlock.clone(), + secret: None, + timelock, + status: SwapStatus::Initiated, + }; + + // Transfer tokens into contract custody + let token_client = token::Client::new(&env, &token); + token_client.transfer(&depositor, &env.current_contract_address(), &amount); + + // Save swap + env.storage().persistent().set(&DataKey::Swap(id.clone()), &swap); + + // Emit event + env.events().publish((SWAP_CREATED, id), swap); + + Ok(()) + } + + /// Phase 4 - Participant Acceptance (Linked HTLC Creation) + pub fn accept_swap( + env: Env, + linked_id: BytesN<32>, + new_id: BytesN<32>, + depositor: Address, + token: Address, + amount: i128, + timelock: u64, + ) -> Result<(), Error> { + depositor.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + if timelock <= env.ledger().timestamp() { + return Err(Error::InvalidTimelock); + } + + if env.storage().persistent().has(&DataKey::Swap(new_id.clone())) { + return Err(Error::AlreadyExists); + } + + let linked_swap: Swap = env + .storage() + .persistent() + .get(&DataKey::Swap(linked_id.clone())) + .ok_or(Error::NotFound)?; + + if linked_swap.status != SwapStatus::Initiated { + return Err(Error::InvalidStatus); + } + + if timelock >= linked_swap.timelock { + return Err(Error::InvalidTimelock); + } + + if depositor != linked_swap.claimer { + return Err(Error::Unauthorized); + } + + let swap = Swap { + id: new_id.clone(), + depositor: depositor.clone(), + claimer: linked_swap.depositor.clone(), + token: token.clone(), + amount, + hashlock: linked_swap.hashlock.clone(), + secret: None, + timelock, + status: SwapStatus::Initiated, + }; + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&depositor, &env.current_contract_address(), &amount); + + env.storage().persistent().set(&DataKey::Swap(new_id.clone()), &swap); + env.events().publish((SWAP_CREATED, new_id), swap); + + Ok(()) + } + + /// Phase 6 - Secret Reveal / Withdraw + pub fn withdraw(env: Env, id: BytesN<32>, secret: BytesN<32>) -> Result<(), Error> { + let mut swap: Swap = env + .storage() + .persistent() + .get(&DataKey::Swap(id.clone())) + .ok_or(Error::NotFound)?; + + if swap.status != SwapStatus::Initiated { + return Err(Error::InvalidStatus); + } + + if env.ledger().timestamp() >= swap.timelock { + return Err(Error::TimelockExpired); + } + + if !verify_hashlock(&env, &secret, &swap.hashlock) { + return Err(Error::HashMismatch); + } + + swap.status = SwapStatus::Withdrawn; + swap.secret = Some(secret.clone()); + env.storage().persistent().set(&DataKey::Swap(id.clone()), &swap); + + let token_client = token::Client::new(&env, &swap.token); + token_client.transfer( + &env.current_contract_address(), + &swap.claimer, + &swap.amount, + ); + + env.events().publish((SWAP_WITHDRAWN, id), secret); + Ok(()) + } + + /// Phase 7 - Timeout Refund Logic + pub fn refund(env: Env, id: BytesN<32>) -> Result<(), Error> { + let mut swap: Swap = env + .storage() + .persistent() + .get(&DataKey::Swap(id.clone())) + .ok_or(Error::NotFound)?; + + if swap.status != SwapStatus::Initiated { + return Err(Error::InvalidStatus); + } + + if env.ledger().timestamp() < swap.timelock { + return Err(Error::TimelockNotExpired); + } + + swap.status = SwapStatus::Refunded; + env.storage().persistent().set(&DataKey::Swap(id.clone()), &swap); + + let token_client = token::Client::new(&env, &swap.token); + token_client.transfer( + &env.current_contract_address(), + &swap.depositor, + &swap.amount, + ); + + env.events().publish((SWAP_REFUNDED, id), ()); + Ok(()) + } + + /// Phase 8 - Status Queries & Swap History + pub fn get_swap(env: Env, id: BytesN<32>) -> Result { + env.storage().persistent().get(&DataKey::Swap(id)).ok_or(Error::NotFound) + } + + pub fn get_status(env: Env, id: BytesN<32>) -> Result { + let swap = Self::get_swap(env, id)?; + Ok(swap.status) + } +} diff --git a/contracts/atomic_swap/src/test.rs b/contracts/atomic_swap/src/test.rs new file mode 100644 index 0000000..d222189 --- /dev/null +++ b/contracts/atomic_swap/src/test.rs @@ -0,0 +1,160 @@ +#![cfg(test)] +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, BytesN, Env, +}; + +fn create_token<'a>(env: &Env, admin: &Address) -> (Address, token::StellarAssetClient<'a>) { + let contract_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let client = token::StellarAssetClient::new(env, &contract_id); + (contract_id, client) +} + +fn create_id(env: &Env, num: u8) -> BytesN<32> { + let mut arr = [0u8; 32]; + arr[31] = num; + BytesN::from_array(env, &arr) +} + +fn create_secret(env: &Env, val: u8) -> BytesN<32> { + let mut arr = [0u8; 32]; + arr[0] = val; + BytesN::from_array(env, &arr) +} + +#[test] +fn test_happy_path() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, AtomicSwapContract); + let client = AtomicSwapContractClient::new(&env, &contract_id); + + let initiator = Address::generate(&env); + let participant = Address::generate(&env); + let admin = Address::generate(&env); + + let (token_a, token_a_admin) = create_token(&env, &admin); + let (token_b, token_b_admin) = create_token(&env, &admin); + + token_a_admin.mint(&initiator, &1000); + token_b_admin.mint(&participant, &2000); + + let secret = create_secret(&env, 42); + let secret_bytes = soroban_sdk::Bytes::from_slice(&env, secret.to_array().as_slice()); + let hashlock = env.crypto().sha256(&secret_bytes); + + let swap1_id = create_id(&env, 1); + let timelock1 = 200; + + env.ledger().with_mut(|l| l.timestamp = 100); + + // Initiator creates swap leg 1 + client.create_swap( + &swap1_id, + &initiator, + &participant, + &token_a, + &1000, + &hashlock, + &timelock1, + ); + + let swap1 = client.get_swap(&swap1_id); + assert_eq!(swap1.status, SwapStatus::Initiated); + + let token_a_client = token::Client::new(&env, &token_a); + assert_eq!(token_a_client.balance(&initiator), 0); + assert_eq!(token_a_client.balance(&contract_id), 1000); + + // Participant creates swap leg 2 + let swap2_id = create_id(&env, 2); + let timelock2 = 150; // must be < timelock1 + + client.accept_swap( + &swap1_id, + &swap2_id, + &participant, + &token_b, + &2000, + &timelock2, + ); + + let swap2 = client.get_swap(&swap2_id); + assert_eq!(swap2.status, SwapStatus::Initiated); + + // Initiator reveals secret and withdraws token_b from leg 2 + client.withdraw(&swap2_id, &secret); + let token_b_client = token::Client::new(&env, &token_b); + assert_eq!(token_b_client.balance(&initiator), 2000); + + // Participant sees secret on-chain and uses it to withdraw token_a from leg 1 + client.withdraw(&swap1_id, &secret); + assert_eq!(token_a_client.balance(&participant), 1000); +} + +#[test] +fn test_refund() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, AtomicSwapContract); + let client = AtomicSwapContractClient::new(&env, &contract_id); + + let depositor = Address::generate(&env); + let claimer = Address::generate(&env); + let admin = Address::generate(&env); + + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&depositor, &1000); + + let secret = create_secret(&env, 42); + let hashlock = env.crypto().sha256(&soroban_sdk::Bytes::from_slice(&env, secret.to_array().as_slice())); + let swap_id = create_id(&env, 1); + + env.ledger().with_mut(|l| l.timestamp = 100); + + client.create_swap(&swap_id, &depositor, &claimer, &token, &1000, &hashlock, &200); + + // Try to refund early (fails) + assert!(client.try_refund(&swap_id).is_err()); + + // Advance time + env.ledger().with_mut(|l| l.timestamp = 250); + + // Refund succeeds + client.refund(&swap_id); + let swap = client.get_swap(&swap_id); + assert_eq!(swap.status, SwapStatus::Refunded); + + let token_client = token::Client::new(&env, &token); + assert_eq!(token_client.balance(&depositor), 1000); +} + +#[test] +fn test_hash_mismatch() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, AtomicSwapContract); + let client = AtomicSwapContractClient::new(&env, &contract_id); + + let depositor = Address::generate(&env); + let claimer = Address::generate(&env); + let admin = Address::generate(&env); + + let (token, token_admin) = create_token(&env, &admin); + token_admin.mint(&depositor, &1000); + + let secret = create_secret(&env, 42); + let hashlock = env.crypto().sha256(&soroban_sdk::Bytes::from_slice(&env, secret.to_array().as_slice())); + let swap_id = create_id(&env, 1); + + env.ledger().with_mut(|l| l.timestamp = 100); + + client.create_swap(&swap_id, &depositor, &claimer, &token, &1000, &hashlock, &200); + + let wrong_secret = create_secret(&env, 99); + assert!(client.try_withdraw(&swap_id, &wrong_secret).is_err()); +}