Tenor engaged Guardian to review the security of their Tenor protocol contracts. From the 7th of April to the 21st of April, a team of 6 auditors reviewed the source code in scope.
- Published
- Review window
- April 7 to 21, 2025
- Language
- Solidity
- Chains
- Base
- Sector
- Lending
- 0 Critical
- 1 High
- 4 Medium
- 27 Low
- 0 Informational
Scope
Overview
Tenor engaged Guardian to review the security of their Tenor protocol contracts. From the 7th of April to the 21st of April, a team of 6 auditors reviewed the source code in scope.
Findings 32
-
H-01 High Uniswap V4 Swaps Revert Due To Invalid Slippage Logical Error Resolved
Description
When performing a swap through the Uniswap v4 Router, the
sqrtPriceLimitX96parameter is automatically set to eitherTickMath.MIN_SQRT_PRICE + 1orTickMath.MAX_SQRT_PRICE - 1(V4Router.sol#164) This value is passed intoSwapConversion.toTenorParams, where it is used to computeslippageLimitfor the Tenor protocol:tenorSwapParams.slippageLimit = uniswapParams.sqrtPriceLimitX96 - MIN_PRICE_LIMITAs a result,
slippageLimitends up being either 0 or a large constant near the full sqrt price range. This causes Tenor’s internal_checkMaxSlippageReachedto always revert in certain swap directions (e.g., exact input swaps fromcurrency1tocurrency0).In the opposite direction, swaps do not revert but
slippageLimitbecomes meaningless. Since users executing swaps through the Uniswap Router have no way to customize slippage settings, Uniswap-based swap routes into Tenor are effectively broken.Recommendation
Consider using the
hookdataparameter to provide slippage values instead of deriving them from Uniswap’ssqrtPriceLimitX96.Resolution
Tenor Team: The issue was resolved in commit 1da33c4.
-
M-01 Medium Spot Tick “Add-Then-Remove” Bypass Enables Near-Free Swaps Warning Acknowledged
Description
Tenor enforces that liquidity providers at the spot tick must deposit and withdraw tokens proportionally so they cannot swap without incurring normal swap fees. This implementation is also clearly defined in these docs.
However, users can front-run a large swap at the spot tick to perform a free or near-free token exchange: 1. The spot tick currently holds X loan and X fixed, implying a nominal 1:1 rate. 2. The user sees a pending transaction on the mempool that will trade a large chunk of tokens at the spot tick, shifting its ratio significantly (e.g., from 1:1 to 1:1.66). 3. Before the large swap transaction is executed, the attacker adds liquidity to the spot tick proportionally, depositing (X, X) of (loan, fixed), doubling the total to (2X, 2X). 4. The big swap then executes, consuming tokens from the spot tick, e.g., leaving (1.5X loan, 2.5X fixed). 5. Right after that trade, the attacker withdraws their liquidity. Because they deposited 50% of the tick’s total before the swap, they now remove 50% of the updated loan/fixed pool. If the trade shifted the ratio to 1:1.66, the user’s portion is no longer 1:1 but includes a bigger portion of one token, netting a no-fee or arbitrage swap.
Despite Tenor’s rule that spot tick deposits must be “proportional,” the post-swap ratio changes within the spot tick, so the attacker’s withdraw (still the same share fraction) entitles them to a newly skewed ratio. This yields a near-free token exchange, front-running the big swap.
They effectively circumvent normal swap paths/fees because the code sees them as legitimate LP actions. Finally it is worth mentioning that in this case, the liquidity provider also managed to take a portion of the liquidity fee of the previous swap.
Recommendation
Consider enforcing a minimum lock period once a user adds liquidity to the spot tick, before they can withdraw. This prevents rapid deposit–withdraw cycles around a large trade.
Resolution
Tenor Team: Acknowledged. 19
-
M-02 Medium Incorrect assertCompatibilityWithUniswap - beforeInitialize Validation Resolved
Description
Within Tenor’s
SwapConversionlibrary, thetoTenorParamsandtoUniswapParamsfunctions, along withtoTenorKeyandtoUniswapKeyinHookPoolKey, are intended to be symmetrical. That is, converting aTenorPoolKey→UniswapPoolKey→ back toTenorPoolKeyshould yield the same or revert if incompatible.However, this symmetry breaks under certain conditions: 1. If
bpsPerTick > 127: The “7-bit” range in the Uniswap key layout allows[1..127]. If the original TenorbpsPerTickis outside that range,toUniswapKeybecomes is truncated, while converting back with toTenorKey quietly folds it into some permissible default. 2. If maturity timestamp not a multiple of 1 hour.assertCompatibilityWithUniswapis called in two places:initialize: receives the raw user-providedTenorPoolKey, so it properly checks if (bpsPerTick <= 127, hour-aligned
maturity, etc.). It will revert right away if an invalid value is provided.
beforeInitialize: receivesuniswapKey.toTenorKey(). But if the Uniswap key was already invalid or out of range,
toTenorKeyquietly produces a “valid” but a “truncated” Tenor key. So the check never fails, the code can’t detect that the original bits were out of range.The main impact is that pools that are created through the UniswapV4 PoolManager contract directly may be configured with incorrect parameters and the deployer would not be able to detect it. For example, deploying the following Pool through UniswapV4 PoolManager:
PoolKey memory poolKey = PoolKey({loanToken: address(contract_ERC4626_loanToken), fixedToken: address(contract_ERC20_fixedToken), maxTick: 16, bpsPerTick: 200, maturity: uint32(Seconds.unwrap(maturity)), loanTokenIsERC4626: true});Would instead setup the following underlying Tenor pool:
emit InitializePool(id: 0x62fc492d78e5ebbca14c6e5dc68be27b2305379d9a0027cbdc135862f05e233e, key: PoolKey({ loanToken: 0x8c15701ab95A8760403009414B830C7df9E4a4dc, fixedToken: 0xEdB52d394123f41118e42Fb0f19008E8022529e1, maxTick: 17, <------------ bpsPerTick: 72, <-------- maturity: 1776171600 [1.776e9], loanTokenIsERC4626: true}))Recommendation
Update the
beforeInitializefunction to correct this issue or otherwise, consider documenting the supported ranges and informing the users of this limitation when deploying directly through the UniswapV4 PoolManager.Resolution
Tenor Team: The issue was resolved in commit 8ab2e7a.
-
M-03 Medium Pools Can Be Initialized Before Protocol Fee Is Set Logical Error Resolved
Description
A portion of the pool fees goes to Tenor as protocol fees, while the remaining portion goes to pool owners as owner fees. These shares are determined based on
protocolShareOfFeeShares, which is set by Tenor as a global configuration value.The
protocolShareOfFeeSharesfor a pool is set to the global configuration value at the time of pool initialization and cannot be changed afterward.The issue is that the global
protocolShareOfFeeSharesis neither set at the time of deployment nor enforced with a minimum value, and must be set by Tenor after deployment.Other protocols integrating with Tenor will be the owners and will have their own
PoolManagerinstances, which they can create via factory contracts.Since there is a time gap between deployment and configuration, these protocols can initialize all major pools immediately after deployment, before the
protocolShareOfFeeSharesis set by Tenor, and receive all the fees themselves.Recommendation
Set a base value for
protocolShareOfFeeSharesat the time of deployment to prevent revenue loss to other protocols, while retaining the ability to modify it later if necessary.Resolution
Tenor Team: The issue was resolved in commit bbf8a20.
-
M-04 Medium Slippage Risk For Swap Limit Orders Validation Resolved
Description
Market orders and slippage orders are executed with the same function. The implemented slippage check is based on the filled amount, but for limit orders zero size is filled.
Therefore if a user expects a pure limit order the user needs to set the slippage check to 0 so that it does not revert. It is also possible to perform a partial limit and partial market order and here this slippage check can be imprecise.
For example:
- Eve provided most of the liquidity to the pool
- Alice wants to perform a big swap through multiple ticks to clear all of the available liquidity and
create a limit order at the last one to incentivize other users to fill it soon (as she expects a good part of the order to be created as limit order she needs to reduce the expected returned filled amount for the slippage check by that)
- Eve front runs the call and redistributes the liquidity so that Alice swaps as much as possible under
the worst condition right before the limit tick
- Alice received less than expected but was not able to prevent that as the slippage check takes the
unfilled limit order part into account
Recommendation
Consider adjusting the slippage check to only account for the market order part.
Resolution
Tenor Team: The issue was resolved in commit 21a07df.
-
L-01 Low Exchange Rate Does Not Always Round In LP Favor Rounding Acknowledged
Description
The protocol is designed to favor liquidity providers (LPs) in swap execution, as seen in
calculateAmountInandcalculateAmountOut, which intentionally round amounts to the LP's advantage.However, in
ERC4626Utils.sol:loanToAssetandExchangeRate.sol:toExchangeRate, the calculated exchange rate always rounds down, regardless of swap direction or type. In certain cases, this benefits the swapper instead of the LP.Recommendation
When calculating the exchange rate at
loanToAssetandtoExchangeRate, the the rounding direction should be aligned with the swap context to ensure LP-favorable execution: Round down for:fixedForLoan+ exact outputloanForFixed+ exact input
Round up for:
fixedForLoan+ exact inputloanForFixed+ exact output
Resolution
Tenor Team: Acknowledged. Exchange rates always need to be rounded the same way to ensure consistency across the protocol.
-
L-02 Low Order-Filling Priority Can Be Gamed Easily Warning Acknowledged
Description
In the current design, Tenor processes swaps by first filling partially filled limit orders, then unfilled limit orders and finally the pool’s spot liquidity. This ordering means that once a limit order is moved from “unfilled” to “partial” it gains top priority in subsequent swaps.
By performing a minimal self swap of size 1 or another small amount against a previously unfilled limit order, an attacker can convert that limit order into a partially filled order, thereby elevating it in the queue for subsequent trades at that tick.
This small fill transforms the order from “unfilled” to “partial,” causing it to be matched first in any future large swap arriving at that tick.
Recommendation
Consider enforcing the same priority among unfilled and partially filled limit orders or add constraints preventing trivial minimal fills that move a large full order into the partial queue.
Resolution
Tenor Team: Acknowledged. A limit order user can only do this if there is no outstanding partially filled limit orders in the same tick and at the spot tick.
-
L-03 Low Missing beforeHook Overwrite In HookPermissions Warning Resolved
Description
Currently, Tenor’s
HookPermissionsforbeforeDonateis set tofalse. As a result, whenever users calldonatein a Uniswap V4 pool withPoolManagerHookas the hook, the call will fail with aNoLiquidityToReceiveFeeserror logged directly from the Uniswap V4 pool instead of reverting with aHookNotImplementederror.Because Tenor already follows the pattern of turning hooks on but reverting for standard
beforeAddLiquidityandbeforeRemoveLiquidityflows, the same approach could be adopted forbeforeDonate.Recommendation
Consider setting
beforeDonate = trueinHookPermissions, so the Uniswap V4 manager callsbeforeDonate(...)reverting with the actualHookNotImplementederror.Resolution
Tenor Team: The issue was resolved in commit a6ca005.
-
L-04 Low Incorrect NatSpec Comment In Swap Function Documentation Resolved
Description
Tenor’s
IPoolManager.swapfunction includes aslippageLimitparameter documented as follows:/// @param slippageLimit Sets the slippage limit for the swap. /// Specified as maximum amount in if swap is exact out. /// Max amount out if swap is exact inThis part is incorrect and does not reflect the current implementation:
/// Max amount out if swap is exact inIt should be instead:
/// Min amount out if swap is exact inOn the other hand, the
NatSpeccomments for theswapfunction inIPoolManagercontract omit documentation for theloanForFixedparameter and redundantly document thelimitTickparameter twice.Recommendation
Update the comments as suggested in the
IPoolManagerinterface.Resolution
Tenor Team: The issue was resolved in commit cc5972c.
-
L-05 Low Potential High Gas On Market Orders Warning Acknowledged
Description
In Tenor’s
PoolManager.swaplogic, each incoming market swap iterates over ticks in ascending or descending order, potentially hitting all ticks in the pool.Since Tenor can support up to 111 ticks (from 1 to 111), a large order with low liquidity might traverse most or all ticks to fill the user’s requested amount. In each tick, the code proceeds through partial limit orders, unfilled limit orders and finally liquidity.
A stress test has shown that, when the user triggers many small partial fills across many ticks, the gas usage can approach or exceed 22 million gas, near the block limit on certain networks. This can cause out-of-gas reverts or extremely expensive calls.
Recommendation
Consider bootstrapping key ticks with sufficient liquidity so fewer ticks must be crossed for typical trades. On the other hand, for lightly used deployments, keep
maxTicksmaller than 111 to reduce iteration count.Resolution
Tenor Team: Acknowledged. Document and push curators to use a smaller maxTick as it's also used to discount the fixed tokens at max rate for collateral use. Related to that, we massively reduced the gas cost of the bestBid function and would appreciate if you could validate the fix, which is using an optimized solady's library function 1 for 1. We fuzzed all the possible values against the last and new implementation and they should be identical:
https://github.com/Shippooor-Labs/tenor-amm/blob/6a1ac868b5b243a3fadbdfafd6fada78d4a251
-
L-06 Low Missing Multicall In PoolManager Contract Warning Acknowledged
Description
Tenor’s
PoolManagercontract currently does not implement amulticallinterface, preventing users from batching multiple actions (such as adding/removing liquidity, creating/canceling limit orders and executing market orders) in a single transaction.Without
multicall, each step must be sent as a separate on-chain transaction, incurring higher gas usage and exposing users to front-running or partial state changes between steps.Recommendation
Implement a
multicall(bytes[] calldata data)function allowing users to bundle multiplePoolManagercalls in a single on-chain transaction.This approach is similar to many existing DeFi contracts, reduces gas costs and ensures atomic execution of user strategies. It would also simplify user flows and protect them against partial updates in between steps.
Resolution
Tenor Team: Acknowledged. We will have a custom bundler implementation to batch on-chain actions later on along with the market implementation.
-
L-07 Low Unused Swap Callback Warning Acknowledged
Description
In the
PoolManagerHookimplementation, thebeforeSwapfunction includes a finalbytes calldataparameter intended for user‐supplied callback data.However, the code ignores this argument altogether, making it impossible to execute the swap callback.
Because the
callbackDatais never referenced, any attempt to pass a payload for on‐chain mid‐swap actions is silently discarded.Users cannot embed special parameters or triggers in the swap call. This deviates from the standard Uniswap V4 design that typically allows arbitrary hook data for advanced strategies.
Recommendation
Update the
beforeSwapfunction to handle or forward thebytes calldatato the_maybeExecuteSwapCallbackinternal function.Resolution
Tenor Team: Acknowledged. This parameter is only used to send arbitrary data to the custom hook implementation as per uniswap documentation. We have no use for it, so we simply don't use it. The recommendation seem to intertwine two different concepts.
-
L-08 Low Frontrun Vault Update For Risk-Free Profit MEV Acknowledged
Description
In Tenor, the exchange rate for swaps is determined by a combination of the tick’s fixed interest rate and the
loanToAssetratio. TheloanToAssetratio is derived from theERC4626vault that backs the loan token and typically increases over time.However, it can drop abruptly if the vault realizes bad debt, such as during a liquidation event. This creates a clear frontrunning opportunity for attackers.
By observing an impending vault state change that will negatively affect
loanToAsset, an attacker can execute a trade just before the update and unwind it just after—locking in a guaranteed profit at the expense of liquidity providers.Example attack scenario: 1. A Tenor pool uses a Morpho USDC Vault as the loan token, where
loanToAsset = 1.12. An attacker anticipates a pending vault liquidation that will introduce bad debt 3. The attacker front-runs with aloanToFixedswap, locking inloanToAsset = 1.14. Bad debt is realized →loanToAssetdrops to 1.0. 5. The attacker back-runs with afixedToLoanswap, extracting more loan tokens than initially depositedThis results in guaranteed profit for the attacker, while the liquidity providers bear the loss.
Recommendation
For limit order liquidity, one option is to consider adding per-order slippage protection. This could be done by storing the user-defined
slippageLimitat order creation, and validate it at execution.However, this will require redesigning the current batching system which aggregates orders from different users. For liquidity providers, implementing similar protections will be more complex and may require a separate mechanism or design discussion.
Resolution
Tenor Team: Acknowledged. We document this behavior and specify that users should not use a ERC4626 with an exchange rate that can go down. Our market implementation handles bad debt 30 accrual to prevent this, so does Metamorpho 1.1.
-
L-09 Low Lack Of Incentives To Lend In Tenor Logical Error Acknowledged
Description
During a pool initialization, the
bpsPerTickandmaxTickare cached in the pool state. The max APY that a user can receive when lending isbpsPerTick * maxTick.However, if the
loanTokenis a very profitableERC4626vault, there could be cases where there are no incentives to keep lending or providing liquidity in Tenor protocol, as the max fixed APY available is way lower than the one received by just holding the vault tokens.Recommendation
If this is intended behavior, document this to the users so they are aware of it and set the appropriate key during initialization. Alternatively, consider setting a min value for
bpsPerTickandmaxTickso the max APY is high enough to cover all scenarios.Resolution
Tenor Team: Acknowledged. This is intended behavior and will be documented.
-
L-10 Low Incorrect Fee Accounting During exactIn Swaps Logical Error Resolved
Description
During
exactInswaps, thecalculateAmountOutfunction is used to determine the output amount. IfuserAmountOutis greater thantotalTickBalance, the tick will be fully cleared during the swap. In that case,userAmountOutwill be set tototalTickBalance, andcalculateAmountInwill be used to determineuserAmountInsince the output amount is known.The newly calculated
userAmountIn_must be less than or equal to the actualuserAmountIn. However, there is an edge case whereuserAmountIn_exceeds theuserAmountIndue to rounding. In this case,userRemainingInis set to 0.However, while setting
userRemainingInto 0 handles the remaining amount,poolFeesPaidCurrencyInis not properly handled. In this scenario, the returnedpoolFeesPaidCurrencyInvalue comes directly from thecalculateAmountInfunction, which was calculated using the higheruserAmountIn_, rather than the actualuserAmountIn.As a result,
poolFeesPaidCurrencyInwill be higher than it should be, leading to a lowertickAmountInvalue during the swap.Recommendation
poolFeesPaidCurrencyInshould be adjusted whenuserAmountIn_ > userAmountIn.Resolution
Tenor Team: The issue was resolved in commit 603746e.
-
L-11 Low Incorrect Errors Are Used In PoolAccounting Informational Resolved
Description
In the
updatePoolAccountingfunction, theInsufficientPoolLoanBalanceerror is used when thefixedtoken balance is insufficient, and theInsufficientPoolFixedBalanceerror is used when theloantoken balance is insufficient.Recommendation
Swap the errors to match their corresponding tokens.
Resolution
Tenor Team: The issue was resolved in commit edab1c8.
-
L-12 Low No Callback Support For Liquidity Operations Informational Acknowledged
Description
Currently, when users add or withdraw liquidity from a Tenor pool, there is no mechanism to pass
callbackData. As a result, theonTenorSwapCallbackhook is never triggered during these operations.In contrast, swap operations allow users to supply
callbackData, enabling advanced interactions like flash accounting, dynamic approvals, or token sourcing from smart contracts.The absence of callback support for liquidity actions reduces the composability of the Tenor protocol and may limit integration opportunities with other on-chain protocols or vault systems.
Recommendation
Introduce an optional
callbackDataparameter for add/withdraw liquidity functions and triggeronTenorSwapCallbackif data is provided.Resolution
Tenor Team: Acknowledged. The tokenized liquidity is not transferable and we do not see a lot of value in adding this callback.
-
L-13 Low Misleading Event Emission Informational Acknowledged
Description
Users can perform swaps directly through Uniswap, and the Uniswap router is expected to be used for this. The actual swap will occur during the
swapInternalcall in thebeforeSwaphook.The
sendervalue is used for both thereceiveranduserparameters. However, during this call,senderrefers to the Uniswap router address, not the actual user.Although these addresses are not directly used for token transfers, the
Swapevent will be emitted with the router address instead of the actual user or receiver.Recommendation
Be aware of this behavior when using event listeners. Alternatively, consider passing the user address via
hookdatawhen performing swaps through the router.Resolution
Tenor Team: Acknowledged.
-
L-14 Low Withdrawing 0 Shares Is Possible Informational Resolved
Description
In general, the protocol does not allow minting or withdrawing zero shares. However, this restriction does not apply when withdrawing limit orders. The
withdrawLimitOrderfunction does not include a check for a zero share amount and successfully executes even when the amount is zero.Recommendation
Return early or revert if share amount is zero.
Resolution
Tenor Team: The issue was resolved in commit 4890806.
-
L-15 Low ERC6909 Approvals And Operator Ignored Access Control Acknowledged
Description
The
ERC6909token standard allows an account to delegate control by approving another account or assigning an operator. However, in the current implementation, only the token owner is permitted to perform actions such as withdrawing liquidity or cancelling limit orders.As a result,
allowanceandoperatorare effectively ignored, which deviates from expectedERC6909behavior and limits composability.Recommendation
Implement permission checks to allow both approved accounts and operators to act on behalf of the token owner.
Resolution
Tenor Team: Acknowledged. Saying that allowance and operator are effectively ignored, deviating from the expected ERC6909 behavior is false. EIP-6909 states that the the allowance/operator are only granting unlimited transfer permissions, which is the case here, although transferring liquidity is not allowed. Using the liquidity token allowance/operator to permit withdrawing limit orders directly within the pool manager would introduce an unclear behavior which is out of scope for this EIP. Withdrawing limit orders on behalf of user is already possible as the liquidity tokens can be transferred from and withdrawn by the operator.
-
L-16 Low loanToAsset Does Not Account For Slippage/Fees Warning Acknowledged
Description
In the custom
ERC4626Utilslibrary, the functionloanToAssetderives the exchange rate via:exchangeRate = loanToken.convertToAssets(10 ** (EXCHANGE_RATE_DECIMALS + loanTokenDecimals - underlyingTokenDecimals));However, per the ERC4626 specification,
convertToAssetsdoes not account for withdrawal fees or slippage. As a result, it may overestimate the actual amount of assets received upon redemption.In contrast, the
previewRedeemfunction is designed to return the actual amount of assets, inclusive of slippage and fees.While this has no immediate effect when using Morpho vaults (which do not charge fees), it could cause mispricing or unexpected behavior if the protocol integrates with fee-charging or slippage-prone vaults in the future.
Recommendation
Use
previewRedeeminstead ofconvertToAssetsto get an accurate, fee-adjusted conversion rate.Resolution
Tenor Team: Acknowledged. This introduces weird scenarios if the fees are subject to change, but will be documented.
-
L-17 Low Unused Error In PoolActions Informational Resolved
Description
InvalidSwaperror inPoolActions.sollibrary is not used and can be removed.Recommendation
Consider removing unused import.
Resolution
Tenor Team: The issue was resolved in commit bed02a5.
-
L-18 Low Missing Max bpsPerTick Check In Init Flow Validation Acknowledged
Description
If the
PoolManagerHookcontract is used the protocol checks that the givenbpsPerTickvalue is not too big during theinitializeflow in theassertCompatibilityWithUniswapfunction.As the
bpsPerTickvalue also equals the swap fee it makes a lot of sense to not allow unreasonable values here. This check is missing in the pure TenorPoolManager.initializeflow.Recommendation
Consider adding the maximum check for the given
bpsPerTickvalue to theinitializeInternalfunction and removing it from theassertCompatibilityWithUniswapfunction.Resolution
Tenor Team: Acknowledged. This is the expected behavior as we don't want to restrict uses cases. In the hook version, we have a hard requirement as the pool key doesn't allow more. This will be documented.
-
L-19 Low Functions Naming Convention Best Practices Resolved
Description
The protocol uses names starting with an underscore for private functions, but not for internal functions. This naming style goes against the solidity convention for external/public vs internal/private functions, more details here
Recommendation
Consider adapting to the function naming convention, according to the solidity docs shared above.
Resolution
Tenor Team: The issue was resolved in commit 7275dcb.
-
L-20 Low Mismatched Comments For Fee And Tick Spacing Documentation Resolved
Description
The
HookPoolKey.toUniswapKeyconverts a Tenor pool key to a Uniswap pool key. There are some comments explaining the uniswapfeeandtickSpacingencoding.However, the first comment mentions
Uniswap pool fee, even though the encoding is fortickSpacing. Same happens with thefeeparam below.Recommendation
Update the comments to clearly show which parameter is being explained (first one is about tick spacing, second about fee). This avoids confusing the reader.
Resolution
Tenor Team: The issue was resolved in commit 84e8bdc.
-
L-21 Low External Incentives Are Lost Rewards Acknowledged
Description
According to Morpho docs,
Users automatically earn rewards while participating in incentivizedmarkets or vaults. Therefore, thePoolManagerwill be entitled to claim these rewards as it holds vault tokens.Although anyone can claim on behalf of other addresses, the current implementation does not contain a function to extract these rewards, so they are effectively lost.
For the case of
PoolManagerHookthe Morpho vault tokens will be stored in the Uniswap side, so these can't never be retrieved.Recommendation
Consider implementing an owner function to withdraw these extra incentives.
Resolution
Tenor Team: Acknowledged. This is a known behavior and will be documented. Tenor's implementation has a loan token that can claim rewards.
-
L-22 Low Typos In Protocol Documentation Informational Resolved
Description
There are multiple typos / small mistakes in the protocol documentation.
Recommendation
Consider fixing all the typos suggested.
Resolution
Tenor Team: Resolved. Fixed in the documentation website repository.
-
L-23 Low Uniswap V4 DoS During Callback DoS Acknowledged
Description
The flow of the protocols
_unlockCallbackfunction looks like the following:- settle positive delta (funds are pushed to user)
- optional callback is executed
- settle negative delta (funds are pulled from the user)
As this optional callback happens during the
_unlockCallbackflow the Uniswap V4PoolManagercontract is already unlocked.This means that it is not possible for the user to use the Uniswap Router to interact with the Uniswap V4 system (for example to perform a swap) as this interaction would revert with a
AlreadyUnlockederror.It is possible for the user to interact directly with the Uniswap V4 core contracts, however this requires the user to be experienced enough with Uniswap V4 to do so.
Recommendation
Consider performing the callback while Uniswap V4 is locked, or document this behaviour.
Resolution
Tenor Team: Acknowledged. The goal of this callback is to be able mint the exact amount of fixed tokens needed in a swap. People wanting to use Uniswap will go through the Uniswap routing directly.
-
L-24 Low Rethink Uniswap Integration Logical Error Acknowledged
Description
The protocol allows to deploy two different types of pools:
- The pure tenor pool named
PoolManager - A similar version that also interacts with Uniswap named
PoolManagerHook
The protocol stated out that there are two reasons for the Uniswap integration: 1: To allow swaps over Uniswap As Uniswap is only used for accounting and no real swaps take place a user who performs a swap over Uniswap will likely have a very bad user experience. The shown price at Uniswap will never change after init therefore the user sees one price in the UI and swaps at a totally different price. This could be very confusing and lead to setting a dangerous slippage check.
2: "If Uniswap creates an "earn" product they could simply route the volume using the hooked version of the pools" As now swaps take place on Uniswap the volume routed through it will always be 0 and Uniswap is not able to charge any fees. This makes it unlikely that Uniswap will distribute rewards to these pools.
Recommendation
Rethink if the Uniswap version makes sense or if it just adds more potentially vulnerable code without a real benefit.
Resolution
Tenor Team: Acknowledged. We're keeping both implementations to allow more uses cases in the future.
- The pure tenor pool named
-
L-25 Low Lack Of Incentives To Provide Liquidity Validation Acknowledged
Description
The pool owner can use
setFeeSharesto adjust the share of the fees earned by the liquidity providers and limit order creators (market makers).The current implementation only validate if these are less than or equal to 100. However, allowing 100% fee share does not make much sense, as market makers will be earning zero fees. Therefore, there are no incentives to provide liquidity to the pool.
Recommendation
Consider setting the max fee share to a value below 100%.
Resolution
Tenor Team: Acknowledged. Don't want to restrict use cases, although setting this to 100 will push LPs away naturally.
-
L-26 Low Failed Invariants For Edge Cases Unexpected Behavior Resolved
Description
The following invariants were invalidated:
- SWP_01: will always fail when dealing with limit orders, either by swapping and creating one, or
filling a limit order during swap. This was more like an issue with the invariant implementation, as unfilled, partial and fulfilled shared will change before and after the swap.
- SWP_02: fails when doing a swap-limit order, when spot tick only contains limit orders (as fulfilled
limit orders are removed from bitmap)
- SWP_06: when swapping loan to fixed at tick 1, cases where you receive less fixed tokens than the
amount of loan tokens swapped, therefore
ln(fixed/loan)is negative.- SWP_07: swapping with very low amounts (wei), can result in implied rates above max.
- SWP_08: low underlying decimals (i.e. decimals = 1), and swapping dust amount of fixed to loan,
leads to an exchange rate below 1e18.
- G_03: the pool had loan token decimals 1 and fixed token decimals 18.
- G_05: simulation reverts due to
loanToAsset = 0(very low asset amount in vault so
convertToAssets(1e18)yields to 0)Recommendation
Document this edge cases so they can be avoided while creating a market/pool.
Resolution
Tenor Team: Ack, will document accordingly.
-
L-27 Low Liquidity Provision Is Capital Inefficient Rewards Acknowledged
Description
Usually, LPs can provide X liquidity to a protocol (e.g. Uniswap, Aave, ...) and earn Y% yield from the X liquidity. For example:
- LP provides $100k liquidity
- After one year the LP earned $3000 (3%)
But at Tenor the LPs need to first mint a specific fixed token for the given pool by depositing collateral. Therefore they do not earn Y% yield on their given amount of X liquidity as the debt position must be overcollateralized. In case of an 80% LLTV (given as example in the docs):
- LP wants to provide $100k liquidity
- $50k is provided in loan tokens and yields $1500 after one year
- The other $50k are used as collateral to borrow $40k fixed tokens which yield $1200 after one year
- Therefore after one year the LP earned $1500 + $1200 = $2700 (only 2.7%)
This is capital inefficient and can lead to LPs searching for better opportunities, which could result in a lack of liquidity for the protocol.
Recommendation
Consider rethinking the LP mechanics if this leads to too a lack of liquidity on Tenor.
Resolution
Tenor Team: Acknowledged. Not only the LPs can add liquidity as loan, earning interest, but they can also add fixed, which is effectively a lending position earning interest (can swap loan for fixed directly). The borrowers will mint the fixed token along with the debt, and trade the fixed token for loan to effectively borrow.
No findings match.
Invariants 58
The review's fuzzing suite asserted 58 invariants. 49 held and 9 did not.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
G-01 | addLiquidity shouldn't fail for valid calls | Broken |
G-02 | withdrawLiquidity shouldn't fail for valid calls | Held |
G-03 (G-01R) | swap shouldn't fail for valid calls | Broken |
G-04 (G-02R) | withdrawLimitOrder shouldn't fail for valid calls | Held |
G-05 (G-03R) | simulateSwap shouldn't fail for valid calls | Held |
G-06 (G-04R) | claimFees shouldn't fail for valid calls | Held |
G-07 (G-05R) | initialize shouldn't fail for valid calls | Held |
G-08 (G-06R) | setFeeShares shouldn't fail for valid calls | Held |
G-09 | simulateAddLiquidity shouldn't fail for valid calls | Held |
G-10 (G-07R) | The PoolManagerHook contract should never hold any funds as everything should be in Uniswap. | Held |
G-11 (G-08R) | d After initialising a PoolManagerHook pool no swaps should happen on uniswap and no ✅ liquidity should be provided therefore the | Held |
PG-01 | related params should not change. A pool cannot be initialized with a poolKey ✅ that already exists | Held |
PG-02 | The pool cannot be initialized if the fixedToken does not meet ERC20 or ERC4626 ✅ | Held |
PG-03 | standards The pool cannot be initialized if the loanToken does not meet ERC20 or ERC4626 ✅ | Held |
PG-04 | standards When using ERC4626 for the loan token, the underlying token decimals should be < loan ✅ | Held |
PG-05 | token decimals + 18 The pool's maturity must be higher than the ✅ current timestamp during initialization | Held |
PG-06 | The pool's maturity must be no later than 500 ✅ days from the initialization timestamp | Held |
PG-07 | (Hook only) The pool's maturity must be ✅ rounded to the nearest hour | Held |
PG-08 | (Hook only) A unique Uniswap pool is always ✅ initialized for each individual Tenor pool | Held |
PG-09 | (Hook only) Each Uniswap pool maps to a ✅ single Tenor pool, and vice versa | Held |
PG-10 | Pool initialization is permissionless if the ✅ initializer address is set to zero | Held |
PG-11 | Only initializer may initialize pools if the ✅ initializer address is non-zero | Held |
PI-01 | Operations in one pool must not influence the state of any other pool | Held |
PI-02 | Actions within a pool should only alter the balances of the loanToken and fixedToken for that specific pool as maintained by the pool | Held |
PA-01 | manager contract sum of limit orders, account balances and fees should equal to total minted for each token | Held |
PA-02 | sum of shares from all actors should equal to total shares for each liquidity token | Held |
PA-03 | After withdrawing a limit order, the 6909 balance change should reflect the change in | Held |
PA-04 | shares burned In a withdraw limit order transaction, the number of tokens removed from the pool manager contract must not exceed the | Held |
PA-05 | number of tokens present in the pool before the transaction Adding and then immediately withdrawing a limit order must result in no positive change to the user's balance. The user balance can | Held |
PA-06 | decrease marginally due to rounding In a withdraw liquidity transaction, the number of tokens removed from the pool manager contract must not exceed the number of | Held |
PA-07 | tokens present in the pool before the transaction In a withdraw limit order transaction, the number of tokens removed from the pool manager contract must not exceed the number of tokens present in the pool before the transaction | Held |
OB-01 | Bid <= Ask | Held |
OB-02 | bidBitmap should match fixed token balances in unfilled and partially filled limit orders | Held |
OB-03 | askBitmap should match loan token balances in unfilled and partially filled limit orders | Held |
OB-04 | A limit bid (using Fixed Tokens) cannot be set at a tick above the current best ask | Held |
OB-05 | A limit ask (using Loan Tokens) cannot be set at a tick below the best bid | Held |
OB-06 | Adding Fixed Tokens above the best ask tick is not allowed | Held |
OB-07 | Adding Loan Tokens below the best bid tick is not allowed | Held |
OB-08 | The buying power of the LP should not make a stepwise jump after calling addLiquidity. | Held |
SWP-01 | After a swap, the number of limit order shares held by LPs must remain unchanged | Broken |
SWP-02 | After a swap, the set of active ticks in the bid and ask bitmaps should never increase, it can | Broken |
SWP-03 | stay the same or decrease For a swap with fixedTokenForLoanToken == True, after swap best Bid/Ask >= Before swap | Held |
SWP-04 | best Bid/Ask For a swap with fixedTokenForLoanToken == False, after swap best Bid/Ask <= Before swap best Bid/Ask | Held |
SWP-05 | For swaps, the pool manager contract's output amount (amountOut) must not exceed the total quantity of tokenOut in the pool before | Held |
SWP-06 | the swap A swap must not result in a user's trade implied rate below 0% | Broken |
SWP-07 | A swap must not result in a trade implied rate above: maxTick * bpsPerTick + bpsPerTick | Held |
SWP-08 | The exchange rate between Underlying Tokens (computed as Loan Tokens * loanToUnderlyingExchangeRate) and Fixed | Held |
SWP-09 | Tokens must always exceed 1 For a swap from Loan Tokens to Fixed Tokens, the user's trade implied interest rate at each tick must be lower than the spot tick interest | Broken |
SWP-10 | rate (to account for fees). In other words, lenders are paying fees For a swap from Fixed Tokens to Loan Tokens, the implied interest rate must be higher than the spot tick interest rate. In other words, | Broken |
SWP-11 | borrowers are paying fees If a user swaps loan tokens for fixed tokens (or vice versa) and then immediately swaps back their entire position, the final balance must be | Broken |
SWP-12 | lower than the initial balance due to fees on both swaps When swapping against an unfilled limit order tick: There must be no remaining partial limit order at the same tick. If a partial order exists, | Broken |
FEE-01 | it must be completely filled before interacting with the unfilled limit order During all swaps, the PoolManager's FixedTokenFees and LoanTokenFees must never decrease | Held |
FEE-02 | These fees should only increase during swaps; adding or removing liuqidity or limit orders | Held |
FEE-03 | should not alter them For any swap, only one fee type (currencyIn) should increase; If fixedFeesChange > 0, then loanFeesChange == 0; If loanFeesChange > 0, | Held |
FEE-04 | then fixedFeesChange == 0 Fees should not accrue after maturity since swapping is not possible after maturity | Held |
FEE-05 | If LimitFeeShare is set to 0 for a given pool, then the pool manager should never | Held |
FEE-06 | accumulate fees for that pool The total amount of loanTokenFees and fixedTokenFees should never be larger than | Held |
FEE-07 | the amount held by the pool manager contract Fees can only decrease when someone calls the claimOwnerFees or claimProtocolFees function | Held |
More from Tenor
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.