Sparkn

CodeFox Inc.
DeFiFoundryProxy
15,000 USDC
View results
Submission Details
Severity: medium

`Distributor` contract upgrade can result in unrecoverable contest funds due to hardcoded `BASIS_POINTS` value

Summary

Due to a hardcoded value that is used to calculate the expected totalPercentage of the funds that should be distributed to selected winners (aka "supporters"), organisers (aka "innovators") and the owner of the protocol are unable to distribute or recover funds, in case the BASIS_POINTS and COMMISSION_FEE of the Distributor contract are adjusted as part of an upgrade.

Vulnerability Details

Context

Organizers distribute contest funds, that were previously sent by "sponsors" of a contest to a pre-calculated proxy contract address, to selected winners via ProxyFactory.deployProxyAndDistribute(bytes32 contestId, address implementation, bytes calldata data).

The selection of the winners and the distribution of price funds is calculated off-chain and sent as data to the aforementioned function, which is then ultimately passed to Distributor._distribute(address token, address[] winners, uint256[] percentages).

For distribution calculations, the protocol uses BASIS_POINTS and COMMISSION_FEE defined in Distributor contract, which are 10000 and 500 respectively at the time of the audit. Meaning 10000 = 100% total funds and 500 = 5% commission fees that go to the stadium address.

The protocol ensures that the provided percentages add up to 100% (BASIS_POINTS), minus the COMMISSION_FEE. If the percentages don't add up as expected, the input data is considered incorrect and the transaction reverts.

Here's the relevant snippet where the check is performed.

uint256 totalPercentage;
for (uint256 i; i < percentagesLength;) {
totalPercentage += percentages[i];
unchecked {
++i;
}
}
// check if totalPercentage is correct
if (totalPercentage != (10000 - COMMISSION_FEE)) {
revert Distributor__MismatchedPercentages();
}

If the check passes, the contest's funds are sent to the selected winners proportionally using BASIS_POINTS to calculate the distributions.

for (uint256 i; i < winnersLength;) {
uint256 amount = totalAmount * percentages[i] / BASIS_POINTS;
erc20.safeTransfer(winners[i], amount);
unchecked {
++i;
}
}

As an example, assuming we have two winners with each getting 47,5% of the funds (5% COMMISSION_FEE go to the protocol) and the contest contract has a total of 100000 units of a given token, then each winner would get 47500 units:

amount = totalAmount * percentages[i] / BASIS_POINTS
47500 = 100000 * 4750 / 10000

The vulnerability

In the aforementioned check where the protocol ensures that the provided percentages properly add up, considering the commission fee, the total BASIS_POINTS value is hardcoded instead of using BASIS_POINTS directly.

This can result in scenarios where the Distributor contract's BASIS_POINTS and COMMISSION_FEE values are adjusted/upgraded, leaving the check untouched, and making it impossible for both, organisers and owners, to either distribute or recover the funds.

Here's how:

Assume the protocol decides to change the granularity of BASIS_POINTS from 10000 to 100000 and COMMISSION_FEE from 500 to 5000 respectively, but forgets to update the check with the new BASIS_POINTS value. In such a scenario, all calls to Distributor._distribute() will revert, even if the provided data has adjusted percentages, because the check still uses 10000 as a base for 100%.

Proof Of Concept

Make the following changes to Distributor.sol:

- uint256 private constant COMMISSION_FEE = 500;
+ uint256 private constant COMMISSION_FEE = 5000;
- uint256 private constant BASIS_POINTS = 10000;
+ uint256 private constant BASIS_POINTS = 100000;

Add a new file AuditTest.t.sol in test/integration/ with the following test code:

contract ProxyTest is StdCheats, HelperContract {
...
function testUnableToDistributeOrRecoverFunds()
public
setUpContestForNameAndSentAmountToken("James", jpycv2Address, 10000 ether)
{
bytes32 randomId_ = keccak256(abi.encode("James", "001"));
bytes memory data = createDataToDistributeJpycv2();
vm.startPrank(organizer);
vm.expectRevert(ProxyFactory.ProxyFactory__DelegateCallFailed.selector);
// this should fail now
proxyFactory.deployProxyAndDistribute(randomId_, address(distributor), data);
vm.stopPrank();
// ensure contest has expired, this is needed so `factoryAdmin`
// can recover funds in the first place
vm.warp(16 days);
vm.startPrank(factoryAdmin);
vm.expectRevert(ProxyFactory.ProxyFactory__DelegateCallFailed.selector);
// even owners can recover funds now
proxyFactory.deployProxyAndDistributeByOwner(organizer, randomId_, address(distributor), data);
vm.stopPrank();
}
}

Then, ensure that createDataToDistributeJpycv2() has adjusted percentages data as well:

function createDataToDistributeJpycv2() public view returns (bytes memory data) {
address[] memory winners = new address[](1);
winners[0] = user1;
uint256[] memory percentages_ = new uint256[](1);
- percentages_[0] = 9500;
+. percentages_[0] = 95000;
data = abi.encodeWithSelector(Distributor.distribute.selector, jpycv2Address, winners, percentages_, "");
}

Execute the test with:

$ forge test --match-path test/integration/AuditTest.t.sol

The test will pass as both calls deployProxyAndDistribute() and deployProxyAndDistributeByOwner() are going to fail due to the bug in the aforementioned check.

Impact

I've marked this finding as medium risk because, even though this could make pretty much all contest funds unrecoverable, the contracts have great test coverage and such an adjustment will most likely be caught by tests.

Tools Used

Manual review for finding the bug.
Foundry for building the proof of concept.

Recommendations

Instead of hardcoding the current BASIS_POINTS value in the mentioned check, use the BASIS_POINTS variable instead:

/// Distributor.sol
- if (totalPercentage != (10000 - COMMISSION_FEE)) {
+ if (totalPercentage != (BASIS_POINTS - COMMISSION_FEE)) {
revert Distributor__MismatchedPercentages();
}

Making this adjustment, upgrades/adjustments done to BASIS_POINTS and COMMISSION_FEE no longer make contest funds unrecoverable as the check that previously reverted will no rely on the value stored in BASIS_POINTS.

Support

FAQs

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