Platform: Code4rena
Start Date: 13/11/2023
Pot Size: $24,500 USDC
Total HM: 3
Participants: 120
Period: 4 days
Judge: 0xTheC0der
Id: 306
League: ETH
Rank: 22/120
Findings: 1
Award: $269.25
π Selected for report: 1
π Solo Findings: 0
π Selected for report: Krace
Also found by: 0xAadi, 0xpiken, AS, D1r3Wolf, PENGUN, SpicyMeatball, Yanchuan, bin2chen, d3e4, ether_sky, glcanvas, immeas, lanrebayode77, leegh, mojito_auditor, rvierdiiev, tnquanghuy0512
269.2459 USDC - $269.25
When users purchase new tokens, the old tokens they already own do not receive the fees from this purchase, resulting in a loss of funds for the users.
According to the explanation by @Roman, when users buy new tokens, the recently purchased token is not eligible for a fee. However, the "old shares" that users already own should be eligible.
Lambda: Hey, just a design decision, the idea behind it is that the user does not own the new shares yet when he buys, so he should not get any fees for it. Whereas he still owns it when he sells (it is basically the last sale)
Krace: So the new shares should not get fee. If the user owns some "old shares", is it eligible for a fee when the user buying?
Lambda: Yes, in that case, these shares are in the "owner pool" at the time of purchase and should be eligible
However, the buy
function will completely disregard the rewards associated with the buyer's "old shares" for these fees.
Let's analyze the buy
function:
_splitFees
increases the value of shareHolderRewardsPerTokenScaled
based on the fees from this purchase.rewardsLastClaimedValue
of the buyer to the latest shareHolderRewardsPerTokenScaled
, which was increased in _splitFees
just now.The question arises: the fees from this purchase are not rewarded to the buyer's old tokens between Step 2 and Step 3, yet the buyer's rewardsLastClaimedValue
is updated to the newest value. This results in the permanent loss of these rewards for the buyer.
function buy(uint256 _id, uint256 _amount) external { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID SafeERC20.safeTransferFrom(token, msg.sender, address(this), price + fee); // The reward calculation has to use the old rewards value (pre fee-split) to not include the fees of this buy // The rewardsLastClaimedValue then needs to be updated with the new value such that the user cannot claim fees of this buy //@audit it will get the rewards first uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); // Split the fee among holder, creator and platform //@audit then split the fees of this buying _splitFees(_id, fee, shareData[_id].tokensInCirculation); //@audit and update sender's rewardsLastClaimedValue to the newest one, which is updated by `splitFees` rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled; shareData[_id].tokenCount += _amount; shareData[_id].tokensInCirculation += _amount; tokensByAddress[_id][msg.sender] += _amount; if (rewardsSinceLastClaim > 0) { SafeERC20.safeTransfer(token, msg.sender, rewardsSinceLastClaim); } emit SharesBought(_id, msg.sender, _amount, price, fee); }
function _splitFees( uint256 _id, uint256 _fee, uint256 _tokenCount ) internal { uint256 shareHolderFee = (_fee * HOLDER_CUT_BPS) / 10_000; uint256 shareCreatorFee = (_fee * CREATOR_CUT_BPS) / 10_000; uint256 platformFee = _fee - shareHolderFee - shareCreatorFee; shareData[_id].shareCreatorPool += shareCreatorFee; if (_tokenCount > 0) { //@audit shareHolderRewardsPerTokenScaled will be increased if tokenCount>0 shareData[_id].shareHolderRewardsPerTokenScaled += (shareHolderFee * 1e18) / _tokenCount; } else { // If there are no tokens in circulation, the fee goes to the platform platformFee += shareHolderFee; } platformPool += platformFee; }
The following poc shows the case mentioned before, add it to 1155tech-contracts/src/Market.t.sol
and run it with forge test --match-test testBuyNoReward -vv
function testBuyNoReward() public { // Bob creat share with id 1 market.changeBondingCurveAllowed(address(bondingCurve), true); market.restrictShareCreation(false); vm.prank(bob); market.createNewShare("Test Share", address(bondingCurve), "metadataURI"); assertEq(market.shareIDs("Test Share"), 1); token.transfer(alice, 1e17); // alice buy 1 token in share1 ==> token1 vm.startPrank(alice); uint256 aliceBalanceBefore = token.balanceOf(alice); token.approve(address(market), 1e18); console.log(" alice balance ", aliceBalanceBefore); market.buy(1, 1); uint256 aliceBalanceAfter = token.balanceOf(alice); console.log(" alice balance after buy ", aliceBalanceAfter); console.log(" alice cost after buy ", aliceBalanceBefore - aliceBalanceAfter); // alice buy another 1 token in share1 ==> token2 market.buy(1,1); uint256 aliceBalanceAfterSecondBuy = token.balanceOf(alice); console.log(" alice balance after second buy ", aliceBalanceAfterSecondBuy); // alice get no reward for her token1 console.log(" alice cost after second buy ", aliceBalanceAfter - aliceBalanceAfterSecondBuy); vm.stopPrank(); }
The result is shown below, alice gets no rewards for token1
[PASS] testBuyNoReward() (gas: 443199) Logs: alice balance 100000000000000000 alice balance after buy 98900000000000000 alice cost after buy 1100000000000000 alice balance after second buy 96700000000000000 alice cost after second buy 2200000000000000
After my suggested patch, alice can get the rewards for token1
: 2200000000000000
- 2134000000000000
= 66000000000000
[PASS] testBuyNoReward() (gas: 447144) Logs: alice balance 100000000000000000 alice balance after buy 98900000000000000 alice cost after buy 1100000000000000 alice balance after second buy 96766000000000000 alice cost after second buy 2134000000000000
Foundry
Split the fees before getting the rewards in buy
:
diff --git a/1155tech-contracts/src/Market.sol b/1155tech-contracts/src/Market.sol index 59c5c96..85d91a5 100644 --- a/1155tech-contracts/src/Market.sol +++ b/1155tech-contracts/src/Market.sol @@ -151,11 +151,11 @@ contract Market is ERC1155, Ownable2Step { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID SafeERC20.safeTransferFrom(token, msg.sender, address(this), price + fee); + // Split the fee among holder, creator and platform + _splitFees(_id, fee, shareData[_id].tokensInCirculation); // The reward calculation has to use the old rewards value (pre fee-split) to not include the fees of this buy // The rewardsLastClaimedValue then needs to be updated with the new value such that the user cannot claim fees of this buy uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); - // Split the fee among holder, creator and platform - _splitFees(_id, fee, shareData[_id].tokensInCirculation); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled; shareData[_id].tokenCount += _amount;
Context
#0 - c4-pre-sort
2023-11-18T03:59:57Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-11-18T04:13:57Z
minhquanym marked the issue as sufficient quality report
#2 - c4-sponsor
2023-11-27T19:34:12Z
OpenCoreCH marked the issue as disagree with severity
#3 - c4-sponsor
2023-11-27T19:34:24Z
OpenCoreCH (sponsor) confirmed
#4 - OpenCoreCH
2023-11-27T19:37:02Z
These fees are not lost, the impact is just that for this particular buys, the other users get more rewards and the user that buys less (but the same also applies for other users, so it might even cancel out for long-term holders). So I think high is definitely over-inflated for that.
However, I agree that the current logic is a bit weird, but changing it such that they just receive rewards for the other shares would also be a bit weird (would incentivize to split up a buy into multiple ones). We discussed this internally and will change the logic such that users always receive fees for their buys, no matter if it is the first or subsequent ones.
#5 - c4-judge
2023-11-28T23:42:12Z
MarioPoneder changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-11-28T23:48:13Z
MarioPoneder marked the issue as satisfactory
#7 - c4-judge
2023-11-28T23:48:17Z
MarioPoneder marked the issue as selected for report
#8 - OpenCoreCH
2023-11-30T12:46:14Z
#9 - romeroadrian
2023-11-30T12:58:03Z
@MarioPoneder judging by this comment, it looks like this is an accepted and intentionally design choice: https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L154
#10 - MarioPoneder
2023-11-30T15:29:22Z
The sponsor admitted that this is a design flaw rather than a pure design choice:
However, I agree that the current logic is a bit weird, but changing it such that they just receive rewards for the other shares would also be a bit weird (would incentivize to split up a buy into multiple ones). We discussed this internally and will change the logic such that users always receive fees for their buys, no matter if it is the first or subsequent ones.
Therefore a fix was released which in turn justifies the initial severity assessment:
Fixed in https://github.com/mkt-market/1555tech-contracts/pull/2
#11 - romeroadrian
2023-11-30T15:42:19Z
The sponsor admitted that this is a design flaw rather than a pure design choice:
However, I agree that the current logic is a bit weird, but changing it such that they just receive rewards for the other shares would also be a bit weird (would incentivize to split up a buy into multiple ones). We discussed this internally and will change the logic such that users always receive fees for their buys, no matter if it is the first or subsequent ones.
Therefore a fix was released which in turn justifies the initial severity assessment:
Fixed in https://github.com/mkt-market/1555tech-contracts/pull/2
I don't know, I feel this a reconsideration of the design choice. The logic is very clear in the implementation, plus the supporting comment leaves no room for not thinking this was on purpose.
I'm thinking this also sets a bad precedent if we would need to submit every design choice as issue in c4.
#12 - MarioPoneder
2023-11-30T21:16:02Z
I partially agree, however it's not that simple because the underlying core issue also leads to another impact (see #302) or point of view of impact which is not intentional and not purely a design choice.
According our C4 rules, submissions with the same underlying core issue need to be duplicated and are therefore valid, even if one group of issues shows different impacts.
See supreme court standardization:
The findings are duplicates if they share the same root cause. More specifically, if fixing the Root Cause (in a reasonable manner) would cause the finding to no longer be exploitable, then the findings are duplicates.
#13 - romeroadrian
2023-11-30T22:56:53Z
I partially agree, however it's not that simple because the underlying core issue also leads to another impact (see #302) or point of view of impact which is not intentional and not purely a design choice. According our C4 rules, submissions with the same underlying core issue need to be duplicated and are therefore valid, even if one group of issues shows different impacts.
See supreme court standardization:
The findings are duplicates if they share the same root cause. More specifically, if fixing the Root Cause (in a reasonable manner) would cause the finding to no longer be exploitable, then the findings are duplicates.
All right, thank you for reviewing this.