The ZENO protocol's zero-coupon bond implementation has a fundamental economic design flaw where users pay a premium price for ZENO tokens but can only redeem them for a fraction of their purchase price, resulting in guaranteed significant losses for users. This completely inverts the intended mechanics of zero-coupon bonds, which should provide a profit through discount pricing.
The issue stems from a mismatch between the auction purchase price and redemption mechanics:
Lets hypothetically think about an auction for 1000 ZENO tokens at a starting price of 900 USDC and a reserve price of 700 USDC.
Critical. The current implementation:
High - This is not a conditional bug but rather a fundamental design flaw that affects every single transaction in the intended core functionality of the protocol.
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ZENO} from "../../contracts/zeno/ZENO.sol";
import {ZENOFactory} from "../../contracts/zeno/ZENOFactory.sol";
import {AuctionFactory} from "../../contracts/zeno/AuctionFactory.sol";
import {Auction} from "../../contracts/zeno/Auction.sol";
import "../../contracts/interfaces/IUSDC.sol";
import "../../contracts/interfaces/zeno/IAuction.sol";
import "../../contracts/interfaces/zeno/IZENO.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Test, console} from "forge-std/Test.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("USDC", "USDC") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
function decimals() public view override returns(uint8) {
return 6;
}
}
contract ZenoTest is Test {
ZENOFactory public zenoFactory;
AuctionFactory public auctionFactory;
MockERC20 public usdc;
address public owner = makeAddr("Owner");
address public businessAddress = makeAddr("businessAddress");
address public user = makeAddr("user");
ZENO.ZENODetails zenoDetails;
Auction.AuctionDetails auctionDetails;
* @dev You can change these variables to any combination
*/
uint256 zenoBondStartingPrice = 900e6;
uint256 zenoBondReservePrice = 700e6;
uint256 zenoAmount = 1000;
uint256 userUSDCMintAmount = 1_000_000e6;
function setUp() public {
vm.startPrank(owner);
usdc = new MockERC20();
zenoFactory = new ZENOFactory(owner);
auctionFactory = new AuctionFactory(owner);
zenoFactory.createZENOContract(address(usdc), (block.timestamp + 86400));
zenoDetails = zenoFactory.getZENODetails(0);
address zenoAddress = zenoDetails.zenoAddress;
auctionFactory.createAuction(
zenoAddress,
address(usdc),
businessAddress,
block.timestamp,
(block.timestamp + 86400),
zenoBondStartingPrice,
zenoBondReservePrice,
zenoAmount
);
auctionDetails = auctionFactory.getAuctionDetails(0);
zenoFactory.transferZenoOwnership(0, auctionDetails.auctionAddress);
vm.stopPrank();
usdc.mint(user, userUSDCMintAmount);
}
function test_brokenredeemAll() public {
uint256 initialBalance = IERC20(address(usdc)).balanceOf(user);
console.log("Initial user balance: ", initialBalance);
console.log("Initial user balance scaled down by 1e6: ", initialBalance / 1e6);
vm.warp(block.timestamp + 1);
vm.startPrank(user);
usdc.approve(auctionDetails.auctionAddress, type(uint256).max);
IAuction(auctionDetails.auctionAddress).buy(zenoAmount);
Auction.AuctionDetails memory auctionState = IAuction(auctionDetails.auctionAddress).getDetails();
assertEq(auctionState.totalZENORemaining, 0);
console.log("User balance after Zeno purchase: ", IERC20(address(usdc)).balanceOf(user));
console.log("User balance after Zeno purchase scaled down by 1e6: ", IERC20(address(usdc)).balanceOf(user) / 1e6 );
console.log("User transfer of USDC scaled down by 1e6: ", int256(int256(int256(initialBalance) - int256(IERC20(address(usdc)).balanceOf(user))) / 1e6));
vm.warp(auctionState.auctionEndTime + 1);
vm.startPrank(businessAddress);
IERC20(address(usdc)).transfer(auctionState.zenoAddress, IERC20(address(usdc)).balanceOf(businessAddress));
vm.stopPrank();
vm.startPrank(user);
IZENO(auctionState.zenoAddress).redeemAll();
vm.stopPrank();
uint256 finalBalance = IERC20(address(usdc)).balanceOf(user);
console.log("User balance after redeemAll(): " , finalBalance);
console.log("User balance after redeemAll() scaled down by 1e6: " , finalBalance / 1e6);
console.log("User loss: ", int256(int256(finalBalance) - int256(initialBalance)));
}
function test_brokenredeem() public {
uint256 initialBalance = IERC20(address(usdc)).balanceOf(user);
console.log("Initial user balance: ", initialBalance);
console.log("Initial user balance scaled down by 1e6: ", initialBalance / 1e6);
vm.warp(block.timestamp + 1);
vm.startPrank(user);
usdc.approve(auctionDetails.auctionAddress, type(uint256).max);
IAuction(auctionDetails.auctionAddress).buy(zenoAmount);
Auction.AuctionDetails memory auctionState = IAuction(auctionDetails.auctionAddress).getDetails();
assertEq(auctionState.totalZENORemaining, 0);
console.log("User balance after Zeno purchase: ", IERC20(address(usdc)).balanceOf(user));
console.log("User balance after Zeno purchase scaled down by 1e6: ", IERC20(address(usdc)).balanceOf(user) / 1e6 );
console.log("User transfer of USDC scaled down by 1e6: ", int256(int256(int256(initialBalance) - int256(IERC20(address(usdc)).balanceOf(user))) / 1e6));
vm.warp(auctionState.auctionEndTime + 1);
vm.startPrank(businessAddress);
IERC20(address(usdc)).transfer(auctionState.zenoAddress, IERC20(address(usdc)).balanceOf(businessAddress));
vm.stopPrank();
vm.startPrank(user);
vm.expectRevert();
IZENO(auctionState.zenoAddress).redeem(zenoAmount + 1);
IZENO(auctionState.zenoAddress).redeem(zenoAmount);
vm.stopPrank();
uint256 finalBalance = IERC20(address(usdc)).balanceOf(user);
console.log("User balance after redeemAll(): " , finalBalance);
console.log("User balance after redeemAll() scaled down by 1e6: " , finalBalance / 1e6);
console.log("User loss: ", int256(int256(finalBalance) - int256(initialBalance)));
}
}
It would be correct to scale the redemption amounts by the price at the time of auction and thereafter add the fixed interest they should've accrued.