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
6 changes: 5 additions & 1 deletion app/contract/contracts/Folder/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,10 @@ pub fn set_fee_config(
) -> Result<(), RustAcademyError> {
require_any_role(env, caller, &[Role::Admin, Role::Operator])?;

if config.fee_bps > 10_000 {
return Err(RustAcademyError::FeeExceedsMaximum);
}

storage::set_fee_config(env, &config);
crate::events::publish_fee_config_changed(env, config.fee_bps);
Ok(())
Expand All @@ -635,7 +639,7 @@ pub fn set_per_asset_fee(
require_any_role(env, caller, &[Role::Admin, Role::Operator])?;

if config.fee_bps > 10_000 || config.arbiter_bps > 10_000 {
return Err(RustAcademyError::InvalidAmount);
return Err(RustAcademyError::FeeExceedsMaximum);
}
config.validate()?;

Expand Down
4 changes: 4 additions & 0 deletions app/contract/contracts/Folder/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ pub enum RustAcademyError {
InsufficientVotes = 321,
/// Fee ratios or denominators are invalid for the configured payout split.
InvalidFeeConfiguration = 322,
/// Fee basis points exceed the maximum allowed value (10000 = 100%).
FeeExceedsMaximum = 329,
/// Fee is configured but no valid recipient (platform wallet or collector) is set.
FeeRecipientRequired = 330,
/// Dispute resolution threshold is zero, exceeds arbiter count, or the
/// arbiters list is empty.
InvalidThreshold = 326,
Expand Down
22 changes: 22 additions & 0 deletions app/contract/contracts/Folder/src/fee_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ pub fn active_collector(env: &Env) -> Option<Address> {
storage::get_platform_wallet(env)
}

/// Validate that a fee recipient exists when fees are non-zero.
///
/// Returns an error if `fee_amount > 0` but no platform wallet or active
/// collector is configured. This prevents silent fee loss where fees remain
/// in the contract without a designated recipient.
pub fn validate_fee_recipient(env: &Env, fee_amount: i128) -> Result<(), RustAcademyError> {
if fee_amount > 0 {
let platform_wallet = storage::get_platform_wallet(env);
let idx = storage::get_fee_collector_index(env);
let rotated_collector = storage::get_fee_collector_at(env, idx);

if platform_wallet.is_none() && rotated_collector.is_none() {
return Err(RustAcademyError::FeeRecipientRequired);
}
}
Ok(())
}

fn uses_explicit_fee_distribution(config: &crate::types::PerAssetFeeConfig) -> bool {
config.arbiter_fee.is_active()
|| config.platform_fee.is_active()
Expand Down Expand Up @@ -143,6 +161,10 @@ pub fn route_payout(

// Resolve total fee using per-asset → oracle → global priority.
let total_fee = fee::calculate_fee_for_token(env, token, amount);

// Validate that a fee recipient exists when fees are non-zero.
validate_fee_recipient(env, total_fee)?;

let net_payout = amount.saturating_sub(total_fee);
let token_client = token::Client::new(env, token);
let per_asset = storage::get_per_asset_fee(env, token);
Expand Down
270 changes: 270 additions & 0 deletions app/contract/contracts/Folder/src/fee_router_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,273 @@ fn test_fee_router_rejects_overallocated_explicit_split() {
Some(EscrowStatus::Pending)
);
}

#[test]
fn test_fee_router_zero_fee_allows_payout_without_recipient() {
let (env, client, admin) = setup();

let token_id = create_token(&env);
let owner = Address::generate(&env);

let token_admin = token::StellarAssetClient::new(&env, &token_id);
let token_client = token::Client::new(&env, &token_id);

token_admin.mint(&owner, &10_000);

// Set 0% fee - no recipient required
client.set_fee_config(&admin, &crate::types::FeeConfig { fee_bps: 0 });

let amount: i128 = 1_000;
let salt = Bytes::from_slice(&env, b"zero_fee_salt");
let commitment = client.deposit(&token_id, &amount, &owner, &salt, &0, &None);
client.withdraw(&token_id, &amount, &commitment, &owner, &salt);

// Full amount returned to owner, no fees collected
assert_eq!(token_client.balance(&owner), 10_000);
assert_eq!(token_client.balance(&client.address), 0);
}

#[test]
fn test_fee_router_hundred_percent_fee_with_recipient() {
let (env, client, admin) = setup();

let token_id = create_token(&env);
let owner = Address::generate(&env);
let collector = Address::generate(&env);

let token_admin = token::StellarAssetClient::new(&env, &token_id);
let token_client = token::Client::new(&env, &token_id);

token_admin.mint(&owner, &10_000);

// Set 100% fee with valid recipient
client.set_fee_config(&admin, &crate::types::FeeConfig { fee_bps: 10_000 });
client.set_platform_wallet(&admin, &collector);

let amount: i128 = 1_000;
let salt = Bytes::from_slice(&env, b"hundred_percent_salt");
let commitment = client.deposit(&token_id, &amount, &owner, &salt, &0, &None);
client.withdraw(&token_id, &amount, &commitment, &owner, &salt);

// Entire fee goes to collector, owner gets nothing from this withdrawal
assert_eq!(token_client.balance(&owner), 9_000);
assert_eq!(token_client.balance(&collector), 1_000);
assert_eq!(token_client.balance(&client.address), 0);
}

#[test]
fn test_fee_router_rejects_nonzero_fee_without_recipient() {
let (env, client, admin) = setup();

let token_id = create_token(&env);
let owner = Address::generate(&env);

let token_admin = token::StellarAssetClient::new(&env, &token_id);
token_admin.mint(&owner, &10_000);

// Set non-zero fee but no recipient
client.set_fee_config(&admin, &crate::types::FeeConfig { fee_bps: 1_000 });

let amount: i128 = 1_000;
let salt = Bytes::from_slice(&env, b"no_recipient_salt");
let commitment = client.deposit(&token_id, &amount, &owner, &salt, &0, &None);

// Withdrawal should fail with FeeRecipientRequired
let result = client.try_withdraw(&token_id, &amount, &commitment, &owner, &salt);
assert!(matches!(result, Err(Ok(crate::RustAcademyError::FeeRecipientRequired)) | Err(Err(_))));
}

#[test]
fn test_fee_router_rotated_collector_receives_fees() {
let (env, client, admin) = setup();

let token_id = create_token(&env);
let owner = Address::generate(&env);
let collector_v1 = Address::generate(&env);
let collector_v2 = Address::generate(&env);

let token_admin = token::StellarAssetClient::new(&env, &token_id);
let token_client = token::Client::new(&env, &token_id);

token_admin.mint(&owner, &10_000);

// Set initial collector
client.set_fee_config(&admin, &crate::types::FeeConfig { fee_bps: 1_000 });
client.rotate_fee_collector(&admin, &collector_v1);

let amount_v1: i128 = 1_000;
let salt_v1 = Bytes::from_slice(&env, b"collector_v1_salt");
let commitment_v1 = client.deposit(&token_id, &amount_v1, &owner, &salt_v1, &0, &None);
client.withdraw(&token_id, &amount_v1, &commitment_v1, &owner, &salt_v1);

// Rotate to new collector
client.rotate_fee_collector(&admin, &collector_v2);

let amount_v2: i128 = 1_000;
let salt_v2 = Bytes::from_slice(&env, b"collector_v2_salt");
let commitment_v2 = client.deposit(&token_id, &amount_v2, &owner, &salt_v2, &0, &None);
client.withdraw(&token_id, &amount_v2, &commitment_v2, &owner, &salt_v2);

// v1 collector got first fee, v2 collector got second fee
assert_eq!(token_client.balance(&collector_v1), 100);
assert_eq!(token_client.balance(&collector_v2), 100);
}

#[test]
fn test_fee_router_per_asset_override_with_arbiter_split() {
let (env, client, admin) = setup();

let token_id = create_token(&env);
let owner = Address::generate(&env);
let recipient = Address::generate(&env);
let arbiter = Address::generate(&env);
let collector = Address::generate(&env);

let token_admin = token::StellarAssetClient::new(&env, &token_id);
let token_client = token::Client::new(&env, &token_id);

token_admin.mint(&owner, &10_000);

// Global fee 5%
client.set_fee_config(&admin, &crate::types::FeeConfig { fee_bps: 500 });
client.rotate_fee_collector(&admin, &collector);

// Per-asset override 10% with 20% arbiter split
client.set_per_asset_fee(
&admin,
&token_id,
&PerAssetFeeConfig {
fee_bps: 1_000,
arbiter_bps: 2_000,
..Default::default()
},
);

let amount: i128 = 1_000;
let salt = Bytes::from_slice(&env, b"per_asset_arbiter_salt");
let commitment = client.deposit(
&token_id,
&amount,
&owner,
&salt,
&0,
&Some(arbiter.clone()),
);

// Use dispute resolution to test arbiter split (arbiter only paid in dispute path)
client.dispute(&commitment);
client.resolve_dispute(&arbiter, &commitment, &false, &recipient);

// Per-asset override: 10% fee = 100, 20% to arbiter = 20, 80% to collector = 80
assert_eq!(token_client.balance(&arbiter), 20);
assert_eq!(token_client.balance(&collector), 80);
// Recipient gets net payout: 1000 - 100 = 900
assert_eq!(token_client.balance(&recipient), 900);

// Invariant: net payout + arbiter_fee + collector_fee = escrow amount
assert_eq!(900 + 20 + 80, amount);
}

#[test]
fn test_fee_router_payout_plus_fees_equals_escrow_amount() {
let (env, client, admin) = setup();

let token_id = create_token(&env);
let owner = Address::generate(&env);
let arbiter = Address::generate(&env);
let platform_wallet = Address::generate(&env);
let collector = Address::generate(&env);

let token_admin = token::StellarAssetClient::new(&env, &token_id);
let token_client = token::Client::new(&env, &token_id);

token_admin.mint(&owner, &10_000);

client.set_platform_wallet(&admin, &platform_wallet);
client.rotate_fee_collector(&admin, &collector);
client.set_per_asset_fee(
&admin,
&token_id,
&PerAssetFeeConfig {
fee_bps: 1_000,
arbiter_bps: 2_000,
arbiter_fee: FeeRatio {
numerator: 1,
denominator: 5,
},
platform_fee: FeeRatio {
numerator: 3,
denominator: 10,
},
collector_fee: FeeRatio {
numerator: 1,
denominator: 2,
},
},
);

let amount: i128 = 1_000;
let salt = Bytes::from_slice(&env, b"invariant_sum_salt");
let commitment = client.deposit(
&token_id,
&amount,
&owner,
&salt,
&0,
&Some(arbiter.clone()),
);
client.withdraw(&token_id, &amount, &commitment, &owner, &salt);

// Fee math: total_fee = 100 (10%)
// arbiter_fee = 100 * 1/5 = 20
// platform_fee = 100 * 3/10 = 30
// collector_fee = 100 * 1/2 = 50
// net_payout = 1000 - 100 = 900
// Total distributed = 900 + 20 + 30 + 50 = 1000 ✓

let arbiter_balance = token_client.balance(&arbiter);
let platform_balance = token_client.balance(&platform_wallet);
let collector_balance = token_client.balance(&collector);

// Owner's balance after: 10000 - 1000 (deposit) + 900 (net payout) = 9900
// Net payout received = 900
let net_payout = 900;

// Invariant: net payout + all fee components = escrow amount
let total_distributed = net_payout + arbiter_balance + platform_balance + collector_balance;
assert_eq!(total_distributed, amount);
}

#[test]
fn test_fee_config_rejects_exceeds_maximum() {
let (env, client, admin) = setup();

// Test global fee config validation
let result = client.try_set_fee_config(&admin, &crate::types::FeeConfig { fee_bps: 10_001 });
assert!(matches!(result, Err(Ok(crate::RustAcademyError::FeeExceedsMaximum)) | Err(Err(_))));

// Test per-asset fee config validation
let token_id = create_token(&env);
let result = client.try_set_per_asset_fee(
&admin,
&token_id,
&PerAssetFeeConfig {
fee_bps: 10_001,
arbiter_bps: 0,
..Default::default()
},
);
assert!(matches!(result, Err(Ok(crate::RustAcademyError::FeeExceedsMaximum)) | Err(Err(_))));

// Test arbiter_bps validation
let result = client.try_set_per_asset_fee(
&admin,
&token_id,
&PerAssetFeeConfig {
fee_bps: 1_000,
arbiter_bps: 10_001,
..Default::default()
},
);
assert!(matches!(result, Err(Ok(crate::RustAcademyError::FeeExceedsMaximum)) | Err(Err(_))));
}
Loading
Loading