#46068 [SC-Low] selfCloseExitTo is lack of slippage protect

Submitted on May 24th 2025 at 12:11:24 UTC by @ox9527 for Audit Comp | Flare | FAssets

  • Report ID: #46068

  • Report Type: Smart Contract

  • Report severity: Low

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

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

When a user invokes selfCloseExitTo, the calculation of requiredFAssets is non-trivial, as it depends on the current collateral ratio (CR). For simplicity, let’s assume the current CR is below the healthy threshold. In this case, the formula for requiredFAssets becomes:

requiredFAssets = agentBackedFAsset * _natShare / poolNatBalance

The agentBackedFAsset is composed of reservedAMG, mintedAMG, and poolRedeemingAMG.

Importantly, any user can call CollateralReservationsFacet.sol::reserveCollateral() to increase the value of reservedAMG. Since agentBackedFAsset includes reservedAMG, this operation directly increases the agentBackedFAsset value.

As a result, the required amount of f-assets for selfCloseExitTo also increases, potentially making the operation more expensive and allowing malicious actors to exploit the mechanism by inflating the cost for other users.

Vulnerability Details

Firstly CollateralReservationsFacet.sol::reserveCollateral() can increase the value of reservedAMG reserveCollateral()->_reserveCollateral():

    function _reserveCollateral(
        Agent.State storage _agent,
        uint64 _reservationAMG
    )
        private
    {
        AssetManagerState.State storage state = AssetManagerState.get();
        Minting.checkMintingCap(_reservationAMG); //@audit-info if minting cap is reached, revert
        _agent.reservedAMG += _reservationAMG;
        state.totalReservedCollateralAMG += _reservationAMG;
    }

And from the selfCloseExitTo()->_selfCloseExitTo()->_getFAssetRequiredToNotSpoilCR():

        } else {
            // f-asset that preserves pool CR (assume poolNatBalance >= natShare > 0)
            // solve (N - n) / (F - f) = N / F get n = N f / F
            return _assetData.agentBackedFAsset.mulDiv(_natShare, _assetData.poolNatBalance);
        }

requiredFAssets is depends on _assetData.agentBackedFAsset

Due to agentBackedFAsset is fetched from assetManager::getFAssetsBackedByPool(agent)->AgentsExternal.getFAssetsBackedByPool():

    function getFAssetsBackedByPool(address _agentVault)
        internal view
        returns (uint256)
    {
        Agent.State storage agent = Agent.get(_agentVault);
        return Conversion.convertAmgToUBA(agent.reservedAMG + agent.mintedAMG + agent.poolRedeemingAMG);
    }

Impact Details

If user's account can't afford the required fAssets , user have to transfer more fAssets to the protocol. If the max cost fAssets is not checked user may cost more fAssets than expected.

References

        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); <@

Proof of Concept

Proof of Concept

Add the following test to file CollateralPool.ts

        it.only("Test selfCloseExit is lack of slippage protect", async () => {

            await collateralPool.enter(0, true, { value: ETH(10), from: accounts[1] });
            await collateralPool.enter(0, false, { value: ETH(3), from: accounts[0] });
            await givePoolFAssetFees(ETH(22));
            const exitTokens = ETH(2);
            const fBefore = await collateralPool.fAssetFeesOf(accounts[0]);

            //user front-run.
            // await fAsset.mint(accounts[6], ETH(2), { from: assetManager.address });
            await collateralPool.selfCloseExitTo(exitTokens, true, accounts[6], "underlying_1", ZERO_ADDRESS, { from: accounts[0] });

            const fAfter = await collateralPool.fAssetFeesOf(accounts[0]);

            console.log("diff:", fBefore.sub(fAfter).toString());

            //3692307692307692308
            //3384615384615384616
        });

Why do i use fAsset.mint ? Cuz from the AssetManagerMock.sol the getFAssetsBackedByPool is total supply :

    function getFAssetsBackedByPool(address /* _backer */) external view returns (uint256) {
        return fasset.totalSupply();
    }

Add or comment fAsset.mint we can see the output difference is :

Was this helpful?