Skip to content
Merged
58 changes: 58 additions & 0 deletions app/contract/contracts/quickex/BUILD_AND_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,61 @@ If build/test issues persist after network is resolved:
## Summary

The implementation is **complete and ready**. The only blocker is temporary network connectivity preventing dependency downloads. Once connectivity is restored, running `cargo build` and `cargo test` should succeed and demonstrate that the contract correctly handles both Native XLM and SAC assets across all flows.

## Issue #309 Auth Optimization Benchmark

Benchmark command used:

```bash
cargo test bench_resolve_dispute_recipient --release -- --nocapture --test-threads=1
```

Measured results on the same workspace:

- Before auth optimization: `cpu=401958`, `mem=65755`
- After auth optimization: `cpu=376461`, `mem=58337`

Improvement:

- CPU instructions reduced by `25497` (`6.34%`)
- Memory bytes reduced by `7418` (`11.28%`)

This benchmark targets the disputed escrow resolution recipient path and confirms measurable efficiency gains after removing redundant signature requirements while preserving authorization checks for the arbiter caller.

## Issue #310 Upgrade Simulation Test Harness

A repeatable golden-state migration harness has been added in `src/upgrade_test.rs`.

### Running the harness

```bash
cargo test upgrade_harness_ -- --nocapture
```

### What is tested (17 tests)

| Category | Tests |
|---|---|
| Version tracking | `upgrade_harness_version_is_legacy_before_migrate`, `upgrade_harness_migrate_bumps_version_to_current` |
| Escrow data integrity | `upgrade_harness_pending_escrow_fields_match_golden_state`, `upgrade_harness_pending_escrow_is_withdrawable_post_upgrade`, `upgrade_harness_disputed_escrow_status_preserved`, `upgrade_harness_disputed_escrow_arbitration_works_post_upgrade`, `upgrade_harness_terminal_escrow_statuses_preserved`, `upgrade_harness_already_spent_escrow_rejects_re_withdrawal` |
| Config / roles / privacy | `upgrade_harness_fee_config_survives_migration`, `upgrade_harness_admin_role_is_seeded_post_migration`, `upgrade_harness_privacy_setting_survives_migration` |
| Regression pitfalls | `upgrade_harness_double_migrate_is_idempotent`, `upgrade_harness_non_admin_migrate_fails`, `upgrade_harness_migrate_without_admin_fails_gracefully`, `upgrade_harness_legacy_symbol_privacy_key_readable_after_upgrade`, `upgrade_harness_escrow_counter_survives_migration`, `upgrade_harness_all_lifecycle_statuses_are_distinct_post_migration` |

### Golden state fixture

`build_golden_state()` seeds a `LegacyV0Contract` (schema version 0, no `ContractVersion` stored) with:

- 4 escrows covering all lifecycle states: **Pending**, **Disputed**, **Spent** (withdrawn), **Refunded**
- `FeeConfig { fee_bps: 200 }` (2 % platform fee)
- Privacy enabled for `alice`
- Non-zero escrow counter

### Upgrade flow

1. `env.register_at(contract_id, QuickexContract, ())` — swaps the WASM in-place on the same address
2. `client.migrate(admin)` — runs the v0→v1 schema migration
3. All 17 assertions then verify no data was lost or corrupted

### Results

206 tests passed; 0 failed.
39 changes: 37 additions & 2 deletions app/contract/contracts/quickex/src/admin.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use crate::errors::QuickexError;
use crate::events::{publish_admin_changed, publish_contract_migrated, publish_contract_paused};
use crate::events::{
publish_admin_changed, publish_contract_migrated, publish_contract_paused,
publish_fee_collector_rotated, publish_per_asset_fee_set,
};
use crate::fee_router;
use crate::storage;
use crate::types::{FeeConfig, Role};
use crate::types::{FeeConfig, PerAssetFeeConfig, Role};
use soroban_sdk::{Address, Env, Vec};

/// Initialize the contract with an admin address.
Expand Down Expand Up @@ -218,6 +222,24 @@ pub fn set_fee_config(env: &Env, caller: &Address, config: FeeConfig) -> Result<
Ok(())
}

/// Set per-asset fee configuration (**Admin or Operator only**).
pub fn set_per_asset_fee(
env: &Env,
caller: &Address,
token: Address,
config: PerAssetFeeConfig,
) -> Result<(), QuickexError> {
require_any_role(env, caller, &[Role::Admin, Role::Operator])?;

if config.fee_bps > 10_000 || config.arbiter_bps > 10_000 {
return Err(QuickexError::InvalidAmount);
}

storage::set_per_asset_fee(env, &token, &config);
publish_per_asset_fee_set(env, token, config.fee_bps, config.arbiter_bps);
Ok(())
}

