Skip to content

Integer Division Rounding Error in Fee Distribution#80

Open
caxtonacollins wants to merge 28 commits intoTrustless-Work:single-release-developfrom
caxtonacollins:rounding-error
Open

Integer Division Rounding Error in Fee Distribution#80
caxtonacollins wants to merge 28 commits intoTrustless-Work:single-release-developfrom
caxtonacollins:rounding-error

Conversation

@caxtonacollins
Copy link

@caxtonacollins caxtonacollins commented Jan 28, 2026

closes #79
Screenshot from 2026-01-29 00-21-19

Summary by CodeRabbit

  • New Features

    • Batch milestone approvals and status updates; observers stored and exposed; withdraw-remaining-funds flow with proportional fee-aware distributions and new FundsWithdrawn event.
  • Improvements

    • Deployment now requires platform-address authorization; stronger initialization, milestone-index, distribution and dispute validations; 50-entry distribution limit; safe multiplication support.
  • Tests

    • Expanded coverage for batch milestones, index edge cases, withdrawal/distribution and dispute scenarios.

✏️ Tip: You can customize this high-level summary in your review settings.

armandocodecr and others added 26 commits December 23, 2025 10:11
@coderabbitai
Copy link

coderabbitai bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

Platform-authorized deployment added; observers persisted; milestone APIs converted to batch operations; dispute/withdraw flows refactored to precompute per-recipient fees and batch net transfers (safe arithmetic); new validators, errors, helper functions, and tests added.

Changes

Cohort / File(s) Summary
Contract Entry Points
contracts/escrow/src/contract.rs
Renamed deploytw_new_single_release_escrow with platform_address auth & validation; added withdraw_remaining_funds; batch milestone APIs (approve_milestones, vector-based change_milestone_status); added get_observers.
Core Dispute & Fee Logic
contracts/escrow/src/core/dispute.rs
Reworked distribution: added execute_distribution, compute per-recipient fee shares before transferring fees, batch net transfers, persist escrow state; added resolve_dispute.
Core Escrow & Storage
contracts/escrow/src/core/escrow.rs, contracts/escrow/src/storage/types.rs
initialize_escrow accepts observers: Vec<Address>, reads token balance for validation, stores observers; added MilestoneUpdate type and DataKey::Observers.
Milestones & Validators
contracts/escrow/src/core/milestone.rs, contracts/escrow/src/core/validators/milestone.rs
Switched to batch milestone updates (Vec<MilestoneUpdate>) and batch approvals (Vec<i128>); added validate_and_convert_milestone_index and updated validation routines.
Validators - Dispute & Escrow
contracts/escrow/src/core/validators/dispute.rs, contracts/escrow/src/core/validators/escrow.rs
New validators: validate_distributions_size (max 50) and validate_withdraw_remaining_funds_conditions; validate_initialize_escrow_conditions now takes escrow_balance; role comparison ignores observers.
Errors & Events
contracts/escrow/src/error.rs, contracts/escrow/src/events/handler.rs
Added TooManyDistributions and several milestone/escrow error variants; InitEsc now includes observers; new event FundsWithdrawn.
Helpers & Math
contracts/escrow/src/lib.rs, contracts/escrow/src/modules/helper/roles.rs, contracts/escrow/src/modules/math/basic.rs
New helper roles_equal_excluding_observers; added safe_mul to BasicArithmetic with overflow check.
Tests
contracts/escrow/src/tests/test.rs
Extensive test additions/updates for batch milestone flows, index edge cases, and rounding/distribution edge-case tests (including dispute rounding scenarios).

Sequence Diagram(s)

sequenceDiagram
    participant Resolver as Dispute Resolver
    participant Contract as Escrow Contract
    participant Validator as Validators
    participant Storage as Escrow Storage
    participant Token as Token Client

    Resolver->>Contract: withdraw_remaining_funds(distributions, trustless_addr, resolver)
    activate Contract

    Contract->>Validator: validate_distributions_size(distributions)
    Validator-->>Contract: Ok / Err

    Contract->>Storage: load escrow
    Storage-->>Contract: escrow

    Contract->>Validator: validate_withdraw_remaining_funds_conditions(escrow, resolver, all_processed, balance, total)
    Validator-->>Contract: Ok / Err

    Note over Contract: compute per-recipient fee shares (safe_mul/div), accumulate actual_trustless/platform fees, build net_transfers Vec
    Contract->>Token: transfer(trustless_work_address, actual_trustless_fees) (if >0)
    Token-->>Contract: Ok
    Contract->>Token: transfer(platform_address, actual_platform_fees) (if >0)
    Token-->>Contract: Ok

    loop for each (recipient, net_amount)
        Contract->>Token: transfer(recipient, net_amount)
        Token-->>Contract: Ok
    end

    Contract->>Storage: persist escrow (flags/balances)
    Storage-->>Contract: Ok

    Contract-->>Resolver: Ok
    deactivate Contract
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #78 — Adds/uses safe_mul and refactors dispute/withdraw distribution logic; this PR implements those safe arithmetic and distribution changes and likely resolves that issue.

