Core Contracts

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

ZENO Token Redemption Returns Negligible USDC Amount Compared to Purchase Price

Summary

The ZENO token redemption mechanism is severely flawed, causing users to receive only a tiny fraction of their initial USDC investment when redeeming ZENO tokens at maturity. In the test case, a user who spent approximately 9,999 USDC to purchase 100 ZENO tokens receives less than 1 USDC upon redemption, resulting in a near-total loss of funds.

Each ZENO token should represent 1 USDC at maturity, regardless of the purchase price. This is how zero-coupon bonds work - if someone buys during the auction at 0.9 USDC per ZENO or 0.5 USDC per ZENO, they should ALL get 1 USDC per ZENO at maturity.

Vulnerability Details

Initial Purchase:

  • User purchases 100 ZENO tokens for ~9,999 USDC (approximately 99.99 USDC per token)

  • The purchase transaction completes successfully, deducting the USDC and minting 100 ZENO tokens

Redemption Process:

  • At maturity, the ZENO contract is funded with 10,000 USDC

  • User attempts to redeem 100 ZENO tokens

  • Instead of receiving back 10000 USDC, the user receives less than 1 USDC (specifically 0.000100 USDC).

    USDC)

  • The ZENO tokens are burned correctly, but the USDC return amount is incorrect

PoC

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console2} from "forge-std/Test.sol";
import {AuctionFactory} from "../../contracts/zeno/AuctionFactory.sol";
import {ZENOFactory} from "../../contracts/zeno/ZENOFactory.sol";
import {Auction} from "../../contracts/zeno/Auction.sol";
import {ZENO} from "../../contracts/zeno/ZENO.sol";
import {MockUSDC} from "../../contracts/mocks/core/tokens/MockUSDC.sol";
contract ZenoTest is Test {
AuctionFactory public auctionFactory;
ZENOFactory public zenoFactory;
MockUSDC public usdc;
address public auctionFactoryAddress;
address public zenoFactoryAddress;
address public usdcAddress;
address public owner;
address public addr1;
address public addr2;
address public businessAccount;
address public auction1Address;
address public zeno1Address;
Auction public auction1;
ZENO public zeno1;
uint256 public auctionStartTime;
uint256 public auctionEndTime;
uint256 public startingPrice;
uint256 public reservePrice;
uint256 public totalZENOAllocated;
uint256 public totalZENORemaining;
uint256 public maturityDate;
function setUp() public {
// Setup accounts
owner = makeAddr("owner");
addr1 = makeAddr("addr1");
addr2 = makeAddr("addr2");
businessAccount = makeAddr("business");
// Deploy USDC with initial supply of 1,000,000
usdc = new MockUSDC(1_000_000 * 10 ** 6);
usdcAddress = address(usdc);
// Deploy factories
auctionFactory = new AuctionFactory(owner);
auctionFactoryAddress = address(auctionFactory);
zenoFactory = new ZENOFactory(owner);
zenoFactoryAddress = address(zenoFactory);
// Create ZENO contract
maturityDate = block.timestamp + 365 days;
vm.prank(owner);
zenoFactory.createZENOContract(usdcAddress, maturityDate);
zeno1Address = address(zenoFactory.getZENO(0));
zeno1 = ZENO(zeno1Address);
// Setup auction parameters
auctionStartTime = block.timestamp + 1 hours;
auctionEndTime = auctionStartTime + 1 days;
startingPrice = 100 * 10 ** 6; // 100 USDC
reservePrice = 10 * 10 ** 6; // 10 USDC
totalZENOAllocated = 100; // 100 Bonds
totalZENORemaining = totalZENOAllocated;
// Create auction
vm.prank(owner);
auctionFactory.createAuction(
zeno1Address,
usdcAddress,
businessAccount,
auctionStartTime,
auctionEndTime,
startingPrice,
reservePrice,
totalZENOAllocated
);
auction1Address = address(auctionFactory.getAuction(0));
auction1 = Auction(auction1Address);
// Transfer ZENO ownership to auction
vm.prank(owner);
zenoFactory.transferZenoOwnership(0, auction1Address);
// Mint USDC to test accounts
usdc.mint(addr1, 10_000 * 10 ** 6);
usdc.mint(businessAccount, 10_000 * 10 ** 6);
vm.prank(addr1);
usdc.approve(auction1Address, 10_000 * 10 ** 6);
}
function test_ZenoRedeemReturnsLessThanExpected() public {
uint256 zenoAmountToBuy = 100;
// wait for auction to start
vm.warp(auctionStartTime + 1 seconds);
uint256 currentPrice = auction1.getPrice();
// calculate price to buy 100 ZENO at current price
uint256 priceToBuy = zenoAmountToBuy * currentPrice;
console2.log("Price to buy:", priceToBuy); // 9999.895900 USDC needed to buy 100 ZENO
// 10_000 USDC
uint256 usdcBalanceBeforeBuy = usdc.balanceOf(addr1);
console2.log("USDC balance before buy of addr1:", usdcBalanceBeforeBuy);
vm.prank(addr1);
auction1.buy(zenoAmountToBuy);
uint256 usdcBalanceAfterBuy = usdc.balanceOf(addr1);
// 0.104100
console2.log("USDC balance after buy of addr1:", usdcBalanceAfterBuy);
assertEq(usdc.balanceOf(addr1), usdcBalanceBeforeBuy - priceToBuy);
// 100 ZENO Tokens bought
assertEq(zeno1.balanceOf(addr1), zenoAmountToBuy);
// wait for maturity date
vm.warp(maturityDate + 1 minutes);
// fund the ZENO contract with 10_000 USDC
uint256 zenoUsdcBalance = 10_000 * 10 ** 6;
usdc.mint(zeno1Address, zenoUsdcBalance);
assertEq(usdc.balanceOf(zeno1Address), zenoUsdcBalance);
// redeem 100 ZENO for USDC => should get back 10_000 USDC
vm.prank(addr1);
zeno1.redeem(100);
// 0.104200 USDC
uint256 usdcBalanceAfterRedeem = usdc.balanceOf(addr1);
console2.log("USDC balance after redeem of addr1:", usdcBalanceAfterRedeem);
// 0.000100 USDC
uint256 actualUsdcReturned = usdcBalanceAfterRedeem - usdcBalanceAfterBuy;
console2.log("Actual USDC returned:", actualUsdcReturned);
//! User has less than 1 USDC after redeeming 100 ZENO tokens
assertLt(1, actualUsdcReturned);
// final check that user has no ZENO tokens left
uint256 zenoBalanceAfterRedeem = zeno1.balanceOf(addr1);
assertEq(zenoBalanceAfterRedeem, 0);
}
}

