diff --git a/contracts/htlc/src/lib.rs b/contracts/htlc/src/lib.rs index 57905c69..98dd8ef5 100644 --- a/contracts/htlc/src/lib.rs +++ b/contracts/htlc/src/lib.rs @@ -104,7 +104,10 @@ impl HtlcContract { let ttl_ledgers = expiry_ledger.saturating_add(SWAP_TTL_GRACE_LEDGERS); env.storage().persistent().set_with_ttl(&DataKey::Swap(swap_id.clone()), &swap, ttl_ledgers); - env.events().publish((symbol_short!("SwapInit"),), swap_id.clone()); + env.events().publish( + (symbol_short!("SwapInit"),), + (swap_id.clone(), initiator.clone(), recipient.clone(), token.clone(), amount, expiry_ledger), + ); swap_id } @@ -150,7 +153,10 @@ impl HtlcContract { let token_client = token::Client::new(&env, &swap.token); token_client.transfer(&env.current_contract_address(), &swap.recipient, &swap.amount); - env.events().publish((symbol_short!("Claimed"),), swap_id); + env.events().publish( + (symbol_short!("Claimed"),), + (swap_id.clone(), swap.recipient.clone(), swap.token.clone(), swap.amount), + ); } /// Refund the locked funds back to the initiator after expiry. @@ -180,7 +186,10 @@ impl HtlcContract { let token_client = token::Client::new(&env, &swap.token); token_client.transfer(&env.current_contract_address(), &swap.initiator, &swap.amount); - env.events().publish((symbol_short!("Refunded"),), swap_id); + env.events().publish( + (symbol_short!("Refunded"),), + (swap_id.clone(), swap.initiator.clone(), swap.token.clone(), swap.amount), + ); } /// Returns the swap details for a given swap_id. @@ -201,14 +210,12 @@ impl HtlcContract { ) -> BytesN<32> { let mut data = Bytes::new(env); data.extend_from_slice(secret_hash.to_array().as_ref()); + data.extend_from_slice(&initiator.to_array()); // Encode expiry as 4 little-endian bytes data.push_back((expiry_ledger & 0xff) as u8); data.push_back(((expiry_ledger >> 8) & 0xff) as u8); data.push_back(((expiry_ledger >> 16) & 0xff) as u8); data.push_back(((expiry_ledger >> 24) & 0xff) as u8); - // Include initiator address bytes via its string representation length as entropy - // We use the secret_hash + expiry as the primary uniqueness factor - let _ = initiator; // initiator auth already enforced above env.crypto().sha256(&data).into() } } diff --git a/contracts/multi_hop_swap/src/lib.rs b/contracts/multi_hop_swap/src/lib.rs index 14d98414..247faf49 100644 --- a/contracts/multi_hop_swap/src/lib.rs +++ b/contracts/multi_hop_swap/src/lib.rs @@ -1,9 +1,15 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, contractclient, symbol_short, vec, Env, Address, Vec, token}; +use soroban_sdk::{contract, contractimpl, contracttype, contractclient, symbol_short, vec, Env, Address, Vec, token, Bytes, BytesN}; -// TTL for pool state (price and reserve): ~30 days (6_048_000 ledgers at 5s/ledger) -// Pool state is extended on each swap to remain active; inactive pools expire and reset. -const POOL_STATE_TTL_LEDGERS: u32 = 6_048_000; +// TTL for swap state: ~30 days (6_048_000 ledgers at 5s/ledger) +const SWAP_STATE_TTL_LEDGERS: u32 = 6_048_000; + +/// Swap status enum +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SwapStatus { + Completed, +} /// One leg of a swap route. #[contracttype] @@ -27,6 +33,24 @@ pub struct HopResult { pub amount_out: i128, } +/// Full swap state stored on chain +#[contracttype] +#[derive(Clone)] +pub struct Swap { + pub caller: Address, + pub hops: Vec, + pub results: Vec, + pub status: SwapStatus, + pub created_ledger: u32, +} + +/// Storage key enum +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Swap(BytesN<32>), +} + /// Pool interface trait — downstream pools must implement this. #[contractclient(name = "PoolClient")] pub trait PoolTrait { @@ -42,14 +66,20 @@ pub struct MultiHopSwap; impl MultiHopSwap { /// Execute a multi-hop swap across `hops` pools. /// Each hop: transfers tokens to pool, calls pool's swap, transfers output to next hop (or caller at end). - /// Returns results for each hop. - pub fn swap(env: Env, caller: Address, hops: Vec) -> Vec { + /// Returns swap_id and results for each hop. + pub fn swap(env: Env, caller: Address, hops: Vec) -> (BytesN<32>, Vec) { caller.require_auth(); if hops.is_empty() { panic!("no hops provided"); } + let swap_id = Self::derive_swap_id(&env, &caller, &hops, env.ledger().sequence()); + + if env.storage().persistent().has(&DataKey::Swap(swap_id.clone())) { + panic!("swap already executed (replay attempt)"); + } + let mut results = vec![&env]; let mut current_amount = 0_i128; let contract_address = env.current_contract_address(); @@ -97,9 +127,9 @@ impl MultiHopSwap { // Update current amount for next hop current_amount = amount_out; - // Emit event + // Emit event with swap ID env.events().publish( - (symbol_short!("hop"), hop.pool.clone()), + (symbol_short!("hop"), swap_id.clone(), hop.pool.clone()), (hop.token_in.clone(), hop.token_out.clone(), amount_in, amount_out), ); } @@ -114,13 +144,60 @@ impl MultiHopSwap { .instance() .set(&symbol_short!("last_out"), ¤t_amount); - results + // Store swap state + let swap = Swap { + caller: caller.clone(), + hops: hops.clone(), + results: results.clone(), + status: SwapStatus::Completed, + created_ledger: env.ledger().sequence(), + }; + env.storage().persistent().set_with_ttl(&DataKey::Swap(swap_id.clone()), &swap, SWAP_STATE_TTL_LEDGERS); + + // Emit swap completion event with swap ID + env.events().publish( + (symbol_short!("SwapCompleted"),), + (swap_id.clone(), caller.clone(), current_amount), + ); + + (swap_id, results) } /// Returns the last output amount recorded. pub fn get_last_out(env: Env) -> Option { env.storage().instance().get(&symbol_short!("last_out")) } + + /// Returns swap details for given swap_id + pub fn get_swap(env: Env, swap_id: BytesN<32>) -> Option { + env.storage().persistent().get(&DataKey::Swap(swap_id)) + } + + // Internal: derive unique swap ID + fn derive_swap_id(env: &Env, caller: &Address, hops: &Vec, created_ledger: u32) -> BytesN<32> { + let mut data = Bytes::new(env); + // Add caller address + data.extend_from_slice(&caller.to_array()); + // Add created ledger as LE bytes + data.push_back((created_ledger & 0xff) as u8); + data.push_back(((created_ledger >> 8) & 0xff) as u8); + data.push_back(((created_ledger >> 16) & 0xff) as u8); + data.push_back(((created_ledger >> 24) & 0xff) as u8); + // Add number of hops + data.push_back(hops.len() as u8); + // Add each hop's details + for hop in hops.iter() { + data.extend_from_slice(&hop.pool.to_array()); + data.extend_from_slice(&hop.token_in.to_array()); + data.extend_from_slice(&hop.token_out.to_array()); + // Add amount_in as LE bytes (16 bytes for i128) + let amount_bytes = hop.amount_in.to_le_bytes(); + for b in amount_bytes { + data.push_back(b); + } + } + env.crypto().sha256(&data).into() + } } mod test; diff --git a/contracts/multi_hop_swap/src/test.rs b/contracts/multi_hop_swap/src/test.rs index 16910c3e..07d6cf3e 100644 --- a/contracts/multi_hop_swap/src/test.rs +++ b/contracts/multi_hop_swap/src/test.rs @@ -66,7 +66,7 @@ fn test_single_hop_swap() { amount_in: 100, min_amount_out: 199, }]; - let results = multi_hop_client.swap(&caller, &hops); + let (swap_id, results) = multi_hop_client.swap(&caller, &hops); // Check results assert_eq!(results.len(), 1); @@ -78,6 +78,11 @@ fn test_single_hop_swap() { // Check last out assert_eq!(multi_hop_client.get_last_out(), Some(200)); + + // Check get_swap returns correct data + let swap = multi_hop_client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.caller, caller); + assert_eq!(swap.status, SwapStatus::Completed); } #[test] @@ -128,7 +133,7 @@ fn test_multi_hop_swap() { min_amount_out: 599, }, ]; - let results = multi_hop_client.swap(&caller, &hops); + let (swap_id, results) = multi_hop_client.swap(&caller, &hops); // Check results assert_eq!(results.len(), 2); @@ -137,6 +142,10 @@ fn test_multi_hop_swap() { // Check caller has received tokens assert_eq!(TokenClient::new(&env, &token_c).balance(&caller), 600); + + // Check get_swap returns correct data + let swap = multi_hop_client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.caller, caller); } #[test] @@ -181,3 +190,40 @@ fn test_slippage_guard() { }]; multi_hop_client.swap(&caller, &hops); } + +#[test] +#[should_panic(expected = "swap already executed (replay attempt)")] +fn test_replay_attack_blocked() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let token_a = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_b = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + + let pool_id = env.register(MockPool, ()); + let pool_client = MockPoolClient::new(&env, &pool_id); + pool_client.initialize(&2, &1); + StellarAssetClient::new(&env, &token_b).mint(&pool_id, &1000); + + let multi_hop_id = env.register(MultiHopSwap, ()); + let multi_hop_client = MultiHopSwapClient::new(&env, &multi_hop_id); + + let caller = Address::generate(&env); + StellarAssetClient::new(&env, &token_a).mint(&caller, &200); // Mint enough for 2 swaps + + let hops = vec![&env, Hop { + pool: pool_id, + token_in: token_a.clone(), + token_out: token_b.clone(), + amount_in: 100, + min_amount_out: 199, + }]; + + // First swap (ok) + multi_hop_client.swap(&caller, &hops); + + // Second swap with same hops (replay attempt, should panic) + // Need to bump ledger to get different swap_id? Wait no, same ledger would have same swap_id + multi_hop_client.swap(&caller, &hops); +}