#46976 [SC-Low] Agent Destruction Can Permanently Lock Unclaimed Transfer Fees
Submitted on Jun 7th 2025 at 07:06:56 UTC by @Bluedragon for Audit Comp | Flare | FAssets
Report ID: #46976
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/flare-foundation/fassets/blob/main/contracts/assetManager/facets/TransferFeeFacet.sol
Impacts:
Permanent freezing of unclaimed yield
Description
Summary:
When transfer fees are enabled (transferFeeMillionths > 0), agents accumulate transfer fees that can be claimed via claimTransferFees
. However, when an agent destroys their vault, the system doesn't enforce collection of these accumulated fees before destruction. Since the claimTransferFees
function depends on agent vault storage that gets deleted during destruction, any unclaimed transfer fees become permanently locked in the AssetManager contract.
Vulnerability Details:
The issue stems from the transfer fee system's dependency on agent vault storage and the lack of fee collection enforcement during agent destruction.
Transfer fees are tracked and accumulated in TransferFeeTracking.sol
, where each agent has associated fee tracking data.
Agents can claim their accumulated fees using TransferFeeFacet.sol
, which requires the agent vault to exist and uses the vault's storage for calculations.
However, when an agent destroys their vault, there's no mechanism to force collection of outstanding transfer fees before the destruction process completes.
Impact:
Permanent Fee Loss: Unclaimed transfer fees become permanently inaccessible after agent destruction
Fund Lock-up: Transfer fees accumulate in the AssetManager contract with no recovery mechanism
Economic Inefficiency: Agents lose rightful fee earnings if they forget to claim before destruction
System Accounting Issues: Contract holds more funds than can be distributed
Scenario (step by step):
Transfer Fee Accumulation:
Transfer fees are enabled with transferFeeMillionths > 0
Agent operates normally, accumulating transfer fees over time
Fees are tracked in
TransferFeeTracking.sol
Agent Decides to Exit:
Agent reduces their position to zero (no minted assets, no collateral backing)
Agent announces exit from the system
Agent has accumulated transfer fees but doesn't claim them
Destruction Process:
Agent calls destroy function after waiting period
System allows destruction without checking for unclaimed transfer fees
Agent vault storage is deleted/reset
Permanent Fee Loss:
After destruction,
claimTransferFees
can no longer be called for this agentThe function requires agent vault storage which fails after vault destruction
Transfer fees remain in the contract but become unclaimable
Recommended Mitigation:
Option 1: Enforce Fee Collection Before Destruction
Add a check in the agent destruction process to ensure all transfer fees are claimed:
// In agent destruction logic, add:
(uint256 unclaimedFees,) = transferFeeFacet.agentTransferFeeShare(agentVault, type(uint256).max);
require(unclaimedFees == 0, "must claim transfer fees before destruction");
Option 2: Auto-claim During Destruction Automatically claim and transfer any outstanding fees to the agent owner during the destruction process.
The most user-friendly approach would be Option 2, automatically claiming fees during destruction to prevent accidental loss.
Proof of Concept
Proof of Concept:
Add the following test to the
12-TrailingFees
intest/integration/fasset-simulation/
directoryRun the test using
yarn testHH
it.only("Agent destruction can lock the unclaimed transfer fees", async () => {
const agent = await Agent.createTest(context, agentOwner1, underlyingAgent1);
const agent2 = await Agent.createTest(context, agentOwner2, underlyingAgent2);
const minter = await Minter.createTest(context, userAddress1, underlyingUser1, context.lotSize().muln(100));
const redeemer = await Redeemer.create(context, userAddress2, underlyingUser2);
const agentInfo = await agent.getAgentInfo();
await agent.depositCollateralsAndMakeAvailable(toWei(1e8), toWei(1e8));
await agent2.depositCollateralsAndMakeAvailable(toWei(1e8), toWei(1e8));
mockChain.mine(10);
await context.updateUnderlyingBlock();
const currentEpoch = await assetManager.currentTransferFeeEpoch();
// perform minting
const lots = 3;
const [minted] = await minter.performMinting(agent.vaultAddress, lots);
const [minted2] = await minter.performMinting(agent2.vaultAddress, lots);
// transfer and check that fee is accounted
const transferAmount = context.lotSize().muln(3);
// transfer exact dest
const transferFee = await calculateFee(transferAmount, true);
await minter.transferFAsset(redeemer.address, transferAmount, true);
const endBalanceM = await fAsset.balanceOf(minter.address);
const endBalanceR = await fAsset.balanceOf(redeemer.address);
// skip 1 epoch and claim
await deterministicTimeIncrease(epochDuration);
// Redeemer redeems the minted lots
const [requests] = await redeemer.requestRedemption(lots);
await agent.performRedemptions(requests);
const info = await agent.getAgentInfo();
console.log("==== Minter mints f-assets ====");
console.log("Agent total Minted UBA: ", info.mintedUBA.toString()); // pool fee
console.log("Agent FAsset balance: ", (await context.fAsset.balanceOf(agent.vaultAddress)).toString());
const poolAmt = toBN("24000000");
const poolFee = await calculateFee(poolAmt, false);
console.log("Transfer fee for pool self close:", poolFee.toString()); // needs extra transfer fee to self close
await minter.transferFAsset(agent.ownerWorkAddress, poolFee, true);
// Agent is going to be destroyed
console.log("\n==== Agent is going to be destroyed ====");
await agent.exitAndDestroy();
console.log("Agent collateral pool token total supply after destroy: ", (await agent.collateralPoolToken.totalSupply()).toString());
// announce agent pool token redemption
let assetManagerFee = await context.fAsset.balanceOf(assetManager.address);
console.log("Total transfer fee in Asset manager:", assetManagerFee.toString());
const claimableAmount1 = await agent.transferFeeShare(10);
console.log("Claimable amount of agent 1:", claimableAmount1.toString());
const claimableAmount2 = await agent.transferFeeShare(10);
console.log("Claimable amount of agent 2:", claimableAmount2.toString());
// Agent 2 claims the transfer fees
const claimed = await agent2.claimTransferFees(agent2.ownerWorkAddress, 10);
console.log("Agent 2 claimed transfer fees:", claimed.agentClaimedUBA.toString());
console.log("Agent 2 pool claimed transfer fees:", claimed.poolClaimedUBA.toString());
// Agent 1 tries to claim transfer fees
const claimed2 = agent.claimTransferFees(agent.ownerWorkAddress, 10);
await expectRevert(claimed2, "invalid agent vault address")
assetManagerFee = await context.fAsset.balanceOf(assetManager.address);
console.log("Total transfer fee in Asset manager after agent2 claim:", assetManagerFee.toString());
});
Logs
Contract: AssetManagerSimulation.sol; test/integration/fasset-simulation/12-TrailingFees.ts; Asset manager simulations - transfer fees
==== Minter mints f-assets ====
Agent total Minted UBA: 24000000
Agent FAsset balance: 0
Transfer fee for pool: 4800
==== Agent is going to be destroyed ====
Agent collateral pool token total supply after destroy: 0
Total transfer fee in Asset manager: 124826
Claimable amount of agent 1: 60012
Claimable amount of agent 2: 60012
Agent 2 claimed transfer fees: 36008
Agent 2 pool claimed transfer fees: 24004
Total transfer fee in Asset manager after agent2 claim: 64814
✔ Agent destruction can lock the unclaimed transfer fees (864ms)
Was this helpful?