Skip to content
Closed
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
85 changes: 80 additions & 5 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ pub enum DataKey {
EarlyReclaim(u64),
NextId,
Oracle,
Admin,
DaoAddress,
FeeBps,
}

#[contracttype]
Expand All @@ -80,6 +83,7 @@ pub struct FundsReleasedEvent {
pub client: Address,
pub artisan: Address,
pub amount: i128,
pub fee_amount: i128,
pub token: Address,
}

Expand Down Expand Up @@ -123,6 +127,48 @@ pub struct EscrowContract;

#[contractimpl]
impl EscrowContract {
/// Initialize contract with global parameters
pub fn init_contract(env: Env, admin: Address, dao_address: Address, fee_bps: u32) {
if env.storage().persistent().has(&DataKey::Admin) {
panic!("Contract already initialized");
}
if fee_bps > 1000 {
panic!("Fee cannot exceed 10% (1000 bps)");
}
env.storage().persistent().set(&DataKey::Admin, &admin);
env.storage().persistent().set(&DataKey::DaoAddress, &dao_address);
env.storage().persistent().set(&DataKey::FeeBps, &fee_bps);

env.storage().persistent().extend_ttl(&DataKey::Admin, TTL_THRESHOLD, NEXT_ID_TTL);
env.storage().persistent().extend_ttl(&DataKey::DaoAddress, TTL_THRESHOLD, NEXT_ID_TTL);
env.storage().persistent().extend_ttl(&DataKey::FeeBps, TTL_THRESHOLD, NEXT_ID_TTL);
}

/// Update the protocol fee
pub fn set_fee(env: Env, admin: Address, fee_bps: u32) {
admin.require_auth();
let stored_admin: Address = env.storage().persistent().get(&DataKey::Admin).expect("Contract not initialized");
if admin != stored_admin {
panic!("Unauthorized: caller is not admin");
}
if fee_bps > 1000 {
panic!("Fee cannot exceed 10% (1000 bps)");
}
env.storage().persistent().set(&DataKey::FeeBps, &fee_bps);
env.storage().persistent().extend_ttl(&DataKey::FeeBps, TTL_THRESHOLD, NEXT_ID_TTL);
}

/// Update the DAO address
pub fn set_dao(env: Env, admin: Address, dao_address: Address) {
admin.require_auth();
let stored_admin: Address = env.storage().persistent().get(&DataKey::Admin).expect("Contract not initialized");
if admin != stored_admin {
panic!("Unauthorized: caller is not admin");
}
env.storage().persistent().set(&DataKey::DaoAddress, &dao_address);
env.storage().persistent().extend_ttl(&DataKey::DaoAddress, TTL_THRESHOLD, NEXT_ID_TTL);
}

/// Initialize a new escrow engagement
/// Creates a new escrow record with Pending status and a per-escrow arbitrator
pub fn initialize(
Expand Down Expand Up @@ -283,11 +329,35 @@ impl EscrowContract {

// Logic: Transfer the stored escrow amount from the contract address to the artisan's address
let token_client = token::Client::new(&env, &token);
token_client.transfer(
&env.current_contract_address(),
&escrow.artisan,
&escrow.amount,
);

// Calculate fee
let fee_bps: u32 = env.storage().persistent().get(&DataKey::FeeBps).unwrap_or(0);

let fee_amount = if fee_bps > 0 {
let fee_num = (escrow.amount as i128).checked_mul(fee_bps as i128).expect("Arithmetic overflow");
fee_num.checked_div(10000).expect("Arithmetic error")
} else {
0
};

let artisan_amount = escrow.amount.checked_sub(fee_amount).expect("Arithmetic underflow");

if fee_amount > 0 {
let dao_address: Address = env.storage().persistent().get(&DataKey::DaoAddress).expect("DAO address not set");
token_client.transfer(
&env.current_contract_address(),
&dao_address,
&fee_amount,
);
}

if artisan_amount > 0 {
token_client.transfer(
&env.current_contract_address(),
&escrow.artisan,
&artisan_amount,
);
}

// State: Update the escrow status to Released
escrow.status = Status::Released;
Expand All @@ -304,6 +374,7 @@ impl EscrowContract {
client: escrow.client,
artisan: escrow.artisan,
amount: escrow.amount,
fee_amount,
token: escrow.token,
},
);
Expand Down Expand Up @@ -500,6 +571,10 @@ impl EscrowContract {
/// Set the oracle address for verifying physical arrival
pub fn set_oracle(env: Env, admin: Address, oracle: Address) {
admin.require_auth();
let stored_admin: Address = env.storage().persistent().get(&DataKey::Admin).expect("Contract not initialized");
if admin != stored_admin {
panic!("Unauthorized: caller is not admin");
}
env.storage().persistent().set(&DataKey::Oracle, &oracle);
env.storage()
.persistent()
Expand Down
105 changes: 104 additions & 1 deletion contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ mod happy_path_tests {
contract_id: Address,
token_address: Address,
default_arbitrator: Address,
admin: Address,
dao: Address,
client_contract: EscrowContractClient<'static>,
token_client: token::Client<'static>,
token_contract_client: token::StellarAssetClient<'static>,
Expand All @@ -28,16 +30,22 @@ mod happy_path_tests {
let token_contract = env.register_stellar_asset_contract_v2(token_admin);
let token_address = token_contract.address();
let default_arbitrator = Address::generate(&env);
let admin = Address::generate(&env);
let dao = Address::generate(&env);

let client_contract = EscrowContractClient::new(&env, &contract_id);
let token_client = token::Client::new(&env, &token_address);
let token_contract_client = token::StellarAssetClient::new(&env, &token_address);

client_contract.init_contract(&admin, &dao, &0);

TestContext {
env,
contract_id,
token_address,
default_arbitrator,
admin,
dao,
client_contract,
token_client,
token_contract_client,
Expand Down Expand Up @@ -1009,10 +1017,105 @@ mod happy_path_tests {

let engagement_id = ctx.full_deposit_workflow(&client, &artisan, amount);

// Client approves twice
ctx.client_contract
.approve_early_reclaim(&engagement_id, &client);
ctx.client_contract
.approve_early_reclaim(&engagement_id, &client);
}

/// Test 31: Double initialization fails
#[test]
#[should_panic(expected = "Contract already initialized")]
fn test_init_contract_already_initialized_fails() {
let ctx = TestContext::new(); // already initializes
let admin = Address::generate(&ctx.env);
let dao = Address::generate(&ctx.env);
ctx.client_contract.init_contract(&admin, &dao, &100);
}

/// Test 32: Initialization with fee > 10% fails
#[test]
#[should_panic(expected = "Fee cannot exceed 10% (1000 bps)")]
fn test_init_contract_fee_too_high_fails() {
let env = Env::default();
let contract_id = env.register_contract(None, EscrowContract);
let client_contract = EscrowContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let dao = Address::generate(&env);
client_contract.init_contract(&admin, &dao, &1001);
}

/// Test 33: Set fee with admin
#[test]
fn test_set_fee_success() {
let ctx = TestContext::new();
ctx.client_contract.set_fee(&ctx.admin, &500); // 5%
}

/// Test 34: Set fee unauthorized
#[test]
#[should_panic(expected = "Unauthorized: caller is not admin")]
fn test_set_fee_unauthorized_fails() {
let ctx = TestContext::new();
let fake_admin = Address::generate(&ctx.env);
ctx.client_contract.set_fee(&fake_admin, &500);
}

/// Test 35: Set fee too high
#[test]
#[should_panic(expected = "Fee cannot exceed 10% (1000 bps)")]
fn test_set_fee_too_high_fails() {
let ctx = TestContext::new();
ctx.client_contract.set_fee(&ctx.admin, &1001);
}

/// Test 36: Release with fee deduction
#[test]
fn test_release_with_fee_deduction() {
let ctx = TestContext::new();
let (client, artisan) = create_addresses(&ctx.env);
let amount: i128 = 5000;

// Set fee to 100 bps (1%)
ctx.client_contract.set_fee(&ctx.admin, &100);

let engagement_id = ctx.full_deposit_workflow(&client, &artisan, amount);

let dao_balance_before = ctx.token_client.balance(&ctx.dao);
let artisan_balance_before = ctx.token_client.balance(&artisan);

ctx.release_funds(engagement_id);

let expected_fee = 50; // 1% of 5000
let expected_artisan_amount = 4950;

assert_eq!(ctx.token_client.balance(&ctx.dao), dao_balance_before + expected_fee);
assert_eq!(ctx.token_client.balance(&artisan), artisan_balance_before + expected_artisan_amount);
}

/// Test 37: Release with fee rounding edge case
#[test]
fn test_release_with_fee_rounding_edge_case() {
let ctx = TestContext::new();
let (client, artisan) = create_addresses(&ctx.env);
let amount: i128 = 5099;

// Set fee to 100 bps (1%)
ctx.client_contract.set_fee(&ctx.admin, &100);

let engagement_id = ctx.full_deposit_workflow(&client, &artisan, amount);

let dao_balance_before = ctx.token_client.balance(&ctx.dao);
let artisan_balance_before = ctx.token_client.balance(&artisan);

ctx.release_funds(engagement_id);

// 5099 * 100 = 509900
// 509900 / 10000 = 50 (integer division floors it)
let expected_fee = 50;
let expected_artisan_amount = 5049;

assert_eq!(ctx.token_client.balance(&ctx.dao), dao_balance_before + expected_fee);
assert_eq!(ctx.token_client.balance(&artisan), artisan_balance_before + expected_artisan_amount);
}
}
Loading