Skip to content

Commit a6fc538

Browse files
committed
refactor: add tests for dispute resolution and withdrawal rounding edge cases
1 parent d2d6f8a commit a6fc538

File tree

1 file changed

+238
-1
lines changed

1 file changed

+238
-1
lines changed

contracts/escrow/src/tests/dispute.rs

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
extern crate std;
22

3-
use crate::storage::types::{Escrow, Flags, Milestone, Roles, Trustline};
3+
use crate::storage::types::{Escrow, Flags, Milestone, MilestoneUpdate, Roles, Trustline};
44
use soroban_sdk::{testutils::Address as _, vec, Address, Env, Map, String};
55

66
use super::helpers::{create_escrow_contract, create_usdc_token};
@@ -578,3 +578,240 @@ fn test_change_dispute_flag_authorized_and_unauthorized() {
578578
"Unauthorized user should not be able to change dispute flag"
579579
);
580580
}
581+
582+
#[test]
583+
fn test_resolve_dispute_rounding_edge_case() {
584+
let env = Env::default();
585+
env.mock_all_auths();
586+
587+
let admin = Address::generate(&env);
588+
let approver = Address::generate(&env);
589+
let service_provider = Address::generate(&env);
590+
let platform = Address::generate(&env);
591+
let release_signer = Address::generate(&env);
592+
let dispute_resolver = Address::generate(&env);
593+
let trustless_work_address = Address::generate(&env);
594+
595+
let usdc_token = create_usdc_token(&env, &admin);
596+
597+
// Use values where floor division rounding causes a mismatch:
598+
// total = 100_003, TW fee (30 bps) = floor(100_003 * 30 / 10000) = 300,
599+
// platform fee (300 bps) = floor(100_003 * 300 / 10000) = 3000,
600+
// total_fees = 3300.
601+
// Per-recipient floor shares: floor(50_001 * 3300 / 100_003) = 1649, floor(50_002 * 3300 / 100_003) = 1650
602+
// sum(fee_shares) = 3299 < 3300, so the old code would over-distribute by 1.
603+
let total: i128 = 100_003;
604+
let platform_fee: u32 = 300; // 3%
605+
606+
let roles = Roles {
607+
approver: approver.clone(),
608+
service_provider: service_provider.clone(),
609+
platform: platform.clone(),
610+
release_signer: release_signer.clone(),
611+
dispute_resolver: dispute_resolver.clone(),
612+
};
613+
614+
let flags = Flags {
615+
disputed: false,
616+
released: false,
617+
resolved: false,
618+
approved: false,
619+
};
620+
621+
let milestones = vec![
622+
&env,
623+
Milestone {
624+
description: String::from_str(&env, "Milestone"),
625+
status: String::from_str(&env, "Pending"),
626+
evidence: String::from_str(&env, ""),
627+
amount: total,
628+
flags: flags.clone(),
629+
receiver: service_provider.clone(),
630+
},
631+
];
632+
633+
let escrow_properties = Escrow {
634+
engagement_id: String::from_str(&env, "rounding_resolve"),
635+
title: String::from_str(&env, "Rounding Test"),
636+
description: String::from_str(&env, "Test floor division rounding in resolve_dispute"),
637+
roles,
638+
platform_fee,
639+
milestones,
640+
trustline: Trustline {
641+
address: usdc_token.0.address.clone(),
642+
},
643+
receiver_memo: 0,
644+
};
645+
646+
let test_data = create_escrow_contract(&env);
647+
let client = test_data.client;
648+
649+
client.initialize_escrow(&escrow_properties);
650+
651+
// Fund the escrow with exactly total
652+
usdc_token.1.mint(&client.address, &total);
653+
654+
// Put milestone in dispute
655+
client.dispute_milestone(&0, &approver);
656+
657+
// Distributions that trigger the rounding mismatch
658+
let mut distributions = Map::new(&env);
659+
distributions.set(approver.clone(), 50_001);
660+
distributions.set(service_provider.clone(), 50_002);
661+
662+
// This must NOT revert (old code would fail here due to insufficient balance)
663+
let result = client.try_resolve_milestone_dispute(
664+
&dispute_resolver,
665+
&0,
666+
&trustless_work_address,
667+
&distributions,
668+
);
669+
assert!(result.is_ok(), "resolve_milestone_dispute should handle fee rounding correctly");
670+
671+
// Verify contract has no negative balance and all funds were distributed
672+
let final_balance = usdc_token.0.balance(&client.address);
673+
assert!(final_balance >= 0, "Contract balance must be non-negative");
674+
675+
// Verify the total outflows equal exactly the initial balance
676+
let tw_balance = usdc_token.0.balance(&trustless_work_address);
677+
let platform_balance = usdc_token.0.balance(&platform);
678+
let approver_balance = usdc_token.0.balance(&approver);
679+
let sp_balance = usdc_token.0.balance(&service_provider);
680+
681+
let total_outflow = tw_balance + platform_balance + approver_balance + sp_balance;
682+
assert_eq!(
683+
total_outflow + final_balance,
684+
total,
685+
"Sum of all outflows plus remaining balance must equal the original total"
686+
);
687+
688+
// Verify dispute was resolved
689+
let escrow = client.get_escrow();
690+
let milestone = escrow.milestones.get(0).unwrap();
691+
assert!(milestone.flags.resolved);
692+
assert!(!milestone.flags.disputed);
693+
}
694+
695+
#[test]
696+
fn test_withdraw_remaining_funds_rounding_edge_case() {
697+
let env = Env::default();
698+
env.mock_all_auths();
699+
700+
let admin = Address::generate(&env);
701+
let approver = Address::generate(&env);
702+
let service_provider = Address::generate(&env);
703+
let platform = Address::generate(&env);
704+
let release_signer = Address::generate(&env);
705+
let dispute_resolver = Address::generate(&env);
706+
let trustless_work_address = Address::generate(&env);
707+
let recipient_a = Address::generate(&env);
708+
let recipient_b = Address::generate(&env);
709+
710+
let usdc_token = create_usdc_token(&env, &admin);
711+
712+
let milestone_amount: i128 = 1_000_000;
713+
let platform_fee: u32 = 300; // 3%
714+
715+
let roles = Roles {
716+
approver: approver.clone(),
717+
service_provider: service_provider.clone(),
718+
platform: platform.clone(),
719+
release_signer: release_signer.clone(),
720+
dispute_resolver: dispute_resolver.clone(),
721+
};
722+
723+
let flags = Flags {
724+
disputed: false,
725+
released: false,
726+
resolved: false,
727+
approved: false,
728+
};
729+
730+
let milestones = vec![
731+
&env,
732+
Milestone {
733+
description: String::from_str(&env, "Milestone"),
734+
status: String::from_str(&env, "Pending"),
735+
evidence: String::from_str(&env, ""),
736+
amount: milestone_amount,
737+
flags: flags.clone(),
738+
receiver: service_provider.clone(),
739+
},
740+
];
741+
742+
let escrow_properties = Escrow {
743+
engagement_id: String::from_str(&env, "rounding_withdraw"),
744+
title: String::from_str(&env, "Rounding Withdraw Test"),
745+
description: String::from_str(&env, "Test floor division rounding in withdraw"),
746+
roles,
747+
platform_fee,
748+
milestones: milestones.clone(),
749+
trustline: Trustline {
750+
address: usdc_token.0.address.clone(),
751+
},
752+
receiver_memo: 0,
753+
};
754+
755+
let test_data = create_escrow_contract(&env);
756+
let client = test_data.client;
757+
758+
client.initialize_escrow(&escrow_properties);
759+
760+
// Fund and go through the full release flow so withdraw_remaining_funds is allowed
761+
usdc_token.1.mint(&client.address, &milestone_amount);
762+
763+
let milestone_updates = vec![
764+
&env,
765+
MilestoneUpdate {
766+
index: 0,
767+
status: String::from_str(&env, "Completed"),
768+
evidence: Some(String::from_str(&env, "Done")),
769+
},
770+
];
771+
client.change_milestone_status(&milestone_updates, &service_provider);
772+
773+
client.approve_milestone(&0, &approver);
774+
775+
client.release_milestone_funds(&release_signer, &trustless_work_address, &0);
776+
777+
// Simulate remaining funds (e.g. from overfunding or rounding leftovers)
778+
let remaining: i128 = 100_003;
779+
usdc_token.1.mint(&client.address, &remaining);
780+
781+
let balance_before = usdc_token.0.balance(&client.address);
782+
783+
// Record initial balances
784+
let tw_before = usdc_token.0.balance(&trustless_work_address);
785+
let platform_before = usdc_token.0.balance(&platform);
786+
let a_before = usdc_token.0.balance(&recipient_a);
787+
let b_before = usdc_token.0.balance(&recipient_b);
788+
789+
// Distributions that trigger rounding mismatch
790+
let mut distributions = Map::new(&env);
791+
distributions.set(recipient_a.clone(), 50_001);
792+
distributions.set(recipient_b.clone(), 50_002);
793+
794+
let result = client.try_withdraw_remaining_funds(
795+
&dispute_resolver,
796+
&trustless_work_address,
797+
&distributions,
798+
);
799+
assert!(result.is_ok(), "withdraw_remaining_funds should handle fee rounding correctly");
800+
801+
// Verify the contract didn't underflow
802+
let final_balance = usdc_token.0.balance(&client.address);
803+
assert!(final_balance >= 0, "Contract balance must be non-negative");
804+
805+
// Verify total outflows from the withdraw operation
806+
let tw_delta = usdc_token.0.balance(&trustless_work_address) - tw_before;
807+
let platform_delta = usdc_token.0.balance(&platform) - platform_before;
808+
let a_delta = usdc_token.0.balance(&recipient_a) - a_before;
809+
let b_delta = usdc_token.0.balance(&recipient_b) - b_before;
810+
811+
let total_withdrawn = tw_delta + platform_delta + a_delta + b_delta;
812+
let balance_used = balance_before - final_balance;
813+
assert_eq!(
814+
total_withdrawn, balance_used,
815+
"Total withdrawn must equal the contract balance decrease"
816+
);
817+
}

0 commit comments

Comments
 (0)