For the complete documentation index, see llms.txt. This page is also available as Markdown.

88551 sc low the capacity guard does not bound vault quantity drift

Submitted on Aug 13th 2026 at 19:03:19 UTC by @whitehorse for Audit Comp | Firelight

  • Report ID: #88551

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/CoverOrderAllocator.sol

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

CoverOrderAllocator._validateMatchingCapacity is the onchain bound on the capacity figure the offchain matching engine submits, widened by divergenceToleranceBps to absorb drift between the engine's snapshot and commit inclusion. It reads two of its three collateral inputs live and the third, vault collateral, at totalAssetsAt(currentPeriodStart()). Collateral leaving the vault mid period is therefore invisible to the bound at any magnitude, and the accepted figure is then pinned into the commitment and consumed by settlement without being requeried. Cover binds against collateral the vault no longer holds, at real backing below the floor the contract declares for its own configuration bounds.

Vulnerability Details

_computeAvailableCapacity builds the period ceiling from three collateral terms:

// CoverOrderAllocator.sol:700 to :715
uint256 assetPriceUSD = $.priceFeedAdapter.getPrice($.maxPriceAge);          // live
uint256 firstLossBufferUSDCanonical = Decimals.convert(
    config.firstLossBufferToken.balanceOf(config.firstLossBuffer), ...       // live
);
uint256 totalAssetsCanonical = Decimals.convert(
    $.vault.totalAssetsAt($.vault.currentPeriodStart()), ...                 // period start
);

totalAssetsAt resolves through Checkpoints.upperLookupRecent, which returns the last key lower than or equal to the search key. Checkpoints.push appends at a strictly greater key or overwrites the tail on an equal one, and pop has no caller in the tree. The read therefore reflects the vault as of the period's first second, and payout (FirelightVault.sol:952), _logTrace (:1055) and checkpointTotalAssets (:525) all write strictly later keys that it cannot reach.

Two orderings reach the defect.

Payout before commit. The engine runs step 4 of the matching flow documented in ICoverOrderAllocator.sol, which reads live vault.totalAssets(), and submits the figure that was true when it ran. A payout lands before inclusion. An equivalent fall in the price or buffer leg is detected and rejected; this one is not, because the vault comparand cannot move.

Payout after commit. commitAllocation pins the accepted figure at :352. _computeAvailableCapacity has one call site, :400 inside _validateMatchingCapacity. _settleCoverOrder (:495 to :520) never reaches it and scales the per protocol concentration caps at :506 to :516 off the stored value. In this ordering the engine cannot have observed the payout at any point.

recommitAllocation reenters the same read, so the assurance at :375 to :377 that a recommit "stays bound to the period's real collateral within tolerance" does not hold across a payout.

Recommended fix, reading the vault leg live like its two siblings:

That closes the commit path only. Settlement needs its own live floor before the concentration caps, min(commit.totalAvailableCapacity, _capacityFromCollateral($, $.vault.totalAssets())), or the second ordering survives the fix.

Impact Details

Selected impact: "Contract fails to deliver promised returns, but doesn't lose value."

MAX_LEVERAGE_FACTOR (:79 to :82) declares real backing behind sold cover floored at 1 / (MAX_LEVERAGE_FACTOR × (1 + tolerance)) "regardless of the configured minCAR", which is 18.18% at the contract's own ceilings. Vault quantity drift falsifies that floor.

Measured at the contract's permitted maxima, with a collateral loss of 30% of the vault's USD value:

Quantity
Value

capacity submitted by the matching engine

$293,231,863

guard ceiling applied, unchanged by the payout

$322,555,050

guard ceiling the remaining collateral supports

$227,438,535

admitted above what remaining collateral supports

$65,793,328

real backing behind the settled cover

14.10% against a declared floor of 18.18%

The tolerance the other two legs are held to is 10% and the loss applied here is 30%, so a bounded leg rejects it. The price leg does reject it, on identical collateral. Paying the vault leg out in full leaves backing at 0.34% with the ceiling unmoved, which establishes the drift as unlimited rather than merely oversized.

The under delivered promise lands on the cover purchasers settling in that window: they hold policies backed below the declared floor and pay the premium computed at :495 for cover priced as though it held.

No funds are lost. The defect admits and settles a commitment, it does not move assets, cover already settled is unaffected, and the next period start snapshot restores the correct basis. No role acts outside its documented authority.

References

  • contracts/core/CoverOrderAllocator.sol:711, vault collateral read at the period start key

  • contracts/core/CoverOrderAllocator.sol:75 to :82, the tolerance and the declared backing floor

  • contracts/core/CoverOrderAllocator.sol:328 to :330, the guard's documented purpose

  • contracts/core/CoverOrderAllocator.sol:352, the accepted ceiling pinned into the commitment

  • contracts/core/CoverOrderAllocator.sol:394 to :403, _validateMatchingCapacity

  • contracts/core/CoverOrderAllocator.sol:495 to :520, settlement consuming the stored ceiling

  • contracts/core/interfaces/ICoverOrderAllocator.sol, step 4 of the offchain matching flow

  • contracts/core/FirelightVault.sol:952, the payout checkpoint push

  • contracts/core/lib/Checkpoints.sol:221 to :224, upperLookupRecent

Proof of Concept

poc_unbounded_vault_leg_divergence.js, four tests, all passing on unmodified source. Build and run steps, parameters and per test expected output are in the file header.

Tests 1 and 2 route the same 30% collateral loss through the vault leg and the price leg: identical remaining collateral, identical honest ceiling of $227,438,535, accepted versus reverted MatchingCapacityOverflow. Test 3 is the unbounded case. Test 4 is the settle path ordering, where the concentration cap holds at $175,939,118 against $124,057,382 of real capacity and cover settles at 14.10% backing.

An isolation control patches line 711 alone to $.vault.totalAssets(). Tests 1 and 3 then revert, test 2 is unchanged, and test 4 passes identically, which is the evidence that the one line change is necessary and not sufficient.

The vault is the real FirelightVault behind its proxy, not MockCoverOrderAllocatorVault. Capacity is read without adding a getter, from the maxCapacity argument of the MatchingCapacityOverflow revert. Phase 2 is not deployed, so the tests run against the in scope source at the competition commit on the program's mandated harness.

Proof of Concept

Was this helpful?