pub fn set_oracle_fee_config(
env: &Env,
caller: &Address,
Expand All @@ -241,3 +263,16 @@ pub fn set_platform_wallet(
crate::events::publish_platform_wallet_changed(env, wallet);
Ok(())
}

/// Rotate active fee collector (**Admin only**).
pub fn rotate_fee_collector(
env: &Env,
caller: &Address,
new_collector: Address,
) -> Result<u32, QuickexError> {
require_admin(env, caller)?;

let next_index = fee_router::rotate_collector(env, &new_collector);
publish_fee_collector_rotated(env, new_collector, next_index);
Ok(next_index)
}
34 changes: 34 additions & 0 deletions app/contract/contracts/quickex/src/bench_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,37 @@ fn bench_verify_proof_view() {
let _ = client.verify_proof_view(&amount, &salt, &owner);
print_budget(&env, "verify_proof_view");
}

/// Benchmark: resolve_dispute (recipient path)
///
/// Captures the disputed-escrow settlement path when funds are awarded to a
/// recipient. This is the path optimized in issue #309 to minimize redundant
/// signature prompts.
#[test]
fn bench_resolve_dispute_recipient() {
let (env, client) = setup();
let token = create_test_token(&env);
let owner = Address::generate(&env);
let arbiter = Address::generate(&env);
let recipient = Address::generate(&env);
let amount: i128 = 1_000_000;
let salt = Bytes::from_slice(&env, b"bench_salt_resolve_dispute");

// Setup: create and dispute escrow — excluded from measurement.
let token_client = token::StellarAssetClient::new(&env, &token);
token_client.mint(&owner, &amount);
let commitment = client.deposit(
&token,
&amount,
&owner,
&salt,
&1000u64,
&Some(arbiter.clone()),
);
client.dispute(&commitment);

// --- Reset budget immediately before the hot path ---
env.cost_estimate().budget().reset_default();
client.resolve_dispute(&arbiter, &commitment, &false, &recipient);
print_budget(&env, "resolve_dispute_recipient");
}
98 changes: 36 additions & 62 deletions app/contract/contracts/quickex/src/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ use soroban_sdk::{token, Address, Bytes, BytesN, Env, Vec};
use crate::{
admin, commitment,
errors::QuickexError,
escrow_id, events, fee, hook,
escrow_id, events, fee_router, hook,
storage::{
count_dispute_votes, get_dispute_vote, get_escrow, get_escrow_id_mapping,
get_platform_wallet, has_dispute_vote, has_escrow, put_dispute_vote, put_escrow,
put_escrow_id_mapping, remove_escrow, LEDGER_THRESHOLD, SIX_MONTHS_IN_LEDGERS,
count_dispute_votes, get_dispute_vote, get_escrow, get_escrow_id_mapping, has_dispute_vote,
has_escrow, put_dispute_vote, put_escrow, put_escrow_id_mapping, remove_escrow,
LEDGER_THRESHOLD, SIX_MONTHS_IN_LEDGERS,
},
types::{DisputeVote, EscrowEntry, EscrowStatus, HookEventKind, Role},
};
Expand Down Expand Up @@ -550,21 +550,8 @@ pub fn withdraw(env: &Env, amount: i128, to: Address, salt: Bytes) -> Result<boo
updated.status = EscrowStatus::Spent;
put_escrow(env, &commitment_bytes, &updated);

let fee_amount = fee::calculate_fee(env, amount_paid);
let payout_amount = amount_paid.saturating_sub(fee_amount);

let token_client = token::Client::new(env, &token_ref);
token_client.transfer(&env.current_contract_address(), &to, &payout_amount);

if fee_amount > 0 {
if let Some(platform_wallet) = get_platform_wallet(env) {
token_client.transfer(
&env.current_contract_address(),
&platform_wallet,
&fee_amount,
);
}
}
let (_payout_amount, fee_amount) =
fee_router::route_payout(env, &token_ref, &to, amount_paid, None);

events::publish_escrow_withdrawn(
env,
Expand Down Expand Up @@ -747,7 +734,7 @@ pub fn dispute(env: &Env, commitment: BytesN<32>) -> Result<(), QuickexError> {

/// Resolve a disputed escrow by determining the recipient of funds.
///
/// - Only callable by the assigned arbiter.
/// - Only callable by the assigned arbiter (or a globally authorized Arbiter role).
/// - Escrow must be in `Disputed` status (INV4).
/// - Arbiter decides whether funds go to owner (refund) or recipient (spend).
///
Expand Down Expand Up @@ -795,38 +782,32 @@ pub fn resolve_dispute(
let (final_status, recipient_address) = if resolve_for_owner {
(EscrowStatus::Refunded, entry.owner.clone())
} else {
recipient.require_auth();
(EscrowStatus::Spent, recipient)
};

let mut updated = entry.clone();
updated.status = final_status;
put_escrow(env, &commitment_bytes, &updated);

let (payout_amount, fee_amount) = if final_status == EscrowStatus::Spent {
let fee = fee::calculate_fee(env, entry.amount_paid);
(entry.amount_paid.saturating_sub(fee), fee)
let (_payout_amount, fee_amount) = if final_status == EscrowStatus::Spent {
fee_router::route_payout(
env,
&entry.token,
&recipient_address,
entry.amount_paid,
Some(&caller),
)
} else {
// Refund path — no fee, direct transfer to owner.
let token_client = token::Client::new(env, &entry.token);
token_client.transfer(
&env.current_contract_address(),
&recipient_address,
&entry.amount_paid,
);
(entry.amount_paid, 0)
};

let token_client = token::Client::new(env, &entry.token);
token_client.transfer(
&env.current_contract_address(),
&recipient_address,
&payout_amount,
);

if fee_amount > 0 {
if let Some(platform_wallet) = get_platform_wallet(env) {
token_client.transfer(
&env.current_contract_address(),
&platform_wallet,
&fee_amount,
);
}
}

if resolve_for_owner {
events::publish_escrow_refunded(
env,
Expand Down Expand Up @@ -1025,38 +1006,31 @@ pub fn resolve_dispute_multi_sig(
let (final_status, recipient_address) = if resolve_for_owner {
(EscrowStatus::Refunded, entry.owner.clone())
} else {
recipient.require_auth();
(EscrowStatus::Spent, recipient)
};

let mut updated = entry.clone();
updated.status = final_status;
put_escrow(env, &commitment_bytes, &updated);

let (payout_amount, fee_amount) = if final_status == EscrowStatus::Spent {
let fee = fee::calculate_fee(env, entry.amount_paid);
(entry.amount_paid.saturating_sub(fee), fee)
let (_payout_amount, fee_amount) = if final_status == EscrowStatus::Spent {
fee_router::route_payout(
env,
&entry.token,
&recipient_address,
entry.amount_paid,
None,
)
} else {
let token_client = token::Client::new(env, &entry.token);
token_client.transfer(
&env.current_contract_address(),
&recipient_address,
&entry.amount_paid,
);
(entry.amount_paid, 0)
};

let token_client = token::Client::new(env, &entry.token);
token_client.transfer(
&env.current_contract_address(),
&recipient_address,
&payout_amount,
);

if fee_amount > 0 {
if let Some(platform_wallet) = get_platform_wallet(env) {
token_client.transfer(
&env.current_contract_address(),
&platform_wallet,
&fee_amount,
);
}
}

// Emit dispute resolved event
events::publish_dispute_resolved(
env,
Expand Down
48 changes: 48 additions & 0 deletions app/contract/contracts/quickex/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,51 @@ pub(crate) fn publish_dispute_resolved(
}
.publish(env);
}

// ---- Fee Router v2 events (Issue #305) -----

#[contractevent(topics = ["TOPIC_ADMIN", "FeeCollectorRotated"])]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FeeCollectorRotatedEvent {
#[topic]
pub new_collector: Address,
pub rotation_index: u32,
pub schema_version: u32,
pub timestamp: u64,
}

pub(crate) fn publish_fee_collector_rotated(
env: &Env,
new_collector: Address,
rotation_index: u32,
) {
FeeCollectorRotatedEvent {
new_collector,
rotation_index,
schema_version: EVENT_SCHEMA_VERSION,
timestamp: env.ledger().timestamp(),
}
.publish(env);
}

#[contractevent(topics = ["TOPIC_ADMIN", "PerAssetFeeSet"])]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PerAssetFeeSetEvent {
#[topic]
pub token: Address,
pub fee_bps: u32,
pub arbiter_bps: u32,
pub schema_version: u32,
pub timestamp: u64,
}

pub(crate) fn publish_per_asset_fee_set(env: &Env, token: Address, fee_bps: u32, arbiter_bps: u32) {
PerAssetFeeSetEvent {
token,
fee_bps,
arbiter_bps,
schema_version: EVENT_SCHEMA_VERSION,
timestamp: env.ledger().timestamp(),
}
.publish(env);
}
Loading
Loading