|
1 | 1 | extern crate std; |
2 | 2 |
|
3 | | -use crate::storage::types::{Escrow, Flags, Milestone, Roles, Trustline}; |
| 3 | +use crate::storage::types::{Escrow, Flags, Milestone, MilestoneUpdate, Roles, Trustline}; |
4 | 4 | use soroban_sdk::{testutils::Address as _, vec, Address, Env, Map, String}; |
5 | 5 |
|
6 | 6 | use super::helpers::{create_escrow_contract, create_usdc_token}; |
@@ -578,3 +578,240 @@ fn test_change_dispute_flag_authorized_and_unauthorized() { |
578 | 578 | "Unauthorized user should not be able to change dispute flag" |
579 | 579 | ); |
580 | 580 | } |
| 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