Skip to content

Latest commit

 

History

History
528 lines (321 loc) · 48.2 KB

File metadata and controls

528 lines (321 loc) · 48.2 KB

Contracts vulnerabilities

Vulnerabilities list

Involved contracts and level of the bugs

The present document describes issues affecting Tokenomics contracts.

Vulnerabilities

1. depositServiceDonationsETH function (services state)

Severity: Low

The following function is implemented in the Treasury contract:

function depositServiceDonationsETH(uint256[] memory serviceIds, uint256[] memory amounts) external payable

This service donating function calls another function from the Tokenomics contract that ultimately results in calling the internal function _trackServiceDonations(). The latter one checks whether agent and component Ids of each of the passed service Id exist, and if not, reverts with the ServiceNeverDeployed() error. The error arises from the fact that the service was never deployed, and its underlying component and agent Ids were not assigned (the assignment of underlying component and/or agent Ids to a service happens during the deployment of the service itself).

However, after a specific service is deployed at least once and then terminated, it can be updated and re-deployed again. In particular, the service can be updated with a different set of agent Ids, making the donation distribution setup invalid for the following reason. If this updated service receives a donation before it is re-deployed, the donation will be distributed between its old component and agent Ids owners and not the new ones.

Therefore, donating to an updated service before its redeployment can affect the correct distribution of rewards in the Tokenomics contract. We recommend not to donate when a service is not in the Deployed or TerminatedBonded state (e.g. any service with serviceIds[i] not in Deployed or TerminatedBonded state must not be passed as input parameters to the function depositServiceDonationsETH). The state of the service can be easily checked via the ServiceRegistry contract view function getService(uint256 serviceId).

2. depositServiceDonationsETH function (OLAS incentives)

Severity: Informative

The following function is implemented in the Treasury contract:

function depositServiceDonationsETH(uint256[] memory serviceIds, uint256[] memory amounts) external payable

If a DAO member, holding the veOLAS threshold1, uses this method to donate ETH to a specific service, or if the service owner is a DAO member holding the veOLAS threshold2, the owners of the agents and components referenced in that service are entitled to receive a share of the donation and OLAS top-ups generated through inflation.

While the current approach encourages service registration and donations through the utilization of all available OLAS each epoch, this might be utilized in a counter-intended way by malicious donators or malicious service-owners. If a donator (or the service-owner) owns all the underlying components and agents, meets the sufficient veOLAS requirement, and makes only a small donation to their service, they could accrue a significant number of OLAS tokens through inflation top-ups at a low cost. This behavior may yield considerable gains initially but becomes less profitable as more major players utilize the protocol, leading to more donations being distributed among multiple services and stakeholders.

3. deposit method

Severity: High

In the depository contracts, the following method is implemented:

function deposit(uint256 productId, uint256 tokenAmount) external

This method allows users to deposit tokens, acquiring OLAS tokens at a discounted rate. A potential concern can arise ten years after OLAS token launch in the case of an epoch crossing into year intervals. In this scenario, a portion of OLAS becomes mintable only in the eleventh year, as a result of the 1 billion fixed supply constraint for the initial ten years.

The creation of bonding programs with payouts leading to exceeding the total OLAS supply mintable before ten years and the bonder's depositing the full amount expecting these payouts lead to a silent return in the OLAS mint() method and not a revert. This results in successful product deposit and a consequent loss of OLAS payouts for bonders.

To address this, a more specific check for epoch crossing year intervals can be integrated into the tokenomics checkpoint() method. In the absence of redeploying a new contract, it is recommended to carefully propose the creation of bonding programs at the end of the tenth year. These programs should be structured ensuring that the payouts are designed to keep the total amount of OLAS minted below 1 billion OLAS before the ten-year mark. This precautionary measure prevents eventual lost OLAS payouts.

4. checkpoint method - cross-year

Severity: Informative

In the tokenomics contracts, the following method is implemented:

function checkpoint() external

This method allows users to deposit tokens, acquiring OLAS tokens at a discounted rate. A potential concern may arise in the event of an epoch crossing into year intervals, where a portion of OLAS larger than the year inflation limit becomes mintable.

The creation of bonding programs with payouts leading to an excess of the total OLAS supply mintable before the specified year and the bonder depositing the full amount may result in an amount of minted OLAS exceeding the year inflation limit. It's crucial to note that, at most, only the amount reserved for the remaining time of the epoch from the following year can be minted.

To address this, a more specific check for epoch crossing year intervals can be integrated into the tokenomics checkpoint() method. In the absence of redeploying a new contract, it is recommended to carefully propose the creation of bonding programs for epoch-crossing years. These programs should be structured to ensure that the payouts are designed in a manner that keeps the total amount of OLAS minted below the year inflation limit.

5. Treasury Fund Token Management

Severity: Informative

By design, within the Treasury contract, there is currently no mechanism in place to facilitate the removal of tokens other than ETH that have not been added to the Treasury through the treasury depositTokenForOLAS() method.

