Flare - FAsset
Findings & Analysis Report
2025-11-27
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [M-01] Vault owner is incetivized to not switch invalid collateral token, during liquidation
- [M-02] Agent underlying balance can be inflated, which cannot be prove-able to challenge it as illegal.
- [M-03] Accumulated rewards in FSTO can be stolen by the agent’s owner
- [M-04] The collateral that liquidators receive is valued below their initial expectations as a result of the price fall
-
Low Risk and Non-Critical Issues
- 01 Self-close exit can revert even when no redemption is needed (strict
>check vs capacity) - 02 Executor-fee remainder (<1 gwei) is neither recorded, nor refunded, when an executor is set, leaving dust in the contract
- 03 Zero-lot redemption is a silent no-op that can permanently trap the caller’s
msg.value(executor fee) - 04 Unhandled zero-price from FTSO causes divide-by-zero reverts (DoS) in price conversion paths
- 05 Outdated comment in
PaymentConfirmations: “verified payments expire in 5 days” (they don’t) - 06
SafePct.mulDivfallback can revert on non-overflowing results (intermediate overflow), violating function contract
- 01 Self-close exit can revert even when no redemption is needed (strict
- Disclosures
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Flare - FAsset smart contract system. The audit took place from August 19 to September 23, 2025.
Following the C4 audit, 3 wardens (givn, farman1094 and rvierdiiev) reviewed the mitigations for all identified issues; the mitigation review report is appended below the audit report.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 4 unique vulnerabilities. Of these vulnerabilities, 0 received a risk rating in the category of HIGH severity and 4 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 18 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and Flare team.
Scope
The code under review can be found within the C4 Flare FAsset repository, and is composed of 105 smart contracts written in the Solidity programming language and includes 8760 lines of Solidity code.
The code in C4’s Flare - FAsset repository was pulled from:
- Repository: https://github.com/flare-foundation/fassets
- Commit hash: d8eb381227be4c238bc91707a34a7ae8f39983e7
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
Medium Risk Findings (4)
[M-01] Vault owner is incetivized to not switch invalid collateral token, during liquidation
Submitted by rvierdiiev
Finding Description and Impact
A collateral token may become invalid following a governance decision. In such a case, the agent is expected to switch it to another valid collateral token. However, there is a scenario where the vault owner has no incentive to perform this switch at least temporarily.
During liquidation, if the vault collateral token is invalid, then the collateral pool covers the entire payment. If both the pool and the vault are undercollateralized, the vault’s liability is capped at half of the pool’s responsibility.
This creates an incentive for the agent:
- With a valid collateral token, the vault typically pays 100% (
vaultFactor) while the pool pays 30% (poolFactor), totaling ~115% for the agent (as vault covers half ofpoolFactor). - With an invalid collateral token, the vault’s effective share may drop to only ~65% of responsibility, which is significantly more favorable for the agent.
As a result, agents may strategically avoid switching to a valid collateral token during liquidation, undermining the system’s design.
Recommended Mitigation Steps
In case the collateral token becomes invalid, liquidation should still require payments from the vault based on the same vaultFactor logic, rather than shifting the entire burden to the pool. This ensures agents remain properly incentivized to update their collateral token when governance decisions invalidate one.
Token deprecation and agent vault token switching have been removed.
Status: Mitigation confirmed. Full details in reports from givn, rvierdiiev, and farman1094.
[M-02] Agent underlying balance can be inflated, which cannot be prove-able to challenge it as illegal.
Submitted by farman1094
Finding Description
This issue is agent can make the payment to one of his another address, and later use this amount to top-up himself. According to the protocol, this shouldn’t be happening or should be considered illegal. But the agent can able to done it that way it wouldn’t be provable on FAsset mechanism, that this is illegal or agent underlying balance has decreased.
There are multiple things which make this possible,
-
First agent will make a payment to any of his other address to use this amount later for top-up,
-
This payment will be made using a valid payment address of redeem which also include
newRedemptionRequestIdas standard payment reference which will not be problem to calculate.function _newRequestId(bool _poolSelfClose) private returns (uint64) { AssetManagerState.State storage state = AssetManagerState.get(); uint64 nextRequestId = state.newRedemptionRequestId + PaymentReference.randomizedIdSkip(); // the requestId will indicate in the lowest bit whether it is a pool self close redemption // (+1 is added so that the request id still increases after clearing lowest bit) uint64 requestId = ((nextRequestId + 1) & ~uint64(1)) | (_poolSelfClose ? 1 : 0); state.newRedemptionRequestId = requestId; return requestId; } -
state.newRedemptionRequestIdcan easily fetch through chain, slot is 14 need to decoding 8 byte.``` |---------------------------------------+-------------------------------------------------------+------+--------+-------+----------------------------------------------------------| | newRedemptionRequestId | uint64 | 14 | 8 | 8 | |---------------------------------------+-------------------------------------------------------+------+--------+-------+----------------------------------------------------------| ``` PaymentReference.randomizedIdSkipcan be guessed through the block number,
-
- After payment agent can initiate the request from the
CoreVaultClientFacet::transferToCoreVaultwhich will return the same payment id which we have already used in form of standard payment reference for agent’s another address. -
As the payment has already been made with the payment reference and the request created later we made this check vulnerable.
// RedemptionConfirmationsFacet::confirmRedemptionPayment require(_payment.data.responseBody.blockNumber >= request.firstUnderlyingBlock,- The payment made with
blockNumberbefore therequest.firstUnderlyingBlockso this payment proof cannot be submitted inRedemptionConfirmationsFacet::confirmRedemptionPayment. If submitted, the above check will save this by reverting, underlying balance cannot be decreased.
- The payment made with
-
Even though
RedemptionDefaultsFacet::executeDefaultOrCancelthis request maybe can defaulted but this is only cancel thetransferToCoreVaultrequest and this will update redeem status todefaultednot close, which is considered open.// RedemptionDefaultsFacet::redemptionPaymentDefault request.status = Redemption.Status.DEFAULTED;Redemptions.isOpen(redemption)the defaulted considered open
// true if redemption is valid and has not been confirmed yet function isOpen(Redemption.Request storage _request) internal view returns (bool) { Redemption.Status status = _request.status; return status == Redemption.Status.ACTIVE || status == Redemption.Status.DEFAULTED; } -
Now come to this this payment also cannot be challenged
ChallengesFacet::illegalPaymentChallengeand nor fromChallengesFacet::freeBalanceNegativeChallengeBecause:ChallengesFacet::illegalPaymentChallengethis check will save this here the whole transaction revert even though it supposed to highlight this as illegalrequire(!redemptionActive, MatchingRedemptionActive());because the payment to different address not to the underlying address of core vault.
// ChallengesFacet::illegalPaymentChallenge if (paymentReference != 0) { if (PaymentReference.isValid(paymentReference, PaymentReference.REDEMPTION)) { uint256 redemptionId = PaymentReference.decodeId(paymentReference); Redemption.Request storage redemption = state.redemptionRequests[redemptionId]; // Redemption must be for the correct agent, must not be rejected and // only statuses ACTIVE and DEFAULTED mean that redemption is still missing a payment proof. // We do not check for timestamp of the payment, because on UTXO chains legal payments can be // delayed by arbitrary time due to high fees and cannot be canceled, which could lead to // unnecessary full liquidations. bool redemptionActive = redemption.agentVault == _agentVault && Redemptions.isOpen(redemption); require(!redemptionActive, MatchingRedemptionActive()); } -
This transaction will not be highlighted to
ChallengesFacet::freeBalanceNegativeChallengein order to liquidate the agent because the payment agent made to his another address is same asredemptionValue.// `ChallengesFacet::freeBalanceNegativeChallenge` bytes32 paymentReference = pmi.data.responseBody.standardPaymentReference; if (PaymentReference.isValid(paymentReference, PaymentReference.REDEMPTION)) { // for open redemption, we don't count the value that should be paid to free balance deduction. // Note that we don't need to check that the redemption is for this agent, because payments // with redemption reference for other agent can be immediatelly challenged as illegal. uint256 redemptionId = PaymentReference.decodeId(pmi.data.responseBody.standardPaymentReference); Redemption.Request storage request = state.redemptionRequests[redemptionId]; uint256 redemptionValue = Redemptions.isOpen(request) ? request.underlyingValueUBA : 0; total += pmi.data.responseBody.spentAmount - SafeCast.toInt256(redemptionValue);-
So this would not be prove
balanceAfterPaymentsis less as becauseredemptionValuedecreased fromspentAmount. This check will here save this from liquidating the Agentrequire(balanceAfterPayments < requiredBalance.toInt256(), MultiplePaymentsChallengeEnoughBalance());
-
Impact
Agent underlying balance is inflated by request.underlyingValueUBA; which is not provable. The agent able to mint more FAsset even without holding it underlying chain.
Proof of Concept
Update the code in: test/integration/assetManager/05-RedemptionConfirmationByOthers.ts.
Use this command to run this specific test:
npx hardhat test test/integration/assetManager/05-RedemptionConfirmationByOthers.ts --grep "test issue default payment reference payment cannot highlighted as illegal"
The POC highlights any redemption request which has been defaulted if the payment reference of that request has been used in underlying chain for the payment to anyone by agent. They cannot be highlighted as in ChallengesFacet by both functions: illegalPaymentChallenge or freeBalanceNegativeChallenge.
it("test issue default payment reference payment cannot highlighted as illegal", async () => {
const agent = await Agent.createTest(context, agentOwner1, underlyingAgent1);
const minter = await Minter.createTest(context, minterAddress1, underlyingMinter1, context.underlyingAmount(10000));
const redeemer = await Redeemer.create(context, redeemerAddress1, underlyingRedeemer1);
const challenger = await Challenger.create(context, challengerAddress1);
// make agent available
const fullAgentCollateral = toWei(3e8);
await agent.depositCollateralsAndMakeAvailable(fullAgentCollateral, fullAgentCollateral);
// update block
await context.updateUnderlyingBlock();
// perform minting
const lots = 3;
const crt = await minter.reserveCollateral(agent.vaultAddress, lots);
const txHash = await minter.performMintingPayment(crt);
const minted = await minter.executeMinting(crt, txHash);
assertWeb3Equal(minted.mintedAmountUBA, context.convertLotsToUBA(lots));
// redeemer "buys" f-assets
await context.fAsset.transfer(redeemer.address, minted.mintedAmountUBA, { from: minter.address });
// perform redemption
await context.updateUnderlyingBlock();
const [redemptionRequests, remainingLots, dustChanges] = await redeemer.requestRedemption(lots);
assertWeb3Equal(remainingLots, 0);
assert.equal(dustChanges.length, 0);
assert.equal(redemptionRequests.length, 1);
const request = redemptionRequests[0];
assert.equal(request.agentVault, agent.vaultAddress);
// mine some blocks to create overflow block
for (let i = 0; i <= context.chainInfo.underlyingBlocksForPayment + 10; i++) {
await minter.wallet.addTransaction(minter.underlyingAddress, minter.underlyingAddress, 1, null);
}
// test rewarding for redemption payment default
const startVaultCollateralBalanceRedeemer = await agent.vaultCollateralToken().balanceOf(redeemer.address);
const startVaultCollateralBalanceAgent = await agent.vaultCollateralToken().balanceOf(agent.agentVault.address);
const startPoolBalanceRedeemer = await context.wNat.balanceOf(redeemer.address);
const startPoolBalanceAgent = await agent.poolCollateralBalance();
await agent.checkAgentInfo({ totalVaultCollateralWei: fullAgentCollateral, freeUnderlyingBalanceUBA: minted.agentFeeUBA, mintedUBA: minted.poolFeeUBA, reservedUBA: 0, redeemingUBA: request.valueUBA });
const res = await redeemer.redemptionPaymentDefault(request);
const randomAddr = "SomeOtherAddress";
const tx1Hash = await agent.performPayment(underlyingRedeemer2, request.valueUBA, request.paymentReference);
await expectRevert.custom(challenger.illegalPaymentChallenge(agent, tx1Hash), "MatchingRedemptionActive", []);
await expectRevert.custom(challenger.freeBalanceNegativeChallenge(agent, [tx1Hash]), "MultiplePaymentsChallengeEnoughBalance", []);
}
Payments made before first redemption block can be challenged.
Status: Mitigation confirmed. Full details in reports from givn, rvierdiiev, and farman1094.
[M-03] Accumulated rewards in FSTO can be stolen by the agent’s owner
Submitted by pashap9990, also found by givn
Finding Description
Rewards obtained from FSTO through the delegation of voting power to signal providers can be stolen by the agent’s owner due to a discrepancy between the reward token’s address and WNAT’s address in the collateralPool contract, leading to the forfeiture of rewards for CPT holders.
Proof of Concept
Issue Path:
- Users contribute their WNATs to the agent’s pool.
- The owner’s agent delegates voting power to signal providers.
- The WNAT address is modified by the asset updater in the Flare Time Series Oracle (FTSO).
AssetManagerController::updateContractsis invoked by every user, as it does not have access constraints. The agent’s owner identifies the opportunity and executesCollateralPool::claimDelegationRewards, resulting in the transfer of new WNAT to the agent’s pool; however, thetotalCollateralremains unchanged due to an inconsistency between the received token and the pool’s collateral token.- Subsequently, the agent’s owner invokes
CollateralPool::upgradeWNatContract, which merely swaps the old WNATs for the new version. - Finally, the agent’s owner can sweep collected new WNATs as a reward following the announcement of destruction.
function claimDelegationRewards(
IRewardManager _rewardManager,
uint24 _lastRewardEpoch,
IRewardManager.RewardClaimWithProof[] calldata _proofs
)
external
onlyAgent
nonReentrant
returns (uint256)
{
@>>> uint256 balanceBefore = wNat.balanceOf(address(this));
_rewardManager.claim(address(this), payable(address(this)), _lastRewardEpoch, true, _proofs);
@>>> uint256 balanceAfter = wNat.balanceOf(address(this));
uint256 claimed = balanceAfter - balanceBefore;
totalCollateral += claimed;
assetManager.updateCollateral(agentVault, wNat);
emit CPClaimedReward(claimed, 1);
return claimed;
}
Coded PoC:
Kindly apply the git patch and incorporate the subsequent proof of concept (PoC) into AttackScenario.ts.
++ b/test/integration/assetManager/AttackScenarios.ts
++import { impersonateContract, stopImpersonatingContract } from "../../../lib/test-utils/contract-test-helpers";
++ const WNat = artifacts.require("WNatMock");
+++ b/contracts/assetManager/mock/RewardManagerMock.sol
@@ -24,7 +24,7 @@ contract RewardManagerMock {
)
external returns(uint256 _rewardAmount)
{
- uint256 reward = 1 ether;
+ uint256 reward = 1000 ether;
if (_wrap) {
wNat.transfer(_recipient, reward);
} else {
it.only("update-wnat-address", async() => {
const agent = await Agent.createTest(context, agentOwner1, underlyingAgent1);
const requiredCollateral = await agent.requiredCollateralForLots(10);
await agent.depositCollateralsAndMakeAvailable(requiredCollateral.vault, requiredCollateral.pool);
const user = accounts[12];
await agent.collateralPool.enter({value: requiredCollateral.pool, from: user});
const newWNAT = await WNat.new(context.governance, "WNAT2", "WNAT2");
await impersonateContract(context.assetManagerController.address, toBNExp(1, 18), accounts[0]);
await context.assetManager.updateSystemContracts(context.assetManagerController.address, newWNAT.address,
{ from: context.assetManagerController.address });
await stopImpersonatingContract(context.assetManagerController.address);
let snapshotId = await network.provider.send("evm_snapshot", []);
await context.assetManager.upgradeWNatContract(agent.agentVault.address, {from: agent.ownerWorkAddress});
let rewardManagerMock = artifacts.require('RewardManagerMock');
let rewardManager = await rewardManagerMock.new(newWNAT.address);
let claimAmount = toBNExp(1000, 18);
await newWNAT.depositTo(rewardManager.address, { value: claimAmount });
await agent.collateralPool.claimDelegationRewards(rewardManager.address, 0, [],
{ from: agent.ownerWorkAddress});
let totalCollateral = await agent.collateralPool.totalCollateral();
let totalSupply = await agent.collateralPoolToken.totalSupply();
let tokenShare = await agent.collateralPoolToken.balanceOf(user);
let natShare1 = totalCollateral.mul(tokenShare).div(totalSupply);
console.log("net share with updating wnat address before claiming is %d NAT:", Number(natShare1) / 1e18);
await network.provider.send("evm_revert", [snapshotId]);
rewardManagerMock = artifacts.require('RewardManagerMock');
rewardManager = await rewardManagerMock.new(newWNAT.address);
claimAmount = toBNExp(1000, 18);
await newWNAT.depositTo(rewardManager.address, { value: claimAmount });
await agent.collateralPool.claimDelegationRewards(rewardManager.address, 0, [],
{ from: agent.ownerWorkAddress});
await context.assetManager.upgradeWNatContract(agent.agentVault.address, {from: agent.ownerWorkAddress});
totalCollateral = await agent.collateralPool.totalCollateral();
totalSupply = await agent.collateralPoolToken.totalSupply();
tokenShare = await agent.collateralPoolToken.balanceOf(user);
let natShare2 = totalCollateral.mul(tokenShare).div(totalSupply);
console.log("net share without updating wnat address before claiming is %d NAT", Number(natShare2) / 1e18);
console.log("user's loss:%d NAT", (natShare1 - natShare2) / 1e18)
});
Contract: AssetManager.sol; test/integration/assetManager/AttackScenarios.ts; Asset manager integration tests
net share with updating wnat address before claiming is 251500 NAT:
net share without updating wnat address before claiming is 250999.99999999997 NAT:
user's loss:500.00000000001154 NAT
✔ update-wnat-address (91ms)
1 passing (3s)
upgradeWNatContractmust be called by governance or a governance-appointed executor.
Status: Mitigation confirmed. Full details in reports from farman1094 and rvierdiiev.
[M-04] The collateral that liquidators receive is valued below their initial expectations as a result of the price fall
Submitted by pashap9990
Finding Description and Impact
Due to insufficient safeguards against price drops, liquidators receive less collateral than expected from the vault and pool, resulting in financial losses.
- An agent is established, after which the necessary collateral is deposited into the agent’s vault and collateral pool. The agent is then made publicly accessible.
- A minter reserves the required collateral and initiates the corresponding payment transaction.
- The function
MintingFacet::executeMintingis invoked, resulting in the minter receiving fassets. - The agent executes a decrease transaction on the underlying network without prior notification.
-
An illegal payment is detected, and the challenger submits proof of the illegal payment. As a result, the agent’s status changes to
FULL_LIQUIDATION.Suppose:
- F-asset = FXRP
- 1 XRP = 1 USD
- Vault’s collateral is USDC
- 1 NAT = 1 USD
- Liquidation factor = 1.05
Based on the above assumptions, the liquidator is expected to receive 10,000 USDC and 500 NAT for one lot.
- The liquidator submits the transaction to the network. However, if the price of XRP decreases to 0.90 USD at the time of execution, the liquidator receives 9,000 USDC and 450 NAT, which is less than anticipated.
Proof of Concept
Apply the git patch provided below and incorporate the following proof of concept into the file AttackScenario.ts.
+++ b/lib/test-utils/actors/TestChainInfo.ts
@@ -59,7 +59,7 @@ export const testChainInfo: Record<'eth' | 'btc' | 'xrp', TestChainInfo> = {
assetSymbol: "XRP",
decimals: 6,
amgDecimals: 6,
- startPrice: 0.53,
+ startPrice: 1,
blockTime: 10,
finalizationBlocks: 10,
underlyingBlocksForPayment: 10,
diff --git a/lib/test-utils/test-settings.ts b/lib/test-utils/test-settings.ts
index 865c696..779a7c9 100644
--- a/lib/test-utils/test-settings.ts
+++ b/lib/test-utils/test-settings.ts
@@ -344,8 +344,8 @@ export async function createMockFtsoV2PriceStore(governanceSettingsAddress: stri
await priceStore.setCurrentPrice(symbol, toBNExp(price, decimals), 0);
await priceStore.setCurrentPriceFromTrustedProviders(symbol, toBNExp(price, decimals), 0);
}
- await setInitPrice("NAT", 0.42);
- await setInitPrice("USDC", 1.01);
+ await setInitPrice("NAT", 1);
+ await setInitPrice("USDC", 1);
await setInitPrice("USDT", 0.99);
for (const ci of Object.values(assetChainInfos)) {
await setInitPrice(ci.symbol, ci.startPrice);
diff --git a/test/integration/assetManager/AttackScenarios.ts b/test/integration/assetManager/AttackScenarios.ts
index 94532e1..0f4353e 100644
--- a/test/integration/assetManager/AttackScenarios.ts
+++ b/test/integration/assetManager/AttackScenarios.ts
@@ -43,7 +43,7 @@ contract(`AssetManager.sol; ${getTestFile(__filename)}; Asset manager integratio
async function initialize() {
commonContext = await CommonContext.createTest(governance);
- context = await AssetContext.createTest(commonContext, testChainInfo.eth);
+ context = await AssetContext.createTest(commonContext, testChainInfo.xrp);
return { commonContext, context };
}
it.only("liquidator-receive-collateral-less-than-expected", async() => {
const agent = await Agent.createTest(context, agentOwner1, underlyingAgent1);
const minter = await Minter.createTest(context, minterAddress1, underlyingMinter1, context.underlyingAmount(1e8));
const liquidator = await Liquidator.create(context, liquidatorAddress1);
// make agent available one lot worth of pool collateral
const requiredCollateral = await agent.requiredCollateralForLots(100);
await agent.depositCollateralsAndMakeAvailable(requiredCollateral.vault, requiredCollateral.pool);
// minter mints
const lots = 100;
const crt = await minter.reserveCollateral(agent.vaultAddress, lots);
const txHash1 = await minter.performMintingPayment(crt);
const minted = await minter.executeMinting(crt, txHash1);
await agent.performPayment("IllegalPayment1", 100);
// challenge agent for illegal payment
const proof = await context.attestationProvider.proveBalanceDecreasingTransaction(txHash1, agent.underlyingAddress);
await context.assetManager.illegalPaymentChallenge(proof, agent.agentVault.address, { from: accounts[1234] });
// liquidator "buys" f-assets
await context.fAsset.transfer(liquidator.address, minted.mintedAmountUBA, { from: minter.address });
// liquidate agent (partially)
console.log("--------------------> XRP price drop Scenario <-----------------------");
let liquidateMaxUBA1 = minted.mintedAmountUBA.divn(lots);
let startBalanceLiquidator1NAT = await context.wNat.balanceOf(liquidator.address);
let startBalanceLiquidator1VaultCollateral = await agent.vaultCollateralToken().balanceOf(liquidator.address);
let snapshotId = await network.provider.send("evm_snapshot", []);
await context.priceStore.setCurrentPrice("FXRP", 90000, 0);
let [liquidatedUBA1, liquidationTimestamp1, liquidationStarted1, liquidationCancelled1] = await liquidator.liquidate(agent, liquidateMaxUBA1);
let endBalanceLiquidator1NAT = await context.wNat.balanceOf(liquidator.address);
let endBalanceLiquidator1VaultCollateral = await agent.vaultCollateralToken().balanceOf(liquidator.address);
console.log("vault collateral recieved amount:%d USDC", (endBalanceLiquidator1VaultCollateral - startBalanceLiquidator1VaultCollateral) /1e18);
console.log("pool collateral recieved amount:%d NAT", (endBalanceLiquidator1NAT - startBalanceLiquidator1NAT) / 1e18);
console.log("liquidatedUBA1:%d FXRP", liquidatedUBA1.toString() / 1e6);
await network.provider.send("evm_revert", [snapshotId]);
console.log("--------------------> Normal Scenario <-----------------------");
liquidateMaxUBA1 = minted.mintedAmountUBA.divn(lots);
startBalanceLiquidator1NAT = await context.wNat.balanceOf(liquidator.address);
startBalanceLiquidator1VaultCollateral = await agent.vaultCollateralToken().balanceOf(liquidator.address);
[liquidatedUBA1, liquidationTimestamp1, liquidationStarted1, liquidationCancelled1] = await liquidator.liquidate(agent, liquidateMaxUBA1);
endBalanceLiquidator1NAT = await context.wNat.balanceOf(liquidator.address);
endBalanceLiquidator1VaultCollateral = await agent.vaultCollateralToken().balanceOf(liquidator.address);
console.log("vault collateral recieved amount:%d USDC", (endBalanceLiquidator1VaultCollateral - startBalanceLiquidator1VaultCollateral) /1e18);
console.log("pool collateral recieved amount:%d NAT", (endBalanceLiquidator1NAT - startBalanceLiquidator1NAT) / 1e18);
console.log("liquidatedUBA1:%d FXRP", liquidatedUBA1.toString() / 1e6);
});
Contract: AssetManager.sol; test/integration/assetManager/AttackScenarios.ts; Asset manager integration tests
--------------------> XRP price drop Scenario <-----------------------
vault collateral recieved amount:9000 USDC
pool collateral recieved amount:450 NAT
liquidatedUBA1:10000 FXRP
--------------------> Normal Scenario <-----------------------
vault collateral recieved amount:10000 USDC
pool collateral recieved amount:500 NAT
liquidatedUBA1:10000 FXRP
✔ liquidator-receive-collateral-less-than-expected (109ms)
1 passing (2s)
Recommended Mitigation Steps
Implementing a safeguard mechanism, such as slippage control, would provide a more robust solution.
Flare commented:
Flare has decided not to fix this issue, as prices from FTSO oracle are updated only every 90 seconds.
Low Risk and Non-Critical Issues
For this audit, 18 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by jerry0422 received the top score from the judge.
The following wardens also submitted reports: Almanax, billyrg131, cosin3, emerald7017, Ephraim, K42, komronkh, LeoGold, Manvita, mbuba666, n0m4d1c_b34r, nachin, NexusAudits, osok, PolarizedLight, Web3Vikings, and yongskiws.
[01] Self-close exit can revert even when no redemption is needed (strict > check vs capacity)
Finding Description
In CollateralPool._selfCloseExitTo, the contract prevents a self-close exit unless:
maxAgentRedemption > requiredFAssets
This strict inequality introduces two false-revert scenarios:
- No redemption needed: When
_getFAssetRequiredToNotSpoilCR(natShare)returns 0 (the pool CR remains acceptable without any f-asset redemption), the code still enforcesmaxAgentRedemption> 0. If the agent currently reports zero per-tx redemption capacity (e.g., no open tickets), the exit reverts even though no redemption is required. - Exact-capacity case: When
requiredFAssetsequalsmaxAgentRedemption, the redemption should be feasible in a single transaction. The strict>comparison wrongly rejects such exits, producing unnecessary reverts.
Why equality is safe: requiredFAssets is already rounded up to assetMintingGranularityUBA() in _getFAssetRequiredToNotSpoilCR, so allowing >= still guarantees the agent can process the full redemption amount in one call.
Impact
Denial of service for legitimate self-close exits:
- Users can be blocked from exiting under healthy CR conditions if the agent’s reported capacity is 0 (even though no actual redemption would occur).
- Users can be blocked when agent capacity matches the exact amount required (needless revert). This is a liveness/usability bug that can lock users out of a valid exit path until external conditions change.
Recommended mitigation steps
require(maxAgentRedemption >= requiredFAssets, RedemptionRequiresClosingTooManyTickets());
[02] Executor-fee remainder (<1 gwei) is neither recorded, nor refunded, when an executor is set, leaving dust in the contract
Finding Description and Impact
When an executor is provided in reserveCollateral, the contract records the executor fee in gwei units:
cr.executorFeeNatGWei = ((msg.value - reservationFee) / Conversion.GWEI).toUint64();
Let:
- x =
msg.value - reservationFee - e =
floor(x / 1e9) * 1e9(the recorded/exposed executor fee in wei) - r =
x - e with 0 ≤ r < 1e9(the sub-gwei remainder)
The event emits e (_cr.executorFeeNatGWei * 1e9), and there is no refund path for r when executor != address(0). The change refund only runs for the non-executor branch.
Result: the remainder r stays in the contract untracked and unrecoverable by the payer. This is a dust loss per call (strictly < 1 gwei), accumulating only if callers send non-gwei-aligned values.
Reproduction (minimal):
- Call
reserveCollateral(agentVault, lots, maxMintingFeeBIPS, executor = E)withmsg.value = reservationFee + 1 wei. - Contract records
cr.executorFeeNatGWei = 0, emitsexecutorFee = 0, and the refund branch is skipped (executor set). - The 1 wei remainder remains in the contract.
Recommended Mitigation Steps
Option A: Refund sub-gwei remainder when executor is set.
```solidity
uint256 paidForExecutor = uint256(cr.executorFeeNatGWei) * Conversion.GWEI;
uint256 change = msg.value - reservationFee - paidForExecutor;
if (cr.executor != address(0) && change > 0) {
Transfers.transferNAT(payable(msg.sender), change);
}
```
Option B: Store exact wei instead of gwei.
- Replace
executorFeeNatGWeiwithexecutorFeeNatWei(e.g., uint128), assignmsg.value - reservationFee, and use the wei value directly in the event and subsequent logic. No rounding occurs.
Option C: Enforce gwei-aligned payments
solidity uint256 remainder = (msg.value - reservationFee) % Conversion.GWEI; if (remainder != 0) revert InappropriateFeeAmount();
[03] Zero-lot redemption is a silent no-op that can permanently trap the caller’s msg.value (executor fee)
Finding Description
redeem(uint256 _lots, string memory _redeemerUnderlyingAddressString, address payable _executor) is payable and accepts an executor fee via msg.value, which is split across the per-agent redemption requests that are created in the function.
However:
- If
_lots == 0, the main loop condition for (uint256 i = 0; i < maxRedeemedTickets && redeemedLots < _lots; i++) evaluates false immediately, so the loop body never runs. No redemption requests are created (redemptionList.length == 0), the post-loop executor-fee distribution loop also doesn’t run, and there is no refund path. The function burns 0fAssetsand returns 0, silently locking the entiremsg.valuein the contract. - Even with
_lots > 0, if the global queue is non-empty but contains only “dust” (no full lots available),_redeemFirstTicketcan end up converting tickets to dust without adding any entry toredemptionList. This again leavesredemptionList.length == 0, so all ofmsg.valueremains stuck. An informationalRedemptionRequestIncompleteevent is emitted, but the fee is still trapped. - The existing
require(redeemedLots != 0, RedeemZeroLots())guard sits inside the branch that executes only when the queue is empty and only after the loop has started. It never fires for_lots == 0, because the loop doesn’t start.
Impact
- Permanent loss of funds for callers who send a non-zero executor fee with
_lots == 0, or when only dust is available. - Griefing/vector for accidental deposits to the contract, growing its trapped balance.
- Inconsistent UX: zero-lot calls “succeed” without effect (no revert), making misconfigurations harder to detect.
Recommended Mitigation Steps
-
Reject zero-lot calls up-front: Place this at the very beginning of
redeem.```solidity require(_lots > 0, RedeemZeroLots()); ``` -
Guarantee refund on no-work paths: If, after processing,
redemptionList.length == 0, revert (preferred, to avoid silent traps) or explicitly refundmsg.valuebefore returning:```solidity if (redemptionList.length == 0) { require(msg.value == 0, "No executor fee when nothing redeemed"); return 0; } ```- Optionally, normalize the executor fee handling to refund any leftover wei
<1 gwei afterexecutorFeeNatGWei = msg.value / Conversion.GWEIto avoid dust accumulation even on successful paths.
- Optionally, normalize the executor fee handling to refund any leftover wei
These changes make zero-effect invocations fail loudly and ensure user funds cannot be inadvertently locked.
[04] Unhandled zero-price from FTSO causes divide-by-zero reverts (DoS) in price conversion paths
Finding Description
At runtime, FTSO feeds can theoretically produce a price of 0. While the sponsor views price == 0 as a real market value (not “unavailable”), the code does not handle zero safely in multiple conversion helpers. In particular, for non-direct pairs calcAmgToTokenWeiPrice performs:
return _assetPrice.mulDiv(10 ** (expPlus - expMinus), _tokenPrice);
If _tokenPrice == 0, this divides by zero and reverts. Similar divisions occur in convert, convertTokenWeiToAMG, and convertFromUSD5, all without explicit zero guards.
Impact
Any execution path that touches these conversions will revert when a feed returns zero, temporarily halting protocol functions that rely on them. Examples include:
- Liquidation health checks and ratio reads via
currentAmgPriceInTokenWeiWithTrusted(can break liveness of safety mechanisms during oracle disturbances). - Core-vault calculations (
_minimumRemainingAfterTransferForCollateralAMG) that convert collateral using a price which could be zero.
Recommended Mitigation Steps
-
Add explicit zero-price guards at the division sites: In
calcAmgToTokenWeiPrice,convert,convertTokenWeiToAMG, andconvertFromUSD5, check the divisor:if (_tokenPrice == 0) revert PriceIsZero(); // or, if preferred, return 0 and let callers handle it.- Use clear custom errors (e.g., error PriceIsZero();) so revert reasons are diagnosable.
- Prefer graceful handling in read paths: Where feasible, return a sentinel (e.g., 0) and let callers decide policy (pause certain actions, fall back to trusted price, etc.), while still avoiding division by zero. This preserves observability/liveness for off-chain keepers.
-
Strengthen trusted-price fallback: In
currentAmgPriceInTokenWeiWithTrusted, if the primary price is zero, prefer a fresh, non-zero trusted price instead of proceeding to a division that can revert.This ensures the protocol remains robust against edge-case oracle values: even if a feed reports 0, the system won’t suffer unintended reverts or loss of liveness.
[05] Outdated comment in PaymentConfirmations: “verified payments expire in 5 days” (they don’t)
Finding Description and Impact
The state comment in PaymentConfirmations says “verified payment hashes; expire in 5 days,” but the implementation never expires or deletes entries:
confirmIncomingPayment/confirmSourceDecreasingTransactionboth call_recordPaymentVerification, which setsverifiedPayments[_txKey] = _txKeyand never schedules or performs removal.- There is no read/write path that touches the per-day linked-list placeholders (
__verifiedPaymentsForDay,__verifiedPaymentsForDayStart) to enforce or perform expiry.
As a result, entries persist indefinitely.
Recommended Mitigation Steps
- Update the comment to accurately reflect current behavior (no expiry).
- Mark legacy per-day fields as deprecated in comments, or remove them if safe.
[06] SafePct.mulDiv fallback can revert on non-overflowing results (intermediate overflow), violating function contract
Finding Description and Impact
The fallback branch of SafePct.mulDiv(x, y, z) is intended to avoid 256-bit intermediate overflow by decomposing:
-
x = a*z + b, y = c*z + d, with 0 ≤ b,d < zand returning:(a * c * z) + (a * d) + (b * c) + (b * d / z)
While the first three terms are safe under the function’s contract (they cannot overflow when the final result fits), the last term computes b * d before dividing by z. That 256-bit multiplication can overflow even when floor(x*y/z) is well within uint256.
A concrete counter-example:
- Let z =
2^200,x = z - 1,y = z - 1. - The exact result is
floor((z−1)^2 / z) = z − 2, which comfortably fits in 256 bits. - The initial unchecked
{ xy = x*y; }overflows, so the code falls back. - In the fallback,
a = c = 0,b = d = z−1, and it evaluates(b*d)/z.
But b*d ≈ 2^400, which overflows 256-bit multiplication and reverts, despite the final quotient (z−2) being valid.
This contradicts the function’s docstring promise (“reverting on overflow, but only if the result overflows”). Any callers relying on that contract can revert unexpectedly under high-magnitude inputs, creating a latent DoS/correctness risk. All downstream uses of mulDiv/mulDivRoundUp inherit the same behavior.
Recommended Mitigation Steps
Use a full-precision (512-bit) mul-div implementation for the risky term, or for the whole operation:
- Replace the fallback with a proven 512-bit routine.
-
Then implement round-up as:
uint256 q = Math.mulDiv(x, y, z); if (mulmod(x, y, z) != 0) q += 1; - If you keep the decomposition, compute
(b*d)/zvia a 512-bit path (not a rawb*d), e.g. usingMath.mulDiv(b, d, z).
Mitigation Review
Introduction
Following the C4 audit, 3 wardens (givn, farman1094 and rvierdiiev) reviewed the mitigations for all identified issues. Additional details can be found within the C4 Flare Mitigation Review repository.
Mitigation Review Scope & Summary
During the mitigation review, the wardens confirmed that all in-scope findings were mitigated. The table below provides details regarding the status of each in-scope vulnerability from the original audit.
| Original Issue | Status | Mitigation URL |
|---|---|---|
| M-01 | 🟢 Mitigation Confirmed | Commit e7723fa08 |
| M-02 | 🟢 Mitigation Confirmed | Commit 1d389c2f8 |
| M-03 | 🟢 Mitigation Confirmed | Commit a70e82119 |
Disclosures
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.