Description
In the RAAC protocol, ZENO
tokens are purchased at a discount during auctions using a corresponding ERC20 token, in this case, USDC
. Upon maturity, users can redeem their ZENO
for USDC
at a 1:1 ratio, as shown in the redeem()
function of the ZENO.sol
contract:
function redeem(uint amount) external nonReentrant {
if (!isRedeemable()) {
revert BondNotRedeemable();
}
if (amount == 0) {
revert ZeroAmount();
}
uint256 totalAmount = balanceOf(msg.sender);
if (amount > totalAmount) {
revert InsufficientBalance();
}
totalZENORedeemed += amount;
_burn(msg.sender, amount);
USDC.safeTransfer(msg.sender, amount);
}
This implies (as confirmed by the RAAC team) that the ZENO
token must have the same number of decimals as the ERC20 token used in the auctions. However, ZENO
is always created with 18 decimals, resulting in inaccurate accounting both when purchasing and redeeming ZENO
tokens.
Context
Impact
High. This mismatch in token decimals completely disrupts the protocol's accounting during both the purchase and redemption of ZENO
tokens.
Likelihood
High. This issue occurs in every ZENO
token purchase and redemption.
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_wrongZenoDecimals() public {
assertNotEq(zeno.decimals(), usdc.decimals());
console.log("ZENO DECIMALS :", zeno.decimals());
console.log("USDC DECIMALS :", usdc.decimals());
}
}
Logs
Ran 1 test for test/foundry/ZENOTest.t.sol:ZENOTest
[PASS] test_wrongZenoDecimals() (gas: 21174)
Logs:
ZENO DECIMALS : 18
USDC DECIMALS : 6
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.17ms (77.98µs 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_wrongZenoDecimals -vvv
Recommendation
Consider setting the number of decimals in the ZENO
constructor and overriding the decimals()
function:
contract ZENO is IZENO, ERC20, Ownable, ReentrancyGuard {
...
+ uint8 private immutable DECIMALS;
constructor(
address _usdc,
uint256 _maturityDate,
string memory _name,
string memory _symbol,
+ uint8 decimals_,
address _initialOwner
) Ownable(_initialOwner) ERC20(_name, _symbol) {
USDC = IERC20(_usdc);
MATURITY_DATE = _maturityDate;
+ DECIMALS = decimals_;
}
...
+ function decimals() public view override returns (uint8) {
+ return DECIMALS;
+ }
}
Therefore, update the createZENOContract
function in ZENOFactory.sol
to pass the correct decimals when creating the ZENO
token:
function createZENOContract(
address _usdcAddress,
uint256 _maturityDate
) external onlyOwner {
string memory id = Strings.toString(zenos.length + 1);
string memory name = string(abi.encodePacked("ZENO Bond ", id));
string memory symbol = string(abi.encodePacked("ZENO", id));
ZENO newZENO = new ZENO(
_usdcAddress,
_maturityDate,
name,
symbol,
+ ERC20(_usdcAddress).decimals(),
address(this)
);
zenos.push(newZENO);
isZENO[address(newZENO)] = true;
emit ZENOCreated(address(newZENO), _maturityDate);
}