Covenant

Covenant
Findings & Analysis Report

2025-11-17

Table of contents

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 Covenant smart contract system. The audit took place from October 22 to November 03, 2025.

Final report assembled by Code4rena.

Summary

The C4 analysis yielded an aggregated total of 0 unique vulnerabilities with a risk rating in the categories of HIGH and MEDIUM severity.

Additionally, C4 analysis included 65 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 Covenant team.

Scope

The code under review can be found within the C4 Covenant repository, and is composed of 26 smart contracts written in the Solidity programming language and includes 2281 lines of Solidity code.

The code in C4’s Covenant repository was pulled from:

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.

Low Risk and Non-Critical Issues

For this audit, 65 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Agontuk received the top score from the judge.

The following wardens also submitted reports: 0x_DyDx, 0xbrett8571, 0xFBI, 0xki, 0xMilenov, 0xnija, 0xshdax, 30_bugs, Abdulyb, ADAMZ, alicrali, aman234, ARMoh, Astroboy, billyrg131, bravon254, caglankaan, ChainSentry, codexNature, cosin3, deebug, deeney, Diavolo, Eniwealth, Eurovickk, home1344, jerry0422, K42, KamiDancho, KineticsOfWeb3, KKKKK, kmkm, luckygru, Maketer7, mbuba666, Meks079, minos, NeuroSpire, niffylord, pyman, rayss, richa, Sai5417, SarveshLimaye, Sathish9098, sl4x0, spectator, taylorhaun, The_Amazing_One, TOSHI, TrillionaireEmpressClub, v12, valarislife, Warwick, whiterabbit, Willy_Petrus, winnerz, Wojack, Wsecure, X-Tray03, Xmannuel, yixuan, zcai, and zubyoz.

[01] Previews misquote protocol fees by returning cumulative accrual in LatentSwapLEX

quoteMint() and quoteRedeem() return protocolFees as currentState.accruedProtocolFee, which is the total time-based fee accrued since the last update, not the incremental fee attributable to the single previewed action.

In LatentSwapLogic._calculateMarketState(), marketState.accruedProtocolFee is computed from elapsed time, and marketState.baseTokenSupply is reduced by this amount; LatentSwapLEX.quoteMint()/quoteRedeem() then forward this cumulative value as protocolFees. Off-chain integrators that interpret the returned protocolFees as an action fee may double-count, since the output amounts are already computed using the fee-adjusted baseTokenSupply. Relevant references include LatentSwapLEX.quoteMint()/quoteRedeem() returning currentState.accruedProtocolFee and LatentSwapLogic._calculateMarketState() setting marketState.accruedProtocolFee and subtracting it from marketState.baseTokenSupply.

Impact

Previews can mislead wallets/routers about per-action fees, degrading UX and slippage protection (view-path only; no on-chain solvency risk).

Return a per-action incremental fee (delta vs a no-op within the same simulated state) and/or rename/document the preview field as cumulative (e.g., accruedProtocolFeeSinceLastUpdate). Alternatively, include both cumulative and per-action fields to avoid ambiguity.

[02] Multicall DoS from strict ETH-invariance check in Covenant.multicall()

Covenant.multicall() computes balanceBeforeCall = address(this).balance - msg.value, then executes all subcalls via MulticallLib.multicall() (which delegatecalls into address(this)), and finally enforces address(this).balance == balanceBeforeCall, reverting with Errors.E_IncorrectPayment() on any deviation. During batched executions, LEX functions forward msg.value to the curator’s updatePriceFeeds() when oracle update data is provided (via _updateOraclePrice()), which in turn forwards ETH to the resolved oracle. Any callee (e.g., a configured oracle) can force-send ETH back to Covenant mid-batch (refunds or selfdestruct), making the post-batch balance differ from balanceBeforeCall and causing the entire multicall() to revert. Because the equality check disallows even benign positive balance deltas, an authorized oracle/callee can grief multicall-based workflows.

Relevant code:

  • multicall() uses balanceBeforeCall = address(this).balance - msg.value and reverts if address(this).balance != balanceBeforeCall.
  • MulticallLib.multicall() performs Address.functionDelegateCall(address(this), data[i]), so all subcalls share the outer msg.value.
  • LEX _updateOraclePrice() forwards msg.value to CovenantCurator.updatePriceFeeds(); the curator forwards it to the resolved oracle.

Impact

Multicall batches that touch such callees always revert, disabling batching/UX on affected markets (DoS without direct fund loss).

Relax the invariant to allow benign positive deltas (e.g., require(address(this).balance >= balanceBeforeCall)), or implement per-subcall ETH accounting to assert only that no net underpayment occurred; alternatively, track and net out intentional refunds in multicall().