Possibly related PRs

  • #60 — Overlaps on dispute/withdraw distribution refactor and token transfer/fee handling changes (strong code-level relation).

Poem

🐰
I hopped through lines, adjusted the sums,
Grouped milestones in tidy little drums,
Fees counted fairly, no coins left astray,
Observers peek in, platform guards the way,
Withdrawals leap home — a happy hop today.

🚥 Pre-merge checks | ✅ 2 | ❌ 3
❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR introduces batch milestone operations and observer functionality alongside the rounding fix, which extend beyond the scoped issue #79 on fee distribution. Clarify whether batch milestone updates and observer features are related to issue #79 or should be separated into distinct PRs for focused review.
Docstring Coverage ⚠️ Warning Docstring coverage is 59.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is minimal, containing only 'closes #79' and a screenshot. While it links to the issue, it lacks substantive explanation of changes per the template. Expand description with sections from the template: brief issue description, changes made, evidence/video, and important notes for better reviewer context.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Integer Division Rounding Error in Fee Distribution' accurately describes the main change—fixing a critical rounding bug in fee distribution logic.
Linked Issues check ✅ Passed The PR substantially addresses issue #79's requirements: fixes rounding errors in fee distribution, implements Strategy 1 (compute fees first), adds test_dispute_resolution_rounding_edge_case, and maintains existing test compatibility.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@caxtonacollins
Copy link
Author

@armandocodecr please review, if there is anything please reachout.
will appreciate some compliments

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
contracts/escrow/src/storage/types.rs (1)

37-45: Storage schema change risk: adding Roles.observers can break deserialization of existing Escrow data.

If this contract is already deployed with stored Escrow entries, adding a field to Roles will make older ledger data incompatible. Please add a migration/versioning strategy (e.g., a versioned EscrowV2 or separate storage for observers) before release.

🧹 Nitpick comments (4)
contracts/escrow/src/core/dispute.rs (2)

64-67: Redundant check after validation.

The amount <= 0 check inside the loop (lines 64-67) is redundant since the earlier validation loop (lines 43-47) already returns an error for non-positive amounts. However, using continue here is harmless and provides defense-in-depth.


58-108: Consider extracting shared fee distribution logic.

The fee calculation and distribution logic in withdraw_remaining_funds (lines 58-108) and resolve_dispute (lines 145-196) is nearly identical. Consider extracting this into a shared helper function to reduce duplication and ensure consistent behavior.

♻️ Example helper signature
fn distribute_with_fees(
    e: &Env,
    token_client: &TokenClient,
    contract_address: &Address,
    trustless_work_address: &Address,
    platform_address: &Address,
    distributions: &Map<Address, i128>,
    total: i128,
    fee_result: &FeeResult,
) -> Result<(), ContractError>

Also applies to: 145-196

contracts/escrow/src/core/milestone.rs (1)

35-38: Consider using ok_or for defensive error handling.

While the .unwrap() on line 38 should be safe after index validation, using .ok_or(ContractError::...) would be more defensive and consistent with the validation pattern.

♻️ Suggested defensive pattern
-            let mut milestone_to_update = existing_escrow
-                .milestones
-                .get(idx)
-                .unwrap();
+            let mut milestone_to_update = existing_escrow
+                .milestones
+                .get(idx)
+                .ok_or(ContractError::MilestoneToUpdateDoesNotExist)?;
contracts/escrow/src/contract.rs (1)

178-191: Missing event publication for withdraw_remaining_funds.

This function does not publish an event after completing the withdrawal, which is inconsistent with other state-changing operations in this contract:

  • resolve_dispute publishes DisputeResolved
  • fund_escrow publishes FundEsc
  • dispute_escrow publishes EscrowDisputed

For auditability and observability of fund movements, consider adding a corresponding event.

♻️ Proposed fix to add event publication

