Description
In the buy()
function of the Auction.sol
contract, the total cost is calculated by multiplying the current price by the amount to be purchased, as shown below:
function buy(uint256 amount) external whenActive {
...
uint256 cost = price * amount;
...
}
Both price
and amount
use the same number of decimals - 6 in this case, as USDC
is used. However, the cost
value does not properly scale back to 6 decimals, causing it to be significantly higher than expected. As a result, users end up paying substantially more than intended.
Context
Impact
High. Users pay substantially more for ZENO
tokens than intended.
Likelihood
High. This issue occurs in every ZENO
token purchase.
Proof of Concept
pragma solidity ^0.8.19;
import {Test, console} from "../../lib/forge-std/src/Test.sol";
import {AuctionFactory} from "../../contracts/zeno/AuctionFactory.sol";
import {Auction} from "../../contracts/zeno/Auction.sol";
import {ZENOFactory} from "../../contracts/zeno/ZENOFactory.sol";
import {ZENO} from "../../contracts/zeno/ZENO.sol";
import {ERC20Mock} from "./mocks/ERC20Mock.sol";
contract ZENOTest is Test {
AuctionFactory auctionFactory;
ZENOFactory zenoFactory;
Auction auction;
ERC20Mock usdc;
ZENO zeno;
address BUSINESS = makeAddr("business");
address OWNER = makeAddr("owner");
address USER = makeAddr("user");
function setUp() public {
vm.startPrank(OWNER);
usdc = new ERC20Mock("USD Coin", "USDC", 6);
auctionFactory = new AuctionFactory(OWNER);
zenoFactory = new ZENOFactory(OWNER);
zenoFactory.createZENOContract(address(usdc), block.timestamp + 365 days);
zeno = zenoFactory.getZENO(0);
auctionFactory.createAuction(
address(zeno), address(usdc), BUSINESS, block.timestamp, block.timestamp + 1 days, 1e6, 0.5e6, 10e6
);
auction = auctionFactory.getAuction(0);
zenoFactory.transferZenoOwnership(0, address(auction));
vm.stopPrank();
}
function test_wrongZenoCost() public {
uint256 amount = 1e6;
uint256 cost = amount * auction.getPrice();
deal(address(usdc), USER, cost);
uint256 userInitialUsdcBalance = usdc.balanceOf(USER);
skip(1);
vm.startPrank(USER);
usdc.approve(address(auction), cost);
auction.buy(amount);
vm.stopPrank();
uint256 userFinalUsdcBalance = usdc.balanceOf(USER);
assertGt(userInitialUsdcBalance - userFinalUsdcBalance, auction.getDetails().startingPrice);
console.log("COST OF BUYING 1 ZENO :", userInitialUsdcBalance - userFinalUsdcBalance);
console.log("ZENO STARTING PRICE :", auction.getDetails().startingPrice);
}
}
Logs:
Ran 1 test for test/foundry/ZENOTest.t.sol:ZENOTest
[PASS] test_wrongZenoCost() (gas: 383362)
Logs:
COST OF BUYING 1 ZENO : 999995000000
ZENO STARTING PRICE : 1000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.95ms (1.40ms CPU time)
Instructions
First, integrate Foundry by running the following commands in your terminal, in the project's root directory:
mkdir out lib
git submodule add https://github.com/foundry-rs/forge-std lib/forge-std
touch foundry.toml
Next, configure Foundry by adding the following settings to foundry.toml
:
[profile.default]
src = "contracts"
out = "out"
lib = "lib"
After that, create a foundry/
directory inside the test/
directory. Inside foundry/
, create the following files:
ZENOTest.t.sol
mocks/ERC20Mock.sol
Then, add the following code to mocks/ERC20Mock.sol
:
pragma solidity ^0.8.19;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ERC20Mock is ERC20 {
uint8 private _decimals;
constructor(string memory _tokenName, string memory _tokenSymbol, uint8 _tokenDecimals)
ERC20(_tokenName, _tokenSymbol)
{
_decimals = _tokenDecimals;
}
function mint(address account, uint256 amount) external {
_mint(account, amount);
}
function burn(address account, uint256 amount) external {
_burn(account, amount);
}
function decimals() public view override returns (uint8) {
return _decimals;
}
}
Finally, paste the provided (PoC) into ZENOTest.t.sol
and run:
forge test --mt test_wrongZenoCost -vvv
Recommendation
To correctly scale the cost
value, adjust it according to the token decimals. Additionally, introduce a require statement after computing cost
to prevent users from paying nothing when purchasing very small amounts of ZENO
tokens.
...
+import "@openzeppelin/contracts/utils/math/Math.sol";
...
contract Auction is IAuction, Ownable {
+ using Math for uint256;
...
function buy(uint256 amount) external whenActive {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
uint256 price = getPrice();
- uint256 cost = price * amount;
+ uint256 cost = price.mulDiv(amount, 10 ** zeno.decimals(), Math.Rounding.Ceil);
+ require(cost != 0, "Invalid Amount");
require(usdc.transferFrom(msg.sender, businessAddress, cost), "Transfer failed");
bidAmounts[msg.sender] += amount;
state.totalRemaining -= amount;
state.lastBidTime = block.timestamp;
state.lastBidder = msg.sender;
zeno.mint(msg.sender, amount);
emit ZENOPurchased(msg.sender, amount, price);
}
...
}