Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Critical Economic Design Flaw in ZENO Zero-Coupon Bond Implementation Leads to Guaranteed User Losses

Summary

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.

Vulnerability Details

The issue stems from a mismatch between the auction purchase price and redemption mechanics:

  1. During the auction, users pay a premium price for ZENO tokens:

// In Auction.sol
function buy(uint256 amount) external whenActive {
uint256 price = getPrice();
@> uint256 cost = price * amount;
usdc.transferFrom(msg.sender, businessAddress, cost);
zeno.mint(msg.sender, amount);
}
  1. However, the redemption is fixed at 1:1 with USDC:

// In ZENO.sol
function redeem(uint256 amount) external nonReentrant {
_burn(msg.sender, amount);
@> USDC.safeTransfer(msg.sender, amount); // 1 ZENO = 1 USDC
}

This is also the case for ZENO.sol::redeemAll()

Lets hypothetically think about an auction for 1000 ZENO tokens at a starting price of 900 USDC and a reserve price of 700 USDC.

NOTE: these numbers are similar to the numbers used by the devs in their own testing

  • The user pays 700,000-900,000 USDC (depending on auction timing)

  • At redemption, they receive only 1,000 USDC (1 USDC per ZENO)

  • The user loses 699,000-899,000 USDC on their investment.

This scenario is demonstrated in the PoC

Impact

Critical. The current implementation:

  • Guarantees substantial losses for all users

  • Completely fails to function as a zero-coupon bond

  • Makes the product economically non-viable

Likelihood

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.

Proof of Concept

  1. Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.

  2. Comment out the forking object from the hardhat.congif.cjs file:

networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
//forking: {
// url: process.env.BASE_RPC_URL,
//},
  1. Copy the following test into the test folder:

// SPDX-License-Identifier: MIT
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";
// MockUSDC contract
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; // Must be RAW ZENO amount, scaling by 1e18 introduces more bugs
uint256 userUSDCMintAmount = 1_000_000e6;
function setUp() public {
// Set up contracts
vm.startPrank(owner);
usdc = new MockERC20();
zenoFactory = new ZENOFactory(owner);
auctionFactory = new AuctionFactory(owner);
// Create a zeno auction
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);
// Tranfer ownership to auction address
zenoFactory.transferZenoOwnership(0, auctionDetails.auctionAddress);
vm.stopPrank();
// Mint user usdc
usdc.mint(user, userUSDCMintAmount);
}
function test_brokenredeemAll() public {
// User initial USDC balance
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);
// User buys full ZENO bond
vm.startPrank(user);
usdc.approve(auctionDetails.auctionAddress, type(uint256).max);
IAuction(auctionDetails.auctionAddress).buy(zenoAmount);
// Assert user bought entire bond
Auction.AuctionDetails memory auctionState = IAuction(auctionDetails.auctionAddress).getDetails();
assertEq(auctionState.totalZENORemaining, 0);
// User USDC balance after purchasing ZENO
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));
//warp to when zeno bond matures
vm.warp(auctionState.auctionEndTime + 1);
//Business account usdc to zeno contract
vm.startPrank(businessAddress);
IERC20(address(usdc)).transfer(auctionState.zenoAddress, IERC20(address(usdc)).balanceOf(businessAddress));
vm.stopPrank();
// User redeems
vm.startPrank(user);
IZENO(auctionState.zenoAddress).redeemAll();
vm.stopPrank();
// User USDC balance after redeemAll
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 {
// User initial USDC balance
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);
// User buys full ZENO bond
vm.startPrank(user);
usdc.approve(auctionDetails.auctionAddress, type(uint256).max);
IAuction(auctionDetails.auctionAddress).buy(zenoAmount);
// Assert user bought entire bond
Auction.AuctionDetails memory auctionState = IAuction(auctionDetails.auctionAddress).getDetails();
assertEq(auctionState.totalZENORemaining, 0);
// User USDC balance after purchasing ZENO
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));
//warp to when zeno bond matures
vm.warp(auctionState.auctionEndTime + 1);
//Business account usdc to zeno contract
vm.startPrank(businessAddress);
IERC20(address(usdc)).transfer(auctionState.zenoAddress, IERC20(address(usdc)).balanceOf(businessAddress));
vm.stopPrank();
// User redeems
vm.startPrank(user);
vm.expectRevert();
IZENO(auctionState.zenoAddress).redeem(zenoAmount + 1); // Reverts :: show RAW ZENO required
IZENO(auctionState.zenoAddress).redeem(zenoAmount);
vm.stopPrank();
// User USDC balance after redeemAll
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)));
}
}
  1. run forge test -vv

  2. Change variables as wanted, any combination shows massive user loss. NOTE: zenoAmount must be raw amount otherwise user would require amount * price * 1e24 USDC.

/**
* @dev You can change these variables to any combination
*/
uint256 zenoBondStartingPrice = 900e6;
uint256 zenoBondReservePrice = 700e6;
uint256 zenoAmount = 1000; // Must be RAW ZENO amount, scaling by 1e18 introduces more bugs
uint256 userUSDCMintAmount = 1_000_000e6;
  1. output:

Logs:
Initial user balance: 1000000000000
Initial user balance scaled down by 1e6: 1000000
User balance after Zeno purchase: 100002314000
User balance after Zeno purchase scaled down by 1e6: 100002
User transfer of USDC scaled down by 1e6: 899997
User balance after redeemAll(): 100002315000 // Increase by 000000001000
User balance after redeemAll() scaled down by 1e6: 100002
User loss: -899997685000

Recommendations

The problem seems to be a lack of correct scaling in the redeem and redeemAll() function.

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

ZENO.sol implements fixed 1:1 redemption with USDC regardless of auction purchase price, breaking zero-coupon bond economics and causing user funds to be permanently lost

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

ZENO.sol implements fixed 1:1 redemption with USDC regardless of auction purchase price, breaking zero-coupon bond economics and causing user funds to be permanently lost

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!