Canto Application Specific Dollars and Bonding Curves for 1155s - Krace's results

Tokenizable bonding curves using a Stablecoin-as-a-Service token

General Information

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

Canto

Findings Distribution

Researcher Performance

Rank: 22/120

Findings: 1

Award: $269.25

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Awards

269.2459 USDC - $269.25

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
sufficient quality report
M-02

External Links

Lines of code

https://github.com/code-423n4/2023-11-canto/blob/b78bfdbf329ba9055ba24bd710c7e1c60251039a/1155tech-contracts/src/Market.sol#L150

Vulnerability details

Impact

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.

Proof of Concept

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:

  • Step 1: It retrieves the rewards for the buyer before the purchase. These rewards are not linked to the purchase because they use the old token amounts, and the fees from this purchase are not factored in.
  • Step 2: It splits the fees for this purchase. _splitFees increases the value of shareHolderRewardsPerTokenScaled based on the fees from this purchase.
  • Step 3: It updates the 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;
    }

Test

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

Tools Used

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;

Assessed type

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.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter