Summary
The RAAC-ZENO bond auction feature is contained within two contracts: ZENO
and Auction
.
The Auction
contract handles the logic behind purchasing bonds; the ZENO
contract handles the logic for redemption.
The ZENO
contract does not calculate the redemption amounts properly which leads to users always receiving dust.
From the docs:
• Bond prices and redemption rates are dynamically adjusted based on market conditions
Vulnerability Details
function buy(uint256 amount) external whenActive {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
uint256 price = getPrice();
> uint256 cost = price * amount;
require(usdc.transferFrom(msg.sender, businessAddress, cost), "Transfer failed");
> zeno.mint(msg.sender, amount);
emit ZENOPurchased(msg.sender, amount, price);
}
On the buy side, price
is used to determine how much funds need to be pulled from the user.
The cost for a ZENO bond is calculated as price * amount
, where amount
is the number of ZENO bonds a user is purchasing.
function redeem(uint amount) external nonReentrant {
totalZENORedeemed += amount;
> _burn(msg.sender, amount);
> USDC.safeTransfer(msg.sender, amount);
}
function redeemAll() external nonReentrant {
uint256 amount = balanceOf(msg.sender);
totalZENORedeemed += amount;
> _burn(msg.sender, amount);
> USDC.safeTransfer(msg.sender, amount);
}
The redemption side is missing the calculation that multiplies the number of ZENO bonds to redeem by a redemption rate to obtain the number of reward tokens to dispense.
Instead, the raw input value, amount
, is transferred to the user.
There should be an additional calculation in both redeem()
and redeemAll()
that scales the payout value to the correct decimals of the output token and multiplies by a redemption rate value.
Add the below test into test/Zeno/Integration.test.js
Run with the following command: npm run test:unit:all
it("Lack of redemption rate calculation results in dust payout amounts on redeem", async function () {
let amountToBuy = 10;
await ethers.provider.send("evm_increaseTime", [3600*2]);
const auctionStateForPrice = await auction1.state();
const price = await auction1.getPrice();
const cost = parseFloat(price) * amountToBuy;
log(`Cost: ${cost}`);
const allowance = await usdc.allowance(addr1.address, auction1Address);
if (allowance < cost) {
const approveTx = await usdc
.connect(addr1)
.approve(auction1Address, cost);
await approveTx.wait();
}
const usdcBalance = await usdc.balanceOf(addr1.address);
const zenoBalance1 = await zeno1.balanceOf(addr1.address);
log(`USDC Balance of addr1 before buy(): ${usdcBalance}`);
log(`ZENO Balance of addr1 before buy(): ${zenoBalance1}`);
await auction1.connect(addr1).buy(amountToBuy);
const usdcBalance1 = await usdc.balanceOf(addr1.address);
const zenoBalance2 = await zeno1.balanceOf(addr1.address);
log(`USDC Balance of addr1 after buy(): ${usdcBalance1}`);
log(`ZENO Balance of addr1 after buy(): ${zenoBalance2}`);
await ethers.provider.send("evm_increaseTime", [86400 * 365 + 1]);
await ethers.provider.send("evm_mine");
const amountToRedeem = 6;
const zenoBalanceInput = await usdc.balanceOf(businessAddress);
await usdc
.connect(businessAccount)
.transfer(zeno1Address, zenoBalanceInput);
const zenoBalance = await usdc.balanceOf(zeno1Address);
expect(zenoBalance).to.equal(zenoBalanceInput);
const beforeRedeemBalance = await usdc.balanceOf(addr1.address);
await zeno1.connect(addr1).redeem(amountToRedeem);
const finalBalance = await usdc.balanceOf(addr1.address);
const zenoBalance3 = await zeno1.balanceOf(addr1.address);
log(`ZENO Balance of addr1 after redeem(): ${zenoBalance3}`);
log(`USDC Balance of addr1 before redeem(): ${beforeRedeemBalance}`);
log(`USDC Balance of addr1 after redeem(): ${finalBalance}`);
log(`Difference: ${finalBalance - beforeRedeemBalance}`);
});
Balance of addr1: 1000000000
Cost: 1000000000
USDC Balance of addr1 before buy(): 1000000000
ZENO Balance of addr1 before buy(): 0
USDC Balance of addr1 after buy(): 33479160
ZENO Balance of addr1 after buy(): 10
ZENO Balance of addr1 after redeem(): 4
USDC Balance of addr1 before redeem(): 33479160
USDC Balance of addr1 after redeem(): 33479166
Difference: 6
✔ Lack of redemption rate calculation results in dust payout amounts on redeem
In the test case, a user purchases 10 ZENO bonds, waits for redemption period to start, then redeems 6 bonds.
As shown in the output, for redeeming 6 ZENO bonds, the user receives 6 USDC tokens in return (including decimals), which is dust.
Impact
All users who participate in purchasing ZENO bonds will receive incorrect amounts on redemption.
Tools Used
Manual Review
Recommendations
Modify redeem()
and redeemAll()
to include calculations for redemptions, which includes scaling of output amount to correct decimals and usage of a redemption rate value, similar to cost
calculation in buy()
.