#46984 [SC-Low] Incomplete Token Supply Check After Token Share Recalculation in `_selfCloseExitTo`

Submitted on Jun 7th 2025 at 08:43:37 UTC by @light279 for Audit Comp | Flare | FAssets

  • Report ID: #46984

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/flare-foundation/fassets/blob/main/contracts/assetManager/implementation/CollateralPool.sol

  • Impacts:

    • Smart contract unable to operate due to lack of token funds

Description

Brief/Intro

In the CollateralPool::_selfCloseExitTo function, after the recalculation of _tokenShare due to insufficient agent redemption capacity, no validation is performed to ensure the updated _tokenShare satisfies the minimum token supply constraint (MIN_TOKEN_SUPPLY_AFTER_EXIT). This breaks an important invariant enforced earlier in the function and could result in an invalid or inconsistent state post-exit.

Vulnerability Details

The CollateralPool::_selfCloseExitTo function initially validates the following condition:

function _selfCloseExitTo(
        uint256 _tokenShare,
        bool _redeemToCollateral,
        address payable _recipient,
        string memory _redeemerUnderlyingAddress,
        address payable _executor
    )
        private
    {
        require(_tokenShare > 0, "token share is zero");
        require(_tokenShare <= token.balanceOf(msg.sender), "token balance too low");
        AssetData memory assetData = _getAssetData();
@>        require(assetData.poolTokenSupply == _tokenShare ||
            assetData.poolTokenSupply - _tokenShare >= MIN_TOKEN_SUPPLY_AFTER_EXIT,
            "token supply left after exit is too low and non-zero");
        uint256 natShare = assetData.poolNatBalance.mulDiv(
            _tokenShare, assetData.poolTokenSupply); // poolTokenSupply >= _tokenShare > 0
        require(natShare > 0, "amount of sent tokens is too small");
        require(assetData.poolNatBalance == natShare ||
            assetData.poolNatBalance - natShare >= MIN_NAT_BALANCE_AFTER_EXIT,
            "collateral left after exit is too low and non-zero");
        uint256 maxAgentRedemption = assetManager.maxRedemptionFromAgent(agentVault);
        uint256 requiredFAssets = _getFAssetRequiredToNotSpoilCR(assetData, natShare);
        // rare case: if agent has too many low-valued open tickets they can't redeem the requiredFAssets
        // in one transaction. In that case we lower/correct the amount of spent tokens and nat share.
        ...........

This check ensures that either:

  • The user is exiting the entire pool (draining all tokens), or

  • The remaining pool token supply after exit stays above a configured minimum.

However, if the (maxAgentRedemption < requiredFAssets), the function recalculates natShare and _tokenShare:

function _selfCloseExitTo(
        uint256 _tokenShare,
        bool _redeemToCollateral,
        address payable _recipient,
        string memory _redeemerUnderlyingAddress,
        address payable _executor
    )
        private
    {
        ................
        if (maxAgentRedemption < requiredFAssets) {
            // natShare and _tokenShare decrease!
            requiredFAssets = maxAgentRedemption;
@>            natShare = _getNatRequiredToNotSpoilCR(assetData, requiredFAssets);
@>            require(natShare > 0, "amount of sent tokens is too small after agent max redemption correction");
            require(assetData.poolNatBalance == natShare ||
                assetData.poolNatBalance - natShare >= MIN_NAT_BALANCE_AFTER_EXIT,
                "collateral left after exit is too low and non-zero");
            // poolNatBalance >= previous natShare > 0
@>            _tokenShare = assetData.poolTokenSupply.mulDiv(natShare, assetData.poolNatBalance);
            emit IncompleteSelfCloseExit(_tokenShare, requiredFAssets);
        }
        // get owner f-asset fees to be spent (maximize fee withdrawal to cover the potentially necessary f-assets)
        uint256 fAssetFees = _fAssetFeesOf(assetData, msg.sender);
        (uint256 debtFAssetFeeShare, uint256 freeFAssetFeeShare) = _getDebtAndFreeFAssetFeesFromTokenShare(
            assetData, msg.sender, _tokenShare, TokenExitType.MAXIMIZE_FEE_WITHDRAWAL);
        // if owner f-asset fees do not cover the required f-assets, require additional f-assets
     ................

While the function re-checks the natShare constraint above:

require(assetData.poolNatBalance == natShare ||
    assetData.poolNatBalance - natShare >= MIN_NAT_BALANCE_AFTER_EXIT,
    "collateral left after exit is too low and non-zero");

It fails to re-validate _tokenShare against the MIN_TOKEN_SUPPLY_AFTER_EXIT constraint. As a result, it is possible for:

assetData.poolTokenSupply - _tokenShare < MIN_TOKEN_SUPPLY_AFTER_EXIT

to occur silently, breaking pool invariants and possibly leading to unintended or unsafe behavior in downstream logic that assumes this minimum is preserved.

Impact Details

  • Users could unintentionally drain the pool below the minimum allowed token supply.

  • Breaks protocol-level assumptions that rely on MIN_TOKEN_SUPPLY_AFTER_EXIT being respected.

Add any relevant links to documentation or code:

After _tokenShare is recalculated in the if (maxAgentRedemption < requiredFAssets) block in function CollateralPool::_selfCloseExitTo, reapply the token supply check:

function _selfCloseExitTo(
        uint256 _tokenShare,
        bool _redeemToCollateral,
        address payable _recipient,
        string memory _redeemerUnderlyingAddress,
        address payable _executor
    )
        private
    {
        ................
        if (maxAgentRedemption < requiredFAssets) {
            // natShare and _tokenShare decrease!
            requiredFAssets = maxAgentRedemption;
            natShare = _getNatRequiredToNotSpoilCR(assetData, requiredFAssets);
            require(natShare > 0, "amount of sent tokens is too small after agent max redemption correction");
            require(assetData.poolNatBalance == natShare ||
                assetData.poolNatBalance - natShare >= MIN_NAT_BALANCE_AFTER_EXIT,
                "collateral left after exit is too low and non-zero");
            // poolNatBalance >= previous natShare > 0
           _tokenShare = assetData.poolTokenSupply.mulDiv(natShare, assetData.poolNatBalance);
+          require(assetData.poolTokenSupply == _tokenShare ||
            assetData.poolTokenSupply - _tokenShare >= MIN_TOKEN_SUPPLY_AFTER_EXIT,
            "token supply left after exit is too low and non-zero");
            emit IncompleteSelfCloseExit(_tokenShare, requiredFAssets);
        }
        // get owner f-asset fees to be spent (maximize fee withdrawal to cover the potentially necessary f-assets)
        uint256 fAssetFees = _fAssetFeesOf(assetData, msg.sender);
        (uint256 debtFAssetFeeShare, uint256 freeFAssetFeeShare) = _getDebtAndFreeFAssetFeesFromTokenShare(
            assetData, msg.sender, _tokenShare, TokenExitType.MAXIMIZE_FEE_WITHDRAWAL);
        // if owner f-asset fees do not cover the required f-assets, require additional f-assets
     ................

Proof of Concept

Proof of Concept

1: User initiates selfCloseExit

  • User calls selfCloseExit(_tokenShare, ...) with some initial _tokenShare.

Checks performed:

  • _tokenShare > 0

  • _tokenShare <= user balance

  • poolTokenSupply - _tokenShare >= MIN_TOKEN_SUPPLY_AFTER_EXIT ✅

  • natShare = poolNatBalance * _tokenShare / poolTokenSupply

  • poolNatBalance - natShare >= MIN_NAT_BALANCE_AFTER_EXIT ✅ All checks pass.

2: Required F-assets are too high

  • requiredFAssets = _getFAssetRequiredToNotSpoilCR(...)

  • But:

maxAgentRedemption < requiredFAssets

This triggers the fallback path to adjust.

3: Adjust natShare and recheck only collateral constraint

  • requiredFAssets is capped to maxAgentRedemption

  • A new natShare is computed via _getNatRequiredToNotSpoilCR(...)

  • Now, natShare is lower than the original

  • Check re-applied:

require(poolNatBalance - natShare >= MIN_NAT_BALANCE_AFTER_EXIT)

4: _tokenShare is recalculated — but no re-validation!

  • _tokenShare is now recomputed:

_tokenShare = poolTokenSupply * natShare / poolNatBalance
  • No check is done again to verify:

poolTokenSupply - _tokenShare >= MIN_TOKEN_SUPPLY_AFTER_EXIT

Therefore, the new _tokenShare could reduce the pool’s token supply below the minimum required threshold, violating the pool’s safety invariants.

Was this helpful?