Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions contracts/htlc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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()
}
}
Expand Down
95 changes: 86 additions & 9 deletions contracts/multi_hop_swap/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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<Hop>,
pub results: Vec<HopResult>,
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 {
Expand All @@ -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<Hop>) -> Vec<HopResult> {
/// Returns swap_id and results for each hop.
pub fn swap(env: Env, caller: Address, hops: Vec<Hop>) -> (BytesN<32>, Vec<HopResult>) {
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();
Expand Down Expand Up @@ -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),
);
}
Expand All @@ -114,13 +144,60 @@ impl MultiHopSwap {
.instance()
.set(&symbol_short!("last_out"), &current_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<i128> {
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<Swap> {
env.storage().persistent().get(&DataKey::Swap(swap_id))
}

// Internal: derive unique swap ID
fn derive_swap_id(env: &Env, caller: &Address, hops: &Vec<Hop>, 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;
50 changes: 48 additions & 2 deletions contracts/multi_hop_swap/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
Loading