Sherlock · #59 · Apr 2025

// Shared _untrackedAvailableAssetBalance Between Redemptions and Rewards Causes Undercollateralization Risk
_untrackedAvailableAssetBalance returns the raw unaccounted ERC-20 balance of the contract. Both approveRedeem and depositIncome call this same function to check available funds — there is no reservation or earmarking of collateral for pending redemptions. Any collateral deposited for a redemption is immediately visible to depositIncome, which can consume it to mint reward YUSD before the redemption is processed.
// AegisMinting.sol L340 — approveRedeem
function approveRedeemRequest(string calldata requestId, uint256 amount) external {
// @audit uses raw untracked balance — no reservation for pending redemptions
uint256 availableAssetFunds = _untrackedAvailableAssetBalance(
request.order.collateralAsset
);
if (availableAssetFunds < collateralAmount) {
revert NotEnoughFunds();
}
// transfers collateral to user
}
// AegisMinting.sol L407 — depositIncome
function depositIncome(OrderLib.Order calldata order, bytes calldata signature) external {
// @audit same balance check — sees funds reserved for pending redemptions
uint256 availableAssetFunds = _untrackedAvailableAssetBalance(
order.collateralAsset
);
if (availableAssetFunds < order.collateralAmount) {
revert NotEnoughFunds();
}
// mints new YUSD as rewards — consuming the shared balance
IYUSD(yusd).mint(aegisRewards, yusdAmount);
}
// _untrackedAvailableAssetBalance — returns raw ERC-20 balance, no reservation
function _untrackedAvailableAssetBalance(address asset) internal view returns (uint256) {
return IERC20(asset).balanceOf(address(this)); // no earmarking
}
// PoC test output:
// [4/6] Untracked balance after redemption request: 100.0
// ⚠️ PROBLEM: Full amount still available despite pending redemption!
// [5/6] depositIncome succeeded (BUG: Should have failed!)
// [6/6] Redemption failed — NotEnoughFunds()
// Final YUSD rewards balance: 85.5 (minted without proper backing)▸ INCORRECT ASSUMPTIONS
▸ AFFECTED CONTRACTS
Admin deposits 100 USDC into AegisMinting to cover Alice's pending redemption request for 90 YUSD → 100 USDC.
↓_untrackedAvailableAssetBalance now returns 100 USDC. The same balance is visible to both approveRedeem and depositIncome.
↓Fund manager calls depositIncome on Monday (per protocol docs). The check passes — 100 USDC is "available." New YUSD rewards are minted. The 100 USDC is now tracked as income backing.
↓Admin calls approveRedeemRequest for Alice. _untrackedAvailableAssetBalance now returns 0 (balance was consumed by depositIncome). Transaction reverts with NotEnoughFunds. Alice cannot redeem her YUSD. The newly minted reward YUSD is unbacked.
Pending redemption requests can fail permanently if depositIncome consumes the collateral earmarked for them. Users holding YUSD cannot redeem 1:1 for collateral.
depositIncome mints reward YUSD against collateral already committed to pending redemptions. The resulting YUSD supply exceeds actual collateral backing.
YUSD's core guarantee — always redeemable 1:1 for collateral — is violated. Any period where rewards are distributed before redemptions are settled creates a backing gap.
Introduce separate tracking for collateral reserved for pending redemptions vs collateral available for rewards. depositIncome must only draw from the excess after all pending redemptions are covered.
// Single shared balance — no separation
function _untrackedAvailableAssetBalance(address asset)
internal view returns (uint256) {
return IERC20(asset).balanceOf(address(this));
// @audit redemption collateral and reward collateral are indistinguishable
}// Separate reservation tracking
mapping(address => uint256) private _reservedForRedemptions;
function approveRedeemRequest(...) external {
// reserve collateral at request time
_reservedForRedemptions[asset] += collateralAmount;
// ... approve
_reservedForRedemptions[asset] -= collateralAmount;
}
function depositIncome(...) external {
uint256 total = IERC20(asset).balanceOf(address(this));
uint256 reserved = _reservedForRedemptions[asset];
uint256 available = total > reserved ? total - reserved : 0;
// @dev only use excess collateral not earmarked for redemptions
if (available < order.collateralAmount) revert NotEnoughFunds();
}▸ SECURITY CONSIDERATIONS
I found this by mapping every function that reads _untrackedAvailableAssetBalance and asking: do any two of these functions compete for the same pool? approveRedeem and depositIncome both called it with no coordination — classic shared-state race condition.
The judge marked this invalid on admin-trust grounds: both functions are admin-only, so a rational admin wouldn't call depositIncome while redemptions are pending. I disagree with the framing. The protocol docs state rewards are distributed every Monday on a fixed schedule. A regular Monday distribution that happens before a pending redemption is approved is not malicious — it's the normal operating sequence. The contract should enforce safety invariants regardless of admin intent.
The PoC made the invariant violation undeniable. After depositIncome succeeds, the hardhat test shows: NotEnoughFunds on the subsequent approveRedeemRequest. 85.5 YUSD in rewards minted with zero net collateral backing. The numbers don't lie even if the judge treated it as a process issue.
This is a recurring pattern I now look for in every stablecoin with admin-gated flows: when multiple admin functions share a single balance pool, ask whether their execution order creates an invariant violation. Fixed schedules (every Monday) make the race condition deterministic, not hypothetical.