Aegis.im YUSD

Sherlock · #59 · Apr 2025

Aegis.im YUSD

HIGHINVALIDSherlock

// Shared _untrackedAvailableAssetBalance Between Redemptions and Rewards Causes Undercollateralization Risk

01 /

EXECUTIVE SUMMARY

EXECUTIVE_SUMMARY.log
ISSUE OVERVIEWAegisMinting uses the same _untrackedAvailableAssetBalance function to gatekeep both approveRedeem (user collateral withdrawals) and depositIncome (reward YUSD minting) with no segregation between the two pools. A fund manager calling depositIncome before a pending redemption is approved consumes collateral reserved for the user, causing the redemption to fail or minting unbacked YUSD — directly violating the protocol's 1:1 redemption guarantee. Marked invalid by the judge on admin-trust grounds, but the accounting invariant violation is real.
AFFECTED COMPONENTAegisMinting.sol — _untrackedAvailableAssetBalance (L340, L407)
RISK CLASSIFICATIONCollateral Accounting Invariant Violation / Stablecoin Peg Risk
SEVERITYHIGH
STATUSINVALID
02 /

ROOT CAUSE ANALYSIS

ROOT_CAUSE_ANALYSIS.log

_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) and L407 (depositIncome) share the same balance check
// 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

  • Collateral deposited for redemptions is implicitly reserved and not visible to depositIncome
  • Admins will always deposit separate collateral before calling depositIncome — the contract enforces this separation
  • The _untrackedAvailableAssetBalance check is sufficient to prevent double-allocation of the same funds

AFFECTED CONTRACTS

  • AegisMinting.sol — approveRedeemRequest() (L340)
  • AegisMinting.sol — depositIncome() (L407)
  • AegisMinting.sol — _untrackedAvailableAssetBalance() (shared)
03 /

ATTACK SCENARIO

ATTACK_VECTOR — Fund Manager Drains Redemption Collateral via depositIncome
01

Admin deposits 100 USDC into AegisMinting to cover Alice's pending redemption request for 90 YUSD → 100 USDC.

02

_untrackedAvailableAssetBalance now returns 100 USDC. The same balance is visible to both approveRedeem and depositIncome.

03

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.

04

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.

04 /

IMPACT ASSESSMENT

HIGH

Redemption Failure

Pending redemption requests can fail permanently if depositIncome consumes the collateral earmarked for them. Users holding YUSD cannot redeem 1:1 for collateral.

HIGH

Unbacked YUSD Minting

depositIncome mints reward YUSD against collateral already committed to pending redemptions. The resulting YUSD supply exceeds actual collateral backing.

HIGH

Peg Integrity

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.

05 /

MITIGATION

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.

— VULNERABLEimplementation
// 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
}
+ SECUREimplementation
// 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

  • 01.Reserve collateral atomically when a redemption request is submitted, not when it is approved — this closes the window entirely
  • 02.Add a protocol invariant test: after any sequence of requestRedeem + depositIncome, all pending redemptions must be fully coverable by remaining contract balance
  • 03.Consider a two-pool architecture: RedemptionVault and RewardVault — transfers between them require explicit admin action with no shared balance view
06 /

RESEARCHER NOTES

RESEARCHER_NOTES.md
@preetsinghmakkar— personal analysis

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.

07 /

RESOURCES

08 /

RELATED FINDINGS