https://github.com/OpenEdenHQ/openeden.vault.audit/tree/d18288e944df21729b18d430b2afec2da99b6287
The claimServiceFee() function allows the protocol to claim unClaimedFee denominated in the underlying token (e.g., USDC). However, these underlying tokens are the exact assets backing TBILLs, and are used in tbillUsdcRate() to calculate the TBILL/USDC exchange rate. As such, claiming any portion of the underlying assets — even a tiny fee — causes the protocol to become undercollateralized.
This directly breaks the tbillUsdcRate() function, which then reverts due to this undercollateralization. Since _deposit(), updateEpoch(), processWithdrawalQueue(), redeem() etc (basically every major function) relies on tbillUsdcRate() to function, this leads to a denial of service in the entire protocol.
In claimServiceFee(), unClaimedFee is transferred out of the contract:
function claimServiceFee() external onlyRole(SERVICE_FEE_CLAIMER_ROLE) {
uint256 feeToClaim = unClaimedFee;
if (feeToClaim == 0) revert NoFeeToClaim();
unClaimedFee = 0;
IERC20MetadataUpgradeable(underlying).safeTransfer(msg.sender, feeToClaim);
emit ServiceFeeClaimed(msg.sender, feeToClaim);
}
This function transfers funds from the contract’s underlying token balance, which is supposed to fully back the TBILLs in existence. Removing any of it causes:
tbillUsdcRate() to revert due to failing price sanity checks.function tbillUsdcRate() public view returns (uint256) {
// ...
int256 answer = getTBillOracleAnswer();
if (answer < 0 || tbillUsdPrice < ONE) revert TBillInvalidPrice(answer);
// ...
}
The answer (representing TBILL's USD price) becomes < 1e8 when undercollateralized due to fee withdrawal, causing an immediate revert.
_deposit(): it calls tbillUsdcRate() and will revert forever once unClaimedFee is claimed.updateEpoch() computes unClaimedFee from TVL:uint256 serviceFee = (tvl * serviceFeeRate * epochLength) / (365 days * FEE_DENOMINATOR);
unClaimedFee += serviceFee;
It is directly proportional to the tvl — but it is not deducted at this point — so totalAssets remains unchanged until claimServiceFee() is called.
function tbillUsdcRate() public view returns (uint256) {
int256 answer = getTBillOracleAnswer(); // uses backing (i.e., underlying tokens)
if (answer < 0 || tbillUsdPrice < ONE) revert TBillInvalidPrice(answer);
}
Once unClaimedFee is removed from underlying, the price drops below 1e8.
_deposit() becomes permanently broken:function _deposit(...) internal override returns (uint256) {
uint256 rate = tbillUsdcRate(); // will revert
...
}
No user can deposit anymore.
Do not claim fees from the underlying token balance.
Instead of pulling funds from underlying, mint TBILLs equivalent to the fee amount:
function claimServiceFee() external onlyRole(SERVICE_FEE_CLAIMER_ROLE) {
uint256 feeToClaim = unClaimedFee;
if (feeToClaim == 0) revert NoFeeToClaim();
unClaimedFee = 0;
_mint(msg.sender, feeToClaim); // mint TBILLs instead
emit ServiceFeeClaimed(msg.sender, feeToClaim);
}
This preserves full backing of TBILLs by underlying assets, while still compensating the service provider.
Only allow unClaimedFee to be claimable from yield or interest earned, not principal. That way, collateral remains intact.
This bug fundamentally breaks core functionality of the protocol (_deposit) and puts the entire collateralization model at risk. It stems from misunderstanding the dual role of underlying as both collateral and claimable fee source.
Severity: Critical
Impact: Denial of Service, Protocol Integrity Loss
claimServiceFee()updateEpoch()tbillUsdcRate()_deposit()soon...