#46520 [SC-Low] ETH loss on `selfCloseExitTo` when redeeming to collateral
Submitted on Jun 1st 2025 at 01:19:34 UTC by @Rhaydden for Audit Comp | Flare | FAssets
Report ID: #46520
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/flare-foundation/fassets/blob/main/contracts/assetManager/implementation/CollateralPool.sol
Impacts:
Permanent freezing of funds
Description
Brief/Intro
In CollateralPool.selfCloseExitTo
, if a user passes msg.value
(intended as an executor fee) but sets _redeemToCollateral = true
, the contract never forwards or refunds that ETH. It remains locked in the pool contract resulting in permanent user fund loss.
Vulnerability Details
Inside _selfCloseExitTo
, the code handles f-asset redemption like this:
// … after calculating `requiredFAssets` …
if (requiredFAssets > 0) {
if (requiredFAssets < assetManager.lotSize() || _redeemToCollateral) {
// Non-payable call: msg.value is ignored
assetManager.redeemFromAgentInCollateral(
agentVault, _recipient, requiredFAssets
);
} else {
// Cross-chain redemption: msg.value is forwarded as executor fee
assetManager.redeemFromAgent{ value: msg.value }(
agentVault, _recipient, requiredFAssets, _redeemerUnderlyingAddress, _executor
);
}
}
When the first branch is taken—either because the redemption amount is too small or _redeemToCollateral==true
—the contract calls the non-payable redeemFromAgentInCollateral
and never touches msg.value
. That ETH is neither wrapped into wNat
nor returned to the sender. Since there is no fallback path for raw ETH (the receive()
guard only allows internal withdrawals), any ETH sent is irretrievably stuck.
Impact Details
Any ETH sent as a fee under these conditions is locked in the pool contract. Users cannot retrieve it, nor can the protocol recover it via any existing withdrawal mechanism. Even accidental small-value calls lead to direct loss. This falls squarely under Permanent freezing of funds in the impacts in scope.
Fix
In any branch that accepts msg.value, either
immediately call wNat.deposit{value: msg.value}()
(via _depositWNat()
) so it’s tracked as collateral, OR revert if _redeemToCollateral
or requiredFAssets < lotSize but msg.value > 0
References
https://github.com/flare-labs-ltd/fassets//blob/fc727ee70a6d36a3d8dec81892d76d01bb22e7f1/contracts/assetManager/implementation/CollateralPool.sol#L363-L372
Proof of Concept
Proof of Concept
Add this test to CollateralPool.ts:
describe("self-close exit fee retention", async () => {
it("should retain msg.value when redeemToCollateral=true", async () => {
// enter pool to get tokens.
// We put 10 ETH into the pool so the caller gets some pool tokens
await collateralPool.enter(0, true, { value: ETH(10) });
// Read the pool contract’s raw ETH balance before the exit
const before = toBN(await web3.eth.getBalance(collateralPool.address));
const tokenBalance = await collateralPoolToken.balanceOf(accounts[0]);
// Define a 1 ETH “executor fee” to pass via msg.value
const fee = ETH(1);
// call self-close exit with redeemToCollateral=true and pass fee
await collateralPool.selfCloseExitTo(tokenBalance, true, accounts[0], "", accounts[0], { value: fee });
// Read the pool contract’s raw ETH balance after the exit
const after = toBN(await web3.eth.getBalance(collateralPool.address));
// Assert that the pool’s balance increased by exactly fee (1 ETH), showing that the ETH was stuck in the contract
assertEqualBN(after.sub(before), fee, "msg.value should remain in contract");
});
});
Was this helpful?