Impact

  • High severity as it results in direct loss of user funds

  • Users lose over 99.99% of their initial investment

  • Breaks the core functionality of the ZENO token as a zero-coupon bond

  • Violates the documented 1:1 backing with USDC

Tools Used

  • Foundry

  • Manual Review

Recommendations

Scale Zeno amount to equivalent USDC amount for the redeem and redeemAll function.

function redeem(uint256 amount) external nonReentrant {
// ... existing code
// Each ZENO is worth exactly 1 USDC at maturity
uint256 usdcAmount = amount * (10 ** 6);
uint256 usdcBalance = USDC.balanceOf(address(this));
if (usdcBalance < amount) {
revert InsufficientBalance();
}
USDC.safeTransfer(msg.sender, usdcAmount);
}
function redeemAll() external nonReentrant {
// ... existing code
uint256 usdcAmount = amount * (10 ** 6);
uint256 usdcBalance = USDC.balanceOf(address(this));
if (usdcBalance < amount) {
revert InsufficientBalance();
}
USDC.safeTransfer(msg.sender, usdcAmount);
}
Updates

Lead Judging Commences

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

Decimal precision mismatch between ZENO token (18 decimals) and USDC (6 decimals) not accounted for in redemption, causing calculation errors and incorrect payments

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

Decimal precision mismatch between ZENO token (18 decimals) and USDC (6 decimals) not accounted for in redemption, causing calculation errors and incorrect payments

Support

FAQs

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