Add a new event type (e.g., FundsWithdrawn) to your events module, then:

     pub fn withdraw_remaining_funds(
         e: Env,
         dispute_resolver: Address,
         trustless_work_address: Address,
         distributions: Map<Address, i128>,
     ) -> Result<(), ContractError> {
-        DisputeManager::withdraw_remaining_funds(
+        let escrow = DisputeManager::withdraw_remaining_funds(
             &e,
             dispute_resolver,
             trustless_work_address,
             distributions,
         )?;
+        // Publish appropriate event here, e.g.:
+        // FundsWithdrawn { escrow }.publish(&e);
         Ok(())
     }

@caxtonacollins
Copy link
Author

@armandocodecr pls review and merge

@caxtonacollins
Copy link
Author

@armandocodecr GM man, please review and merge, will appreciate some compliments

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@contracts/escrow/src/core/dispute.rs`:
- Around line 1-7: The code treats flags.disputed as a terminal processed state
(all_processed = true), allowing withdraw_remaining_funds to run while an active
dispute exists; change the logic in the place that sets all_processed (and/or
the validator validate_withdraw_remaining_funds_conditions) so that
all_processed is true only for terminal states like flags.released or
flags.resolved and explicitly disallow withdraw_remaining_funds when
flags.disputed is set (i.e., require !flags.disputed or require
flags.resolved/released before withdraw_remaining_funds/resolve_dispute). Also
fix the fee rounding leak: instead of computing per-recipient fees using integer
division that loses remainder ((amount * total_fee) / total) for each recipient,
compute per-recipient fees and track allocated_sum, then compute remainder =
total_fee - allocated_sum and add the remainder to one recipient (e.g., the
largest share or the resolver) so the sum of per-recipient fees equals the
previously-calculated total_fee; adjust symbols referenced: all_processed,
flags.released, flags.resolved, flags.disputed, withdraw_remaining_funds,
resolve_dispute, validate_withdraw_remaining_funds_conditions, total_fee,
basis_points, and the per-recipient fee calculation.
🧹 Nitpick comments (1)
contracts/escrow/src/core/dispute.rs (1)

131-167: Avoid double rounding by computing per‑recipient fees directly from basis points.
Current amount * fee_result / total rounds twice and can under‑collect fees more than necessary. Computing per‑recipient fees directly reduces leakage and keeps fee math closer to policy.

♻️ Suggested refactor
-        let fee_result = FeeCalculator::calculate_standard_fees(total, escrow.platform_fee)?;
-
         let mut actual_trustless_fees = 0i128;
         let mut actual_platform_fees = 0i128;
         let mut net_distributions: Vec<(Address, i128)> = Vec::new(e);

         for (addr, amount) in distributions.iter() {
-            let recipient_trustless_fee =
-                BasicMath::safe_mul(amount, fee_result.trustless_work_fee)?
-                    .checked_div(total)
-                    .ok_or(ContractError::DivisionError)?;
-            let recipient_platform_fee = BasicMath::safe_mul(amount, fee_result.platform_fee)?
-                .checked_div(total)
-                .ok_or(ContractError::DivisionError)?;
-
-            let total_recipient_fee =
-                BasicMath::safe_add(recipient_trustless_fee, recipient_platform_fee)?;
-            let net_amount = BasicMath::safe_sub(amount, total_recipient_fee)?;
+            let per_recipient_fees =
+                FeeCalculator::calculate_standard_fees(amount, escrow.platform_fee)?;
+            let recipient_trustless_fee = per_recipient_fees.trustless_work_fee;
+            let recipient_platform_fee = per_recipient_fees.platform_fee;
+            let net_amount = per_recipient_fees.receiver_amount;

Comment on lines 1 to +7
use soroban_sdk::token::Client as TokenClient;
use soroban_sdk::{Address, Env, Map};
use soroban_sdk::{Address, Env, Map, Vec};

use crate::core::escrow::EscrowManager;
use crate::core::validators::dispute::{
validate_distributions_size, validate_withdraw_remaining_funds_conditions,
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the validator for withdraw_remaining_funds
cat -n contracts/escrow/src/core/validators/dispute.rs

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 3632


🏁 Script executed:

# Let's also check the dispute.rs file to see the full context
cat -n contracts/escrow/src/core/dispute.rs

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 7531


🏁 Script executed:

# Search for all_processed and its usage
rg -n 'all_processed' contracts/escrow/src -g '*.rs' -B 2 -A 2

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 1718


🏁 Script executed:

# Search for disputed flag usage
rg -n 'disputed' contracts/escrow/src -g '*.rs' -B 1 -A 1

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 8627


🏁 Script executed:

# Check if there are any rounding correction mechanisms for fees
rg -n 'remainder|round|truncat' contracts/escrow/src/modules/fee.rs -B 2 -A 2

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 161


🏁 Script executed:

# Verify if there are tests that exercise withdraw_remaining_funds with disputed flag
rg -n 'withdraw_remaining_funds' contracts/escrow/src/tests/test.rs -B 5 -A 5

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 1009


🏁 Script executed:

# Find fee-related files
fd -t f 'fee' contracts/escrow/src

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 68


🏁 Script executed:

# Look at the fee calculation implementation
cat -n contracts/escrow/src/modules/fee.rs 2>/dev/null || find contracts/escrow/src -name "*fee*" -type f

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 68


🏁 Script executed:

# Search for FeeCalculator trait and implementation
rg -n 'FeeCalculator|calculate_standard_fees' contracts/escrow/src -B 2 -A 5

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 3832


🏁 Script executed:

# Check the modules directory structure
ls -la contracts/escrow/src/modules/ 2>/dev/null || find contracts/escrow/src -type d -name modules

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 354


🏁 Script executed:

# See the complete fee calculator implementation
cat -n contracts/escrow/src/modules/fee/calculator.rs

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 1823


🏁 Script executed:

# Check if test exercises withdraw_remaining_funds with disputed flag set
rg -n 'test_withdraw_remaining_funds' contracts/escrow/src/tests/test.rs -A 100 | head -150

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 4284


🏁 Script executed:

# Check if there's a test case for withdraw_remaining_funds with disputed=true
rg -n 'withdraw_remaining_funds' contracts/escrow/src/tests/test.rs -B 10 | grep -E 'disputed|withdraw_remaining_funds|fn test'

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 233


🏁 Script executed:

# Check if there's any test with disputed flag set to true during withdraw
rg -n 'disputed.*true' contracts/escrow/src/tests/test.rs -B 5 -A 15 | grep -A 15 'withdraw_remaining_funds'

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 68


🏁 Script executed:

# Look at the rounding issue more carefully - check if there's any remainder handling
cat -n contracts/escrow/src/core/dispute.rs | sed -n '140,167p'

Repository: Trustless-Work/Trustless-Work-Smart-Escrow

Length of output: 1575


all_processed treats disputed as a terminal state, allowing withdrawals before resolution.

Lines 34–38 set all_processed = true when any of flags.released, flags.resolved, or flags.disputed is set. The validator (lines 19–42 of validators/dispute.rs) requires all_processed == true to proceed with withdraw_remaining_funds. This means a dispute_resolver can distribute funds via withdraw_remaining_funds while the escrow is disputed, before calling resolve_dispute. Confirm whether interim distributions during active disputes are intended.

Additionally, the fee calculation has a rounding loss issue. At line 140, total fees are calculated once using basis points. Then at lines 147–153, per-recipient fees are calculated using integer division: (amount * total_fee) / total for each recipient. The sum of these rounded values will be less than the pre-calculated total fees, leaking the remainder to the contract.

🤖 Prompt for AI Agents
In `@contracts/escrow/src/core/dispute.rs` around lines 1 - 7, The code treats
flags.disputed as a terminal processed state (all_processed = true), allowing
withdraw_remaining_funds to run while an active dispute exists; change the logic
in the place that sets all_processed (and/or the validator
validate_withdraw_remaining_funds_conditions) so that all_processed is true only
for terminal states like flags.released or flags.resolved and explicitly
disallow withdraw_remaining_funds when flags.disputed is set (i.e., require
!flags.disputed or require flags.resolved/released before
withdraw_remaining_funds/resolve_dispute). Also fix the fee rounding leak:
instead of computing per-recipient fees using integer division that loses
remainder ((amount * total_fee) / total) for each recipient, compute
per-recipient fees and track allocated_sum, then compute remainder = total_fee -
allocated_sum and add the remainder to one recipient (e.g., the largest share or
the resolver) so the sum of per-recipient fees equals the previously-calculated
total_fee; adjust symbols referenced: all_processed, flags.released,
flags.resolved, flags.disputed, withdraw_remaining_funds, resolve_dispute,
validate_withdraw_remaining_funds_conditions, total_fee, basis_points, and the
per-recipient fee calculation.

@armandocodecr armandocodecr self-requested a review February 5, 2026 16:02
@armandocodecr
Copy link
Contributor

Hi @caxtonacollins! Thank you for your PR. 🚀

The branch you should clone and consequently send your changes to is called “single-release-develop-v2.” This is mentioned in the issue instructions. Please make the changes to continue with the review.

Apologies for the delay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security Issue: Integer Division Rounding Error in Fee Distribution

2 participants