Therefore, we strongly recommend refraining from transferring funds directly to the Treasury contract that does not adhere to the established tokenomics logic. This precautionary measure will help prevent potential freezing of funds within the Treasury contract.

6. Encoded inflation schedule

Severity: Informative

If donors in a given epoch fail to meet the veOLAS threshold for donating ETH to specific services within 10 years of OLAS token creation, the reserved OLAS inflation for top-ups remains inactive. Although accounted for in the inflation schedule of that epoch, that amount is essentially deducted from the inflation schedule. For instance, if x OLAS were accounted for in the inflation for top-ups during the inaugural tokenomics epoch but no donator meets the veOLAS threshold, these top-ups cannot be utilized for subsequent epochs encoded in the 10-year inflation schedule.

A similar scenario can occur when OLAS top-ups and staking incentives are distributed. Due to the natural rounding behavior of Solidity and the division involved in calculating top-ups and staking emissions, it's possible that the actual sum of OLAS allocated to owners of agents and components referenced in donated services and the calculated staking emissions might be slightly less than the exact amount that can be extracted from the encoded inflation schedule in the tokenomics contract. In such cases, the difference between the exact amount and the actually allocated amount for top-up and staking is implicitly deducted from the inflation schedule.

This deferred inflation isn't lost; rather, it's postponed, as the OLAS token ensures that no more than 1 billion tokens are minted within a decade, with no more than 2% of the supply cap being minted annually, starting from 1 billion.

7. Withheld tokens

Severity: Informative

The TargetStakingDispenser contract on L2 withholds some staking emissions sent by L1 (see the section "Verification on staking contract enabled by StakingVerifier" here for details on the tokens withheld by the TargetStakingDispenser).

To prevent L1 from sending new emissions while there are still withheld emissions on the TargetStakingDispenser, we need to ensure regular synchronizations between L1 and L2. Specifically, if there is demand for emissions for a specific contract on L2, and L1 is synchronized with the withheld amount on the TargetStakingDispenser, L1 will only send a message without minting or sending new emissions to the L2 target contract until the withheld amount is fully utilized and additional demand arises.

Additionally, if there is no new demand for emissions from the L2 target dispenser and a withheld amount remains, the DAO can initiate a new staking campaign to utilize the withheld amount.

Finally, the DAO can employ the combination of the functions migrate(), syncWithheldAmount(), processDataMaintenance(), updateWithheldAmountMaintenance() to transfer and update balance of the withheld tokens to a DAO-controlled account.

8. changeManagers function (specifically - voteWeighting)

Severity: Informative

The following function is implemented in the Dispenser contract:

function changeManagers(address _tokenomics, address _treasury, address _voteWeighting) external

The purpose of this function is to change core tokenomics contract addresses. However, when the Vote Weighting contract address is changed, if not all the staking incentives are claimed, those can be lost. The idea is to force claim all the staking incentives before the voteWeighting is updated. More details here.

9. claimStakingIncentives / _calculateStakingIncentivesBatch functions

Severity: Low

Following functions is implemented in the Dispenser contract:

function claimStakingIncentives(uint256 numClaimedEpochs, uint256 chainId, bytes32 stakingTarget, bytes memory bridgePayload) external payable

function _calculateStakingIncentivesBatch(uint256 numClaimedEpochs, uint256[] memory chainIds, bytes32[][] memory stakingTargets) internal returns (uint256[] memory totalAmounts, uint256[][] memory stakingIncentives, uint256[] memory transferAmounts)

The purpose of these functions is to calculate staking incentives and returns according to the staking target provided. However, these functions do not account for the fact that the amount of OLAS previously sent to L2 and communicated as not used and available for re-usage (withheldAmount) should also be subtracted from the staking incentives amount in favor of amounts returned back to Tokenomics. This means that staking incentives amounts that are reused from withheld ones are calculated as subject to inflation used, whereas in fact that part of inflation is untouched. Ultimately it results in spending less inflation throughout the inflation period for the amount of funds that were minted but withheld on L2 target dispenser contracts as over-excessive.

Note that the inflation amount is not returned to Tokenomics due to withheldAmount reuse is never minted, meaning there is no loss of funds, just the inflation miscalculation lowering its yearly mint possibility. In the absence of redeploying a new contract, the DAO might act to adjust the inflation numbers in a distant timeline consolidating information about all the withheld amounts across chains.

10. migrate function

Severity: Low

The following function is implemented in the TargetDispenserL2 contract:

function migrate(address newL2TargetDispenser) external

The purpose of this function is to migrate all the funds to a new L2TargetDispenser address. However, this function does not check if the current withheldAmount value is zero before migrating, essentially having the possibility to lose the inflation information for not sending additional funds to L2.

In order to avoid the loss of withheldAmount, the DAO is advised to update the value with the updateWithheldAmountMaintenance() function call right after the TargetDispenser migration procedure is complete.

