Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Invalid

Integer Division Precision Loss in `distributeProtocolAssetReward`

Description:
In the MarketMakingEngineConfiguration::distributeProtocolAssetReward function, the code for distributeProtocolAssetReward explicitly uses integer division in the core reward calculation:

uint256 feeRecipientReward = amountX18.mul(ud60x18(shares)).div(totalFeeRecipientsSharesX18).intoUint256();

This line calculates each recipient's reward by multiplying the total reward amount (amountX18) by the recipient's share (ud60x18(shares)) and then dividing by the total shares (totalFeeRecipientsSharesX18). The .div() operation in Solidity performs integer division, truncating any fractional part.

/// @notice Sends the accumulated protocol reward to the configured recipients using the given asset.
/// @param self The {MarketMakingEngineConfiguration} storage pointer.
/// @param asset The asset to be distributed as reward.
/// @param amount The accumulated reward amount.
function distributeProtocolAssetReward(Data storage self, address asset, uint256 amount) internal {
// cache unchanging variables before loop
uint256 feeRecipientsLength = self.protocolFeeRecipients.length();
UD60x18 totalFeeRecipientsSharesX18 = ud60x18(self.totalFeeRecipientsShares);
UD60x18 amountX18 = ud60x18(amount);
// variable to verify the total distributed
uint256 totalDistributed = 0;
// iterate over the protocol configured fee recipients
for (uint256 i; i < feeRecipientsLength; i++) {
// load the fee recipient address and shares
(address feeRecipient, uint256 shares) = self.protocolFeeRecipients.at(i);
// Calculate the fee recipient reward
uint256 feeRecipientReward = amountX18.mul(ud60x18(shares)).div(totalFeeRecipientsSharesX18).intoUint256();
// cache the total distributed
totalDistributed += feeRecipientReward;
// verify if is the last fee recipient
if (i == feeRecipientsLength - 1) {
// to prevent small amounts of protocol fees remain stuck in the contract due to rounding
feeRecipientReward += amountX18.sub(ud60x18(totalDistributed)).intoUint256();
}
// Transfer the fee recipient reward
IERC20(asset).safeTransfer(feeRecipient, feeRecipientReward);
}
}

impact:
The code attempts to compensate for rounding loss in the last iteration of the loop:

if (i == feeRecipientsLength - 1) {
// to prevent small amounts of protocol fees remain stuck in the contract due to rounding
feeRecipientReward += amountX18.sub(ud60x18(totalDistributed)).intoUint256();
}

This logic adds the entire remaining amount (amountX18.sub(ud60x18(totalDistributed)) converted to a uint256) to the last recipient's reward.
While this attempts to prevent fees from being "stuck," it introduces a systematic bias in favour of the last fee recipient in the protocolFeeRecipients mapping. The last recipient will always receive any accumulated rounding errors from all previous recipient calculations. This is not a fair or proportional distribution and can lead to significant discrepancies in reward allocation over time, especially if there are many fee recipients and frequent distributions.

Proof of Concept:

Recomended Mitigation:

Distribute Remainder Proportionally. A more equitable and accurate approach is to distribute the "remainder" (lost fractions due to integer division) proportionally across all recipients in subsequent distributions. This requires tracking the accumulated remainder for each recipient. A possible implementation strategy:

  1. Maintain a mapping accumulatedRemainder[address feeRecipient] of UD60x18.

  2. In each distribution, calculate the feeRecipientReward using integer division (for gas efficiency in the main calculation)

  3. Calculate the fractional remainder for each recipient: remainder = amountX18.mul(ud60x18(shares)).div(totalFeeRecipientsSharesX18).sub(ud60x18(feeRecipientReward))

  4. Add this remainder to accumulatedRemainder[feeRecipient]

  5. In each distribution, also distribute any accumulated remainder from previous distributions: feeRecipientReward += accumulatedRemainder[feeRecipient].intoUint256(); (and then reset accumulatedRemainder[feeRecipient] = UD60x18_ZERO; or implement a more sophisticated remainder carry-forward mechanism if needed). The key is to distribute accumulated fractional remainders across all recipients over time, not just give all rounding errors to the last recipient in each distribution cycle.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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