[03] Unsupported exact-out BASE swaps are not rejected early, causing wasted gas (ValidationLogic, LatentSwapLogic, Covenant)

Covenant.swap() validates inputs via ValidationLogic.checkSwapParams() but does not proactively forbid unsupported swap modes where assetOut == BASE with isExactIn == false (exact-out of BASE). The LEX implementation (LatentSwapLogic._calcSwap()) only supports:

  • synth↔synth swaps,
  • assetIn == BASE && isExactIn == true,
  • assetOut == BASE && isExactIn == true.

Any other BASE-involved exact-out combination falls through to a late revert (E_LEX_OperationNotAllowed) inside _calcSwap(), because checkSwapParams() only caps amountSpecified against baseSupply and allows this combination. Users discover the failure deep into execution (after Covenant.swap() dispatches to LEX, and potentially after an oracle update via LatentSwapLEX._updateOraclePrice() when swapParams.data is supplied), wasting gas.

Relevant locations:

  • Early validation: ValidationLogic.checkSwapParams()
  • Swap flow: Covenant.swap()ILiquidExchangeModel.swap()LatentSwapLogic.swapLogic()_calcSwap()
  • Unsupported branch: _calcSwap() final else reverts for (assetOut == BASE && !isExactIn).

Impact

Unnecessary gas waste for users due to late rejection of an unsupported swap mode; no funds at risk.

Reject unsupported modes early in ValidationLogic.checkSwapParams():

  • Revert when swapParams.assetOut == AssetType.BASE && !swapParams.isExactIn.
  • Also revert when swapParams.assetIn == AssetType.BASE && !swapParams.isExactIn (symmetrical unsupported exact-out case).
  • Use a clear error to surface intent.

[04] Default noCapLimit keyed by base token leads to misapplied mint/redeem caps in LatentSwapLEX

setDefaultNoCapLimit() is documented to set defaults “for markets with this quote token” (see ILatentSwapLEX.setDefaultNoCapLimit() and SetDefaultNoCapLimit event docstrings). However, the implementation applies the default by base token:

  • LatentSwapLEX stores defaults in tokenNoCapLimit and, during initMarket(), reads tokenNoCapLimit[marketParams.baseToken].
  • LatentSwapLogic.getInitMarketInfo() receives uint8 baseTokenNoCapLimit, reinforcing base-token semantics.

Relevant code:

  • mapping(address token => uint8) internal tokenNoCapLimit;
  • In initMarket(): tokenNoCapLimit[marketParams.baseToken]
  • In getInitMarketInfo(): uint8 baseTokenNoCapLimit

As a result, governance that sets defaults keyed by quote tokens (per docs) will not affect new markets; those markets will use a base-token keyed default (or a base-decimal derived fallback), weakening intended issuance throttles until a per-market override is set.

Impact

Governance defaults set by quote token are ignored on new markets, causing caps to be misapplied and enabling unexpectedly large mints/redeems until corrected.

Align code and documentation:

  • If intent is quote-keyed: index defaults by marketParams.quoteToken in initMarket() and pass tokenNoCapLimit[marketParams.quoteToken] to getInitMarketInfo(). Rename variables to reflect quote-token semantics and migrate existing configs.
  • If intent is base-keyed: update interface/event docstrings and admin tooling to state “for markets with this base token,” and guide operators accordingly.

[05] Preview quotes may return up to 15-minute stale prices in PythOracle, diverging from live quotes and risking stale-price decisions

The Covenant PythOracle adapter relaxes staleness checks for preview paths, allowing prices up to a constant 15 minutes old. Specifically, previewGetQuote()/previewGetQuotes() call _previewFetchPriceStruct(), which checks staleness against MAX_STALENESS_UPPER_BOUND (15 minutes), while live quoting checks use the per-instance maxStaleness. As a result, when maxStaleness < staleness ≤ 15 minutes, preview quotes succeed, but live quotes revert on the same feed.

Relevant code:

  • PythOracle.sol:

    • _previewFetchPriceStruct() uses MAX_STALENESS_UPPER_BOUND
    • previewGetQuote()/previewGetQuotes() route through _previewFetchPriceStruct()
  • Upstream live path (_fetchPriceStruct()) enforces maxStaleness

This divergence can mislead integrators that rely on preview outputs for decision-making (e.g., sizing min-out or health checks), producing mis-sized parameters or subsequent on-chain reverts when the live path is executed.

Impact

Preview quoting can surface stale prices (up to 15 minutes) that diverge from live behavior, leading to misconfiguration or failed follow-up transactions.

Align preview staleness to the instance-configured maxStaleness in _previewFetchPriceStruct(), or introduce a configurable previewMaxStaleness (defaulting to maxStaleness) to avoid stale-price divergence.


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.