Project

One World
NFTDeFi
15,000 USDC
View results
Submission Details
Severity: medium
Invalid

Precision Loss in Weighted Profit Distribution Leads to Rounding Errors, Resulting in Inaccurate Payouts Across Token Tiers

Summary

The MembershipERC1155 contract experiences precision loss during profit calculations due to the interaction between weighted token shares and profit distribution scaling. This affects profit distribution accuracy, particularly for holders of higher-tier tokens (those with lower weights) where the multiplication factors in shareOf() can lead to larger accumulated rounding errors when combined with the ACCURACY scaling factor in profit calculations.

The precision loss originates from two interacting mechanisms:

  1. The weighted share calculation in shareOf() which uses different powers of 2 (64, 32, 16, etc.)

  2. The subsequent use of these weighted shares in profit calculations with ACCURACY (1e30) scaling

https://github.com/Cyfrin/2024-11-one-world/blob/main/contracts/dao/tokens/MembershipERC1155.sol#L169

function shareOf(address account) public view returns (uint256) {
return (balanceOf(account, 0) * 64) +
(balanceOf(account, 1) * 32) +
(balanceOf(account, 2) * 16) +
(balanceOf(account, 3) * 8) +
(balanceOf(account, 4) * 4) +
(balanceOf(account, 5) * 2) +
balanceOf(account, 6);
}
function getUnsaved(address account) internal view returns (uint256 profit) {
return ((totalProfit - lastProfit[account]) * shareOf(account)) / ACCURACY;
}

When this weighted share is used in getUnsaved(), the multiplication and subsequent division by ACCURACY can cause rounding errors to accumulate, especially for higher token tiers where the weights are smaller.

Recommended Fix

contract MembershipERC1155 {
// Add scaling factor for share weights
uint256 private constant WEIGHT_SCALE = 1e6;
function shareOf(address account) public view returns (uint256) {
return (balanceOf(account, 0) * 64 * WEIGHT_SCALE) +
(balanceOf(account, 1) * 32 * WEIGHT_SCALE) +
(balanceOf(account, 2) * 16 * WEIGHT_SCALE) +
(balanceOf(account, 3) * 8 * WEIGHT_SCALE) +
(balanceOf(account, 4) * 4 * WEIGHT_SCALE) +
(balanceOf(account, 5) * 2 * WEIGHT_SCALE) +
(balanceOf(account, 6) * WEIGHT_SCALE);
}
function getUnsaved(address account) internal view returns (uint256 profit) {
uint256 share = shareOf(account);
uint256 profitDifference = totalProfit - lastProfit[account];
// First divide by WEIGHT_SCALE to handle the share scaling
uint256 scaledShare = share / WEIGHT_SCALE;
// Then perform profit calculation
return (profitDifference * scaledShare) / ACCURACY;
}
}

This solution better preserves precision across different token tiers while maintaining the intended weight ratios in profit distribution.

Scenario

// Vulnerable code
function shareOf(address account) public view returns (uint256) {
return (balanceOf(account, 0) * 64) +
(balanceOf(account, 1) * 32) +
(balanceOf(account, 2) * 16) +
(balanceOf(account, 3) * 8) +
(balanceOf(account, 4) * 4) +
(balanceOf(account, 5) * 2) +
balanceOf(account, 6);
}
function getUnsaved(address account) internal view returns (uint256 profit) {
return ((totalProfit - lastProfit[account]) * shareOf(account)) / ACCURACY;
}

Let's consider these holders:

  • Alice holds 1 token of tier 6 (weight: 1)

  • Bob holds 1 token of tier 0 (weight: 64)

  • Charlie holds 2 tokens of tier 3 (weight: 8 * 2 = 16)

Total weighted supply = 81 (64 + 1 + 16)

When 100 tokens are distributed:

// Initial profit addition
totalProfit += (100 * 1e30) / 81
// Profit calculations
Alice (tier 6):
(100 * 1e30 * 1) / (81 * 1e30) = 1.234567... tokens
Actual result: 1 tokens (lost 0.234567... due to division)
Bob (tier 0):
(100 * 1e30 * 64) / (81 * 1e30) = 79.012345... tokens
Actual result: 79 tokens (lost 0.012345...)
Charlie (tier 3):
(100 * 1e30 * 16) / (81 * 1e30) = 19.753086... tokens
Actual result: 19 tokens (lost 0.753086...)

Total distributed: 99 tokens (1 token lost to rounding)

Fixed Version Scenario

uint256 private constant WEIGHT_SCALE = 1e6;
function shareOf(address account) public view returns (uint256) {
return (balanceOf(account, 0) * 64 * WEIGHT_SCALE) +
(balanceOf(account, 1) * 32 * WEIGHT_SCALE) +
(balanceOf(account, 2) * 16 * WEIGHT_SCALE) +
(balanceOf(account, 3) * 8 * WEIGHT_SCALE) +
(balanceOf(account, 4) * 4 * WEIGHT_SCALE) +
(balanceOf(account, 5) * 2 * WEIGHT_SCALE) +
(balanceOf(account, 6) * WEIGHT_SCALE);
}
function getUnsaved(address account) internal view returns (uint256 profit) {
uint256 share = shareOf(account);
uint256 profitDifference = totalProfit - lastProfit[account];
uint256 scaledShare = share / WEIGHT_SCALE;
return (profitDifference * scaledShare) / ACCURACY;
}

Same holders:

  • Alice: 1 * WEIGHT_SCALE (tier 6)

  • Bob: 64 * WEIGHT_SCALE (tier 0)

  • Charlie: 16 * WEIGHT_SCALE (tier 3)

When 100 tokens are distributed:

Alice (tier 6):
share = 1 * 1e6 = 1,000,000
scaledShare = 1
Result: 1.234567 tokens (preserved more decimals)
Bob (tier 0):
share = 64 * 1e6 = 64,000,000
scaledShare = 64
Result: 79.012345 tokens (preserved more decimals)
Charlie (tier 3):
share = 16 * 1e6 = 16,000,000
scaledShare = 16
Result: 19.753086 tokens (preserved more decimals)

Total distributed: 100 tokens (minimized rounding loss)

The fixed version:

  1. Preserves more precision during intermediate calculations by scaling the weights up first

  2. Reduces cumulative rounding errors in the final distribution

  3. Ensures more accurate proportional distribution especially for smaller weight tokens

  4. Maintains better precision when multiple distributions occur over time

The key difference is that the fixed version handles the scaling of weights separately from the profit calculations, allowing for better precision preservation throughout the calculation steps. This becomes especially important when dealing with multiple token tiers and frequent profit distributions.

Updates

Lead Judging Commences

0xbrivan2 Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.