11. _sendMessage function

Severity: Low

The following function is implemented in the OptimismDepositProcessorL1 contract:

function _sendMessage(address[] memory targets, uint256[] memory stakingIncentives, bytes memory bridgePayload, uint256 transferAmount, bytes32 batchHash) internal override returns (uint256 sequence, uint256 leftovers)

This function forms required data to send tokens and messages to L2 in all the optimism deposit processor related contracts. A user-controlled gas limit is decoded as a uint256, which is later truncated to uint32 when passed to the CrossDomainMessenger. If a user supplies a payload with a value exceeding type(uint32).max, the truncation produces a much smaller gas limit than intended, bypassing the protocol's minimum gas check.

Although this action does not result in loss of funds (which are sent separately), it could deliberately pass a smaller amount of gas such that a corresponding function on L2 reverts. This can then be corrected via the processDataMaintenance() function. In the absence of contract re-deployment, users are advised to pass a sufficient amount of gas, or just have it set to zero, such that the fallback value takes care of it.

12. calculateStakingIncentives function (public state-mutating call bricks zero-weight epoch refund)

Severity: High Source: Code4rena 2026-01 Olas audit (submission #S-907)

The following function is implemented in the Dispenser contract:

function calculateStakingIncentives(uint256 numClaimedEpochs, uint256 chainId, bytes32 stakingTarget, uint256 bridgingDecimals) public returns (uint256 totalStakingIncentive, uint256 totalReturnAmount, uint256 lastClaimedEpoch, bytes32 nomineeHash)

The function is public and state-mutating. During iteration, if totalWeightSum == 0, it writes a one-way flag mapZeroWeightEpochRefunded[j] = true, but the function itself does not execute the actual refund (Tokenomics.refundFromStaking). Since future callers skip epochs where this flag is set, any external caller can permanently mark a zero-weight epoch as "refunded" without the refund ever being performed, effectively bricking the staking refund for that epoch.

In order not to re-deploy the contract, the protocol just needs to delegate a minimal (0.01%) vote for a specific staking contract, such that the totalWeight is never zero.

Source code: Dispenser.sol

13. updateInflationPerSecondAndFractions function (effectiveBond reset)

Severity: Informative Source: Internal audit 14

The following function is implemented in the Tokenomics contract:

function updateInflationPerSecondAndFractions(uint256 _inflationPerSecond, uint256 _maxBondFraction, uint256 _topUpComponentFraction, uint256 _topUpAgentFraction, uint256 _stakingFraction) external

This owner-only function resets effectiveBond to just curMaxBond (the current epoch's bond allocation), discarding the accumulated leftover bond capacity from all prior epochs. In contrast, checkpoint() uses the additive pattern curMaxBond += effectiveBond before assigning. On the deployed contract, this would reduce effectiveBond from 5.66M OLAS to 484K OLAS (a 91% drop), temporarily limiting new bond product creation until capacity rebuilds via subsequent checkpoint() calls (~242K OLAS per epoch).

This is not externally exploitable: the function is restricted to the contract owner (Timelock = DAO governance). The reset direction is conservative -- it under-counts available bond capacity, never over-counts -- so no OLAS can be over-minted. The DAO must ensure that all bonding products are closed before calling updateInflationPerSecondAndFractions(), so that no outstanding product supply exceeds the reset effectiveBond. The effectiveBond rebuilds naturally through subsequent checkpoint() calls.

Source code: Tokenomics.sol

14. BalancerPriceOracle.updatePrice flash-loan steerability within minUpdateInterval

Severity: Medium — accepted residual Source: Internal audit 15 (M-02) / C4A 2026-01 H-03 (partial) Status: Acknowledged — no code change; track via monitoring

BalancerPriceOracle.updatePrice() reads spot balances from the Balancer Vault once per minUpdateInterval and commits them as the new observation. Within that window, a flash-loan move that happens to coincide with the update is committed to state — the commit-on-success pattern (which fixed the rejected-update corruption from C4A H-11) does not reject the adversarial sample because getPrice() returns non-zero on the manipulated balance.

Mitigations in place:

  • updatePrice() is rate-limited via minUpdateInterval, so at most one spot sample per window can land.
  • getTWAP() enforces maxStaleness on lastObservation, so obviously-old data is rejected downstream.
  • buyBack(...) is the only on-chain consumer of the TWAP on the V2 path; V3 uses a separate TWAP source.

Residual risk: within any single minUpdateInterval, a well-timed flash-loan move into the Balancer pool can still commit a skewed sample. The fix would be architectural (swap oracle source to Vault-on-swap callbacks or a different TWAP primitive) rather than a small code edit — not planned for this PR.

Mitigation plan: off-chain monitoring of ObservationUpdated events against moving-average sanity bands, alert + pause on deviation beyond the configured maxSlippage. Escalates to High if updatePrice ever becomes permissionlessly callable with a tighter cadence, or if buyBack volumes scale to the point where flash-loan damage per window crosses a material threshold.

Source code: BalancerPriceOracle.sol

15. LiquidityManagerCore.convertToV3 front-run via permissionless collectFees

Severity: Low Source: Code4rena 2026-01 Olas audit (L-02) — tracked forward as internal audit 15 (L-03)

The following functions are implemented in the LiquidityManagerCore contract:

function convertToV3(address[] memory tokens, bytes32 v2Pool, int24 feeTierOrTickSpacing, int24[] memory tickShifts, uint16 olasBurnRate, bool scan) external
function collectFees(address[] memory tokens, int24 feeTierOrTickSpacing) external

convertToV3() expects tokens to be transferred to the contract before the call and consumes the current balance. collectFees() is permissionless and, via _manageUtilityAmounts(tokens, MAX_BPS, true), burns all OLAS held by the contract. A keeper that stages a direct OLAS transfer and then calls convertToV3 in a separate transaction can be front-run by an attacker who calls collectFees between the two txs, burning the staged OLAS before it is paired into V3 liquidity.

Exposure: owner-gated conversion flow + permissionless fee collection. The realized risk is low when the operator avoids the "bare direct transfer → convertToV3" pattern; staging OLAS inside the same tx that calls convertToV3 defuses the race. Document in the admin playbook; the preferred architectural fix (atomic transfer-and-convert path, or a conversion-in-flight flag that skips collectFees OLAS burn) is out of scope for internal audit 15's low bundle.

Source code: LiquidityManagerCore.sol

16. LiquidityManagerCore slippage derived from spot-derived amounts in _increaseLiquidity / _decreaseLiquidity

Severity: Low Source: Code4rena 2026-01 Olas audit (L-04) — tracked forward as internal audit 15 (L-04)

The following internal helpers are implemented in the LiquidityManagerCore contract:

function _increaseLiquidity(address pool, uint256 positionId, uint256[] memory inputAmounts) internal
function _decreaseLiquidity(address pool, uint256 positionId, uint16 decreaseRate) internal

Both helpers compute amountsMin[i] = amounts[i] * (MAX_BPS - maxSlippage) / MAX_BPS using amounts derived from slot0 (_getPriceAndObservationIndexFromSlot0), not from the TWAP-derived sqrt price. Even though changeRanges / convertToV3 apply the TWAP deviation guard via checkPoolAndGetCenterPrice separately, the slippage math here is anchored to the instantaneous price. Realized worst-case slippage in an admin-initiated op can therefore stack up to maxSlippage + ±MAX_ALLOWED_DEVIATION (the deviation band).

Exposure: admin-only surface (onlyOwner via convertToV3 / changeRanges / increaseLiquidity / decreaseLiquidity). The realized risk is low in normal DAO-paced operations, but increases the MEV window on owner-initiated liquidity operations. The architectural fix — use the TWAP-derived center price as the anchor for amountsMin, then apply maxSlippage — is out of scope for internal audit 15's low bundle.

Source code: LiquidityManagerCore.sol

17. changeRegistries can lock pending user incentives

Severity: Low Source: Code4rena 2026-01 Olas audit (L-06)

The following function is implemented in the Tokenomics contract:

function changeRegistries(address _componentRegistry, address _agentRegistry, address _serviceRegistry) external

changeRegistries() updates registry pointers without a pre-condition that pending owner incentives have been claimed. accountOwnerIncentives() verifies unit ownership against the current registry addresses, so component/agent owners that accrued incentives under the previous registry can lose access to those incentives if a registry swap happens before they claim.

Disposition: not planned. The function is owner-gated (Timelock / DAO governance), and the operational workflow is to ensure all outstanding incentives have been claimed before registries are rotated. A migration-preserving implementation is out of scope for this cycle — documented here so the DAO operations playbook tracks it.

Source code: Tokenomics.sol

18. _trackServiceDonations precision loss via integer division

Severity: Low Source: Code4rena 2026-01 Olas audit (L-09)

The following internal function is implemented in the Tokenomics contract:

function _trackServiceDonations(address donator, uint256[] memory serviceIds, uint256[] memory amounts, uint256 donationETH) internal

Per-service donation is split across the service's numServiceUnits component/agent owners via integer division:

uint96 amount = uint96(amounts[i] / numServiceUnits);

When amounts[i] % numServiceUnits != 0, the remainder is truncated. Aggregated across every donation event each service ever receives, owners collectively receive a few wei less than the donated amount; the lost dust is not credited anywhere and does not inflate effectiveBond.

Disposition: not planned. The loss per event is bounded by numServiceUnits − 1 wei (single-digit wei for realistic service sizes), does not accumulate into any exploitable protocol state, and the distribution codepath is on the fading-out Tokenomics donation surface. Documented for completeness; no code change.

Source code: Tokenomics.sol

19. checkpoint permanently unusable after MAX_EPOCH_LENGTH without a call

Severity: Low Source: Code4rena 2026-01 Olas audit (L-13)

The following function is implemented in the Tokenomics contract:

function checkpoint() external returns (bool)

If checkpoint() is not called for a duration that exceeds MAX_EPOCH_LENGTH from the last settled checkpoint, subsequent calls can land in a state where arithmetic based on block.timestamp - prevEpochTime exceeds the limits embedded in the epoch accounting, permanently wedging the checkpoint advancement path on the live proxy. Recovery would require a Tokenomics implementation upgrade.

Disposition: not planned for this audit cycle — the code path is entangled with enough of the checkpoint() accounting that landing a surgical fix here without broader refactor risk was deemed not worth the effort. Operationally mitigated by: (a) the DAO's existing keeper cadence, which calls checkpoint() well within MAX_EPOCH_LENGTH; (b) monitoring alerts on missed checkpoint windows. Documented so a future Tokenomics refactor that opens this code path can bundle the fix.

Source code: Tokenomics.sol


20. BuyBackBurner V3 path is per-chain optional (post-internal15 follow-up)

Severity: Notes / operational Source: Internal follow-up to PR #272 (restore-v3-bbb) deployment audit

The BuyBackBurner constructor (contracts/utils/BuyBackBurner.sol) takes four addresses: _liquidityManager, _bridge2Burner, _treasury, _swapRouter. Prior to this change, all four were checked non-zero. That blocked V2-only chain deployments (gnosis, polygon, arbitrum) where no Uniswap V3 / Slipstream router exists, and forced operators to deploy a LiquidityManager upfront on every chain even if V3 was not in scope.

Resolution. _liquidityManager and _swapRouter are now optional — pass address(0) to deploy an implementation with the V3 path disabled. _bridge2Burner and _treasury remain required. A new error V3PathDisabled() is reverted by every V3-touching surface when either V3 immutable is unset:

  • buyBack(address, uint256, uint256) (auto-routes V3 first when mapV3Pools[token] != 0, V2 fallback otherwise; post-PR #280 the V3 4-arg overload is removed)
  • _buyOLAS(address, uint256, address) (V3 internal — defense in depth; takes the canonical pool, fee/tickSpacing read from it)
  • setV3Pools(address[] secondTokens, address[] pools) — pool whitelisting is meaningless without a swap path (renamed from setV3PoolStatuses in PR #280)
  • checkPoolPrices(...) — gates on liquidityManager == address(0) only since swapRouter is not on its read path

The V2 path (buyBack(address, uint256, uint256)) and admin setters (setV2Oracles, setMaxSlippages, changeOwner, transferToken, updateOraclePrice, changeImplementation) are unaffected. setMaxSlippages is intentionally ungated because mapTokenMaxSlippages is read by both V2 and V3 paths.

To enable V3 on a chain that initially deployed without it: deploy a new BuyBackBurnerUniswap / BuyBackBurnerBalancer implementation with non-zero _liquidityManager and _swapRouter, then call changeImplementation on the proxy. Immutables are encoded in bytecode, so the new impl swap atomically enables the V3 path. Storage maps mapV3Pools and mapTokenMaxSlippages survive the upgrade.

Source code: BuyBackBurner.sol Tests: BuyBackBurnerV3Disabled.t.sol — 19 unit tests covering constructor relaxation, all four guarded surfaces, and V2/admin sanity.


21. Depository OLAS transfer return value not checked

Severity: Notes / code hygiene Source: Internal audit 16 (INFO-2)

The following call is made inside the Depository payout path:

IToken(olas).transfer(msg.sender, payout);

The boolean return value is not checked. A non-standard ERC20 implementation that returned false instead of reverting on failure would silently let the call succeed without transferring the payout.

Disposition: not planned. The token argument here is the canonical OLAS contract — a standard revert-on-failure ERC20 with no path that returns false from transfer. The risk is theoretical, not realistic. A future refactor to use SafeTransferLib.safeTransfer would normalize the call style across the codebase; documented for completeness.

Source code: Depository.sol


22. Bridge2BurnerOptimism TOKEN_GAS_LIMIT hardcoded

Severity: Notes / operational Source: Internal audit 16 (L-NEW-1)

Bridge2BurnerOptimism passes a constant TOKEN_GAS_LIMIT = 300_000 to withdrawTo as the L1 receive-side gas budget:

uint32 public constant TOKEN_GAS_LIMIT = 300_000;
// ...
IBridge(l2TokenRelayer).withdrawTo(olas, OLAS_BURNER, olasAmount, TOKEN_GAS_LIMIT, "0x");

If OLAS_BURNER (the L1 receiver) were ever upgraded to a contract whose receive-side transfer / mint callback exceeded 300 K gas, the bridged messages would fail at L1 finalization.

Disposition: not planned. The current OLAS_BURNER L1 receive footprint is well under 300 K and there is no roadmap that would push it higher. Failed L1 messages are not value-loss events: they can be replayed with more gas through the standard Optimism bridge replay mechanism. Documented as the explicit assumption "OLAS_BURNER L1 receive < 300 K gas". A setter would only be revisited if OLAS_BURNER is ever materially refactored.

Source code: Bridge2BurnerOptimism.sol


23. setV2Oracles and setV3Pools are not mutually exclusive

Severity: Notes / operational Source: Internal audit 16 (INFO-1)

After the L-06 reshape, both mapV2Oracles[token] and mapV3Pools[token] can be set non-zero for the same token. Auto-routing in buyBack(secondToken, ...) prefers V3 when mapV3Pools[secondToken] != address(0), so a stale V2 oracle entry is silently unreachable while a V3 pool is configured. The combined transfer() rescue gate handles either-or-both states correctly (V2-non-zero alone is sufficient to revert), so no fund-loss path opens up.

Disposition: not planned for code. Both setters are owner-only and the security gate is symmetric. The footgun is operational only — a stale V2 oracle entry can mislead an off-chain reader of mapV2Oracles into thinking V2 is the active path when V3 has taken over. Operational runbook: when migrating an existing V2-oracle token to V3, explicitly call setV2Oracles(token, address(0)) before setV3Pools(token, pool) to keep on-chain state matching off-chain intent.

Source code: BuyBackBurner.sol


24. Tokenomics M-09 effectiveBond saturating subtraction at year boundaries

Severity: Notes / accepted residual Source: Internal audit 16 (post-fix design note around C4R M-09)

The fix for C4R M-09 (downward inflation correction at year boundaries) introduces a saturating subtraction:

effectiveBond = (effectiveBond > overCredited) ? uint96(effectiveBond - overCredited) : 0;

When overCredited > effectiveBond — i.e., users have already bonded against more capacity than the post-correction inflation would allow — the residual is floored at zero rather than carried forward as future debt. The "lost" residual represents bonds already minted as OLAS to bond holders during the over-credited period; those bonds cannot be retroactively unminted.

Disposition: intentional. The alternative — carry-forward residual debt that suppresses future epochs' bond capacity — would penalize future periods for past inflation transitions and require a perpetual bookkeeping field to track the unwind. The exposure window is bounded to year-boundary downward-inflation transitions: Y2 → Y3 (already past at 2025-06-30; phantom capacity already realized on the live 0xc096…ce300 proxy under the pre-fix code) and Y9 → Y10 (still ahead — protected by the fix once the redeploy lands). Realized impact at each boundary is small — a one-time minor over-issuance bounded by the difference between old- and new-inflation rates over the transition epoch — and not exploitable for ongoing extraction.

Source code: Tokenomics.sol


25. Dispenser mapRemovedNomineeEpochs not cleared on addNominee (two-contract invariant coupling)

Severity: Informative — defense-in-depth observation Source: Cross-cycle dispatch audit 2026-05-11

The following functions are implemented in the Dispenser contract:

function addNominee(bytes32 nomineeHash) external
function removeNominee(bytes32 nomineeHash) external

removeNominee() sets mapRemovedNomineeEpochs[nomineeHash] = eCounter and that slot is never cleared elsewhere. addNominee() re-initializes mapLastClaimedStakingEpochs[nomineeHash] to the current epoch counter but does not clear mapRemovedNomineeEpochs[nomineeHash]. Consequently, the Dispenser in isolation contains a latent failure mode: if the same nomineeHash were ever re-added after removal, _checkpointNomineeAndGetClaimedEpochCounters() would revert with Overflow(firstClaimedEpoch, epochRemoved - 1) on every claim attempt during the second lifecycle (the slot from the first lifecycle still satisfies firstClaimedEpoch >= epochRemoved).

The path is unreachable on the deployed system because the safety invariant lives in the sister governance contract VoteWeighting._addNominee(), which reverts on both mapNomineeIds[nomineeHash] > 0 (already added) and mapRemovedNominees[nomineeHash] > 0 (previously removed). "Remove is final" is enforced upstream, so the Dispenser slot can never be exercised under the second-lifecycle scenario.

Disposition: not planned for code. The Dispenser is a deployed proxy implementation that cannot be modified without a redeploy. The invariant coupling is recorded here so that future maintainers know:

  • Dispenser.changeManagers(_voteWeighting, ...) (owner-gated) makes voteWeighting mutable; repointing to a VoteWeighting that does not enforce "remove is final" would make this path reachable. Issue #8 already flags the broader operational risk of swapping voteWeighting.
  • A future Dispenser variant (e.g., for a new chain or a refactor) must either (a) clear mapRemovedNomineeEpochs[nomineeHash] inside addNominee, or (b) explicitly assume the upstream VoteWeighting guarantees "remove is final" and document the assumption.
  • Failure mode is a hard revert, not a silent zero-claim — funds accrued under the first lifecycle remain accessible via the original claim window and cannot be silently stranded.

Source code: Dispenser.sol


26. LiquidityManagerCore.checkPoolAndGetCenterPrice fail-open on stale-observation / inactive pools

Severity: Low — acknowledged; code fix planned Source: Internal triage (2026-06)

The following internal helper is implemented in the LiquidityManagerCore contract:

function checkPoolAndGetCenterPrice(address pool) internal returns (uint160 sqrtP)

checkPoolAndGetCenterPrice derives a 30-minute TWAP "center" price from the Uniswap V3 oracle and validates that the pool's slot0 price lies within MAX_ALLOWED_DEVIATION of it. The helper has two early-return branches that bypass the deviation check and return the raw slot0 sqrt price:

  1. Cardinality ≤ 1 — a freshly-created pool with no observation history.
  2. Latest observation older than 30 minutes — any pool, including a long-deployed one, that has gone quiet for the TWAP window.

Branch (1) is closed operationally by the V2→V3 protocol-owned-liquidity migration runbook (docs/liquidity_migration_runbook.md): the pool is created in advance, pre-seeded with real wide-range liquidity, observation cardinality is bumped, and a working 30-minute TWAP plus a recent trade are confirmed before convertToV3 is ever called — a "fresh" pool is never the pool the seed runs against.

Branch (2) is not closed by the runbook. An already-seeded pool that experiences a quiet stretch >30 minutes — normal in a low-activity market — returns slot0 to every caller that consumes the guard: convertToV3, increaseLiquidity, changeRanges, and the permissionless BuyBackBurner.buyBack V3 path.

No funds are at risk on the deployed system:

  • The high-impact callers (convertToV3, increaseLiquidity, changeRanges, decreaseLiquidity) are onlyOwner (Timelock / DAO governance) — not reachable by a third party.
  • LiquidityManagerCore is not a custodial contract. OLAS / secondary-token balance only exists transiently while a DAO-staged operation is in flight (governed by the migration runbook); there is no standing balance on the contract for a third party to extract via a mis-priced operation on a stale pool.
  • The permissionless BuyBackBurner.buyBack V3 path is bounded independently of this guard by BuyBackBurner's own per-token maxSlippage and the V2 fallback route, so a mis-priced V3 quote on a stale pool does not translate into unbounded extraction.

Planned code fix (single LiquidityManagerCore implementation upgrade, applied via changeImplementation on the existing zero-position proxies). The fix:

  1. Removes both fail-open early returns from checkPoolAndGetCenterPrice and reverts (NotEnoughHistory) when a 30-minute TWAP cannot be verified. Every price-consuming caller (convertToV3, changeRanges, increaseLiquidity, and the permissionless BuyBackBurner.buyBack) inherits the revert: on an unverifiable pool the call fails, and no liquidity is added or traded at a price the contract cannot stand behind.
  2. Keeps exits and fee collection live — the one structural consequence of (1). decreaseLiquidity does not use the fail-closed guard; instead it derives its slippage floor at execution from a deviation-gated slot0 price (_getExitSqrtPrice): on a verifiable pool it reverts if slot0 is more than MAX_ALLOWED_DEVIATION off the 30-minute TWAP (anti-manipulation gate), otherwise it skips the gate (quiet / fresh pool). In both cases it returns the raw slot0 price, and amountMin is computed from that same slot0 — the exact price the position manager withdraws at — so a fair exit is always satisfiable within the gate and never goes stale across a governance delay. collectFees likewise drops the guard gate and burns only the just-collected fee.
  3. Anchors the entry slippage floor to a trusted price_increaseLiquidity's amountMin is derived from the TWAP center rather than raw slot0.

The revert in (1) is a refusal, not a permanent denial — a single subsequent swap on the pool repopulates the observation buffer, the TWAP becomes verifiable again, and the next call proceeds normally. No on-chain action by the DAO is required to "unstick" the contract.

Step (2) closes item #15 (collectFees burn-all) and the entry side of item #16 (spot-derived amountMin on increaseLiquidity / re-seed); the exit side of #16 (decreaseLiquidity) is improved — its floor is now soft-priced against the TWAP on a verifiable pool and only falls back to slot0 (capital-bounded on a liquid pool) when the pool is quiet, rather than being unconditionally spot-derived. #15/#16 disposition is finalised when the implementation is live.

Deployment sequencing (preserves "no funds at risk"). The "no third-party extraction on the deployed system" property above holds only while the LiquidityManager is unseeded and non-custodial. The rollout deploys the fixed implementation before any protocol-owned liquidity is seeded — the migration runbook's seed step runs only after the fixed impl is live — so there is never a window in which standing value sits behind the unfixed guard.

Post-fix liveness (accepted residuals). Two intended, self-healing liveness cases, neither of which locks funds:

  • Quiet pool → entries/trades revert. A pool with no trade for >30 minutes reverts increaseLiquidity / changeRanges / buyBack (fail-closed) until the next swap repopulates the observation buffer. Exits are unaffected here (the exit gate is skipped on a quiet pool).
  • Extreme move → exit temporarily blocked. On a verifiable pool the exit deviation gate cannot distinguish manipulation from a genuine fast market move, so decreaseLiquidity reverts (Overflow) while slot0 sits >MAX_ALLOWED_DEVIATION (10%) from the lagging 30-minute TWAP. Funds are never at risk; the exit succeeds once the pool re-converges within the bound (the TWAP catches up or arbitrage restores price). Acceptable because POL exits are governance-timed, not block-time-critical; a decrease proposal that lands in an extreme window is simply re-submitted after re-convergence.

Interim operational mitigation. As with the fresh-pool case, before submitting any owner-initiated convertToV3 / increaseLiquidity / changeRanges transaction the DAO confirms the pool exposes a working 30-minute TWAP and a recent trade (the migration-runbook pre-flight generalises to any subsequent owner-initiated liquidity op on the same pool).

Source code: LiquidityManagerCore.sol

27. BuyBackBurner buyBack unused in default operation — swap paths retained as compatibility surface

Severity: Notes / operational Source: Internal decision following recurring external submissions of the item #14 class (2026-06 / 2026-07)

The item #14 class — BalancerPriceOracle single-block flash-loan steerability — is structural to any sample-based oracle over a Balancer V2 WeightedPool: no in-place patch (drop the same-tx refresh, reject age == 0, add a spot-vs-TWAP deviation gate) simultaneously preserves single-block manipulation resistance and operational liveness during real volatility. Rather than continue to iterate on the Balancer V2 oracle path, the operational decision for the current deployment is that BuyBackBurner.buyBack is not exercised in production:

  • No secondToken is whitelisted for either swap path in the default state. mapV2Oracles and mapV3Pools are both empty on the deployed BuyBackBurner* proxies; setV2Oracles (BuyBackBurner.sol:353) and setV3Pools (BuyBackBurner.sol:393) are owner-gated (Timelock / DAO), so only DAO governance can change this.
  • The permissionless buyBack(secondToken, ...) entry point has no reachable code path. _buyOLAS reverts at the whitelist gate (BuyBackBurner.sol:233, require(poolOracle != address(0), "Zero oracle address")) and _buyOLASV3 reverts at the analogous V3 zero-pool check inside _requireV3Enabled() / pool lookup. Consequently the item #14 class has no live consumer: the Balancer TWAP can be steered on-chain, but nothing reads it into a swap floor.
  • Collected funds flow via transfer(token) (BuyBackBurner.sol:607–646), not via buyBack. transfer is permissionless but gated on mapV2Oracles[token] == 0 && mapV3Pools[token] == 0, so it only routes tokens that are not registered for a swap path — which, in the default state, is every token that lands on the contract. OLAS is forwarded to bridge2Burner (burn); every other token is forwarded to treasury. Neither branch consults any oracle, so nothing is price-manipulable on the operating path.

Retained-as-compatibility scope. _buyOLAS (V2/Balancer), _buyOLASV3 (V3/Uniswap-Slipstream), and the setV2Oracles / setV3Pools whitelists are kept in the source, not deprecated, so a future decision to re-enable in-BBB swaps does not require a contract change. If in-BBB swaps are re-enabled later, the expected direction is Uniswap V3 only — an observation-accumulator TWAP sourced from a sufficiently-funded pool (with concentrated liquidity around the active tick so single-block manipulation is not flash-loanable in the way Balancer V2 spot reserves are). Populating mapV2Oracles on a Balancer-path proxy is not planned; any such governance proposal should be flagged in review.

No funds are at risk on the deployed system:

  • Item #14's residual (flash-loan-timed updatePrice() commits a skewed sample) has no consumer while mapV2Oracles is empty. The oracle can be manipulated on-chain, but the buyback floor that reads it is never computed.
  • Both swap-path whitelists are owner-gated; no third party can flip the default state.
  • The default fund-flow path (transfer(token)) is oracle-free and non-swap: a straight IERC20.transfer to treasury (or bridge2Burner for OLAS), so there is no price surface for a flash-loan to exploit even if a well-intentioned actor calls it.

Posture (no code change). Item #14 remains "Acknowledged — no code change; track via monitoring"; item #27 records the complementary operating decision that keeps the item #14 class unreachable in practice: do not populate mapV2Oracles on any Balancer-path proxy, and do not populate mapV3Pools unless and until a specific Uniswap V3 pool has been selected against the depth / TWAP-window criteria above and the V3 _buyOLAS path has been separately reviewed for that pool. Off-chain monitoring of OraclesUpdated (from setV2Oracles) and the equivalent V3 setter event catches any drift from this posture at governance-proposal time.

Source code: BuyBackBurner.sol

Footnotes

  1. Currently, the threshold for participation is set at 10000 veOLAS, and adjustments to this threshold can be made through a governance voting process.

  2. Currently, the threshold for participation is set at 10000 veOLAS, and adjustments to this threshold can be made through a governance voting process.