OpenTrade

OpenTrade: LytPool

Cantina Security Report

Organization

@OpenTrade

Engagement Type

AI Hybrids

Period

-

Researchers


Findings

High Risk

2 findings

2 fixed

0 acknowledged

Medium Risk

5 findings

4 fixed

1 acknowledged

Low Risk

39 findings

14 fixed

25 acknowledged

Informational

16 findings

8 fixed

8 acknowledged


High Risk2 findings

  1. Anyone can profit by entering before an exchange-rate update and exiting after it

    Severity

    Severity: High

    Submitted by

    0xRajeev


    Description

    In permissionless mode anyone can deposit and requestRedeem:

    • deposit mints shares at the current exchangeRate().
    • requestRedeem fixes the payout at the rate in effect when the request is made. No interest accrues after that.
    • Nothing requires shares to be held for any length of time.
    • Nothing enforces a minimum time before payout. openClaims only requires the request's block to have passed, and processRedemptions has no age check at all. Operations may keep a 5–7 day settlement window. That is the issuer's choice, not a rule the contract enforces.

    So anyone can deposit just before a rate rise and request redemption just after it, or request redemption just before a drop. The net gain is roughly the size of the rate jump minus the yield given up while waiting for payout. Holders who stay pay for it.

    Rate moves are predictable:

    • Linear, Compounding and Term pools step up automatically at 00:00 UTC.
    • Dynamic pools change through setExchangeRateDynamic, which can be seen in the public mempool and is predictable when automation posts on a schedule.

    Large one-step moves pay regardless of the payout delay:

    • A markdown.
    • The recovery after a markdown.
    • A catch-up after missed postings.

    Routine yield steps pay only when payout comes sooner than the next update:

    Case ($10M, 5% APY)Payout afterNet to the attacker
    Front-running a −2% markdown7 days≈ +$190,000 (loss avoided)
    +2% recovery after a markdown7 days≈ +$194,000
    Weekly Dynamic update (+0.096%)1 day≈ +$8,200
    Weekly Dynamic update (+0.096%)7 days≈ $0
    Daily Linear step5–7 daysNegative

    Scenario 1: front-running a markdown (Dynamic pool, rate 1.0, defaults in place):

    1. A holder has 10,000,000 shares, which may have been bought on a DEX pair. A −2% markdown is known to be coming, either publicly or from the mempool.
    2. The holder calls requestRedeem(10,000,000 shares), which fixes assets = 10,000,000 USDC.
    3. setExchangeRateDynamic(0.98e18) is mined.
    4. Seven days later the holder is paid 10,000,000 USDC. That avoids a $200,000 loss at a cost of about $9,600 in lost yield. The holders who stay absorb the $200,000.

    Scenario 2: depositing before a recovery (rate 0.98 after the markdown):

    1. Automation submits setExchangeRateDynamic(1.0e18) to reverse the markdown.
    2. The attacker deposits 10,000,000 USDC at 0.98 and receives 10,204,081 shares.
    3. The update is mined, and in the next block the attacker calls requestRedeem, which fixes assets = 10,204,081 USDC.
    4. After a 7-day payout, the attacker nets about $194,000.

    Scenario 3: depositing before a routine update (+0.096% weekly):

    1. The attacker deposits 10,000,000 USDC just before the update. They call requestRedeem just after it, which fixes 10,009,600 USDC.
    2. If the treasury pays the next day, the attacker nets about $8,200. They can repeat this every week.
    3. If payout takes 7 days, the yield given up cancels the gain. The contract doesn't enforce that delay, so the attack's profitability depends on how quickly operations pay out.

    Recommendation

    Consider the following:

    • Minimum payout age. Enforcing the settlement window onchain:
      • Record the request time. Add uint64 requestedAt to ILYTPoolWithdrawExtra and set it to block.timestamp in requestRedeemExecute. It packs into the slot requestedAtBlock already uses. Use time rather than blocks, because blocks per day differ between chains.
      • Add a setting. Append a protocol-admin setting minClaimDelay to the controller (or a per pool/chain setting for specific usages). It is in seconds and defaults to 0.
      • Check it in openClaims: if (block.timestamp < _withdrawExtra[uuid].requestedAt + minClaimDelay) revert ClaimsNotSettled();. Requests are ordered by block, so checking the named request covers every request below it.
      • Check it in processRedemptions for each request, and in processRedemption too if push mode should follow the same rule.
      • Leave cancelRedemption and claim unchanged. Cancel is the recovery path, and claim already depends on openClaims. Requests made before the field existed read 0, so they pass the check.
    • Operations. Before posting a known markdown, call pauseWithdraws. Before posting a recovery or catch-up, call pauseDeposits. Post routine Dynamic updates in smaller, more frequent steps. A private relay hides the transaction, but not a predictable schedule. The pause itself can be front-run. A pauseWithdraws sent to the public mempool lets holders request ahead of it in the same block. Send it through a private relay. Rate changes sent through the issuer-admin timelock are announced in CallScheduled for the whole delay, so post them through automation instead.

    OpenTrade

    Fixed in Commit #4889725

    Cantina

    Concerns with this proposed fix:

    1. Push mode isn't covered because only openClaims checks the delay. processRedemption and processRedemptions don't check it in push mode. But this should be ok if there is another fix that allows permissionless pools only in claim mode.
    2. It counts blocks, not time. Block rates differ between chains, and on Avalanche they vary with load. An attacker can fill blocks with transactions to make them come at the fastest rate the chain allows. A block count chosen from average block times can then pass much sooner than intended. A timestamp avoids this.
    3. It defaults to 0. Pools get no protection until the protocol admin sets it on each one.
    4. Nothing requires it before going permissionless. setPermissionless(true) doesn't check that the delay is set.
    5. No size is recommended. The delay has to be at least the interval between rate updates, measured in that chain's fastest blocks.
    6. Large one-step moves still pay. Markdowns, recoveries and catch-ups gain far more than the yield lost during the delay. Only a redemption fee covers them, and the fee and its ceiling still default to 0.
    7. The reverse case is untouched. A holder who requests just before a markdown still fixes the old value. The delay only postpones the payout.
    8. Changing the delay affects requests already queued. openClaims reads the delay when it runs, so raising it holds back requests that are already open. The runbook should mention this.

    OpenTrade

    1 Push mode. setPermissionless(true) already reverts unless claim-based repayment is on. Permissionless pools therefore run only in claim mode, and openClaims is the gate.

    2 and 5. Blocks vs time, and sizing. Withdrawals settle within a window of up to 7 days. Over that window, the yield a redeemer gives up while waiting cancels out the rate step they would capture. Buying before a rate update and selling after it is a net loss, which matches the 5–7 day rows in your table.

    3 and 4. Defaults to 0, and no precondition in setPermissionless. We have controls on pool settings that make sure each pool's configuration is what we intend when it goes live. The transition to permissionless happens once, and it has already happened. There will be no more transitions to permissionless, so a check in setPermissionless would never be exercised.

    6 and 7. Large one-step moves. These pools are yield products, and the rate only moves up. Markdowns, and the recoveries after them, are not part of how the pools operate. So front-running a markdown, or depositing before a recovery, does not apply. In the event of a material adverse event, all protocol activity can be paused and any outstanding unfunded redemptions will be handled in accordance with jurisdictional laws and legal framework as outlined between Sierra Foundation and OpenTrade SPC.

    8 Changing the delay affects queued requests. Acknowledged. The NatSpec on setMinimumClaimRedeemBlocks already notes that a change applies to requests already in the queue.

    Cantina

    Acknowledged.

  2. An Avalanche mainnet deployment is controlled by keys derived from a mnemonic committed to the repository

    Severity

    Severity: High

    Submitted by

    0xRajeev


    Description

    This change replaces Hardhat's standard test mnemonic with a new 12-word mnemonic, UNSECURE_MNEMONIC, committed in hardhat.config.ts. It also makes it the fallback for DEPLOY_MNEMONIC whenever MNEMONIC_DEPLOY is not set (process.env.MNEMONIC_DEPLOY ?? UNSECURE_MNEMONIC). The qaavalanche network that uses DEPLOY_MNEMONIC is Avalanche mainnet (chainId: 43114).

    The deployment recorded in ignition/deployments/qaavalanche was made with keys derived from that public mnemonic:

    • Index 0, 0xdF12A200570031De615a9bC72041e37bcfe89CF5. It deployed the service configuration proxy and ran its initialize, which grants the caller DEFAULT_ADMIN_ROLE. It also deployed the registry, the three factories and the implementations.
    • Index 2, 0xAc1Cd9077A664fce06BdCCe9609E3f3a3A611516. It called setImplementation on all three beacons, so it holds DEPLOYER_ROLE.

    Anyone who reads the repository can derive both private keys. Unless these roles were later revoked on chain, anyone can therefore:

    • grant themselves every role in that service configuration;
    • point the pool, controller and allowlist beacons at their own code, which takes over every pool in that deployment (LytQaAvalanche, YosemiteQA) and any value or integrations they hold;
    • upgrade the service configuration and registry.

    Production deployments that use the Fireblocks signer (fbLytAvalanche) are not affected. Deploy configuration is outside the threat model's scope (§0), but the effect is the loss of the most privileged roles on a live mainnet deployment.

    Recommendation

    Consider the following:

    • Checking on chain whether 0xdF12…9CF5 and 0xAc1C…1516 still hold DEFAULT_ADMIN_ROLE or DEPLOYER_ROLE (or any other role) in the qaavalanche service configuration 0x7a80…3Ea6. If they do, moving every role to a safe key and revoking theirs right away, before this repository is shared further.
    • Removing the committed mnemonic. Making every mainnet network fail closed when MNEMONIC_DEPLOY is unset, rather than falling back to a development key.
    • Checking the expected signer address and chain id before any deployment transaction, and routing mainnet deployments through the Fireblocks signer.

    OpenTrade

    The mnemonic has been removed from the source code and the implementation will be deployed with secure fireblocks wallet going forward.

Medium Risk5 findings

  1. Revoking an override's role hands its powers to the issuer

    Severity

    Severity: Medium

    Submitted by

    0xRajeev


    Description

    issuerAdmin() re-checks ISSUER_ADMIN_ROLE on the override, and returns _settings.issuer once that role is revoked. depositManager() works the same way, falling back to issuerAdmin(). When the protocol admin revokes an override's role, the code therefore fails open to the issuer.

    This is intended (per Natspec and plan decision 8), but it removes the second key that the threat model relies on (§6): the issuer also controls the allowlist. The issuer can then allowlist any address and redirect any open redemption to it. It also inherits every other issuer-admin power with no timelock, along with the deposit manager's mints.

    Scenario:

    1. On pool P, the issuer admin override is timelock T (which holds ISSUER_ADMIN_ROLE), and the issuer is I.
    2. The protocol admin revokes ISSUER_ADMIN_ROLE from T, intending to name a replacement later.
    3. issuerAdmin() now returns I on every pool where T is the override.
    4. I calls allowParticipant(X), then changeRedemptionDestination(lender, uuid, X) on an open 1,000,000 USDC request.
    5. I calls processRedemptions([uuid]), and X receives the payout. No timelock or second key was involved.

    Recommendation

    Consider:

    1. Failing closed: if an override is set but no longer holds its role, returning address(0) (or reverting) until the protocol admin names a replacement.
    2. Falling back to the issuer only when no override was ever set. If the fallback stays, consider making "set a replacement, then revoke" a required step in the runbook.
  2. A pool's poolAdmin keeps protocol-admin powers after its role is revoked

    Severity

    Severity: Medium

    Submitted by

    0xRajeev


    Description

    Every onlyProtocolAdmin function is gated by:

    return isProtocolAdmin(msg.sender) || msg.sender == poolAdmin;

    The second clause accepts a pool's poolAdmin by address alone, without checking whether it still holds PROTOCOL_ADMIN. While the address holds the role, the clause adds nothing, because the first clause already passes. It only takes effect after the role is revoked, so revoking a protocol admin does not remove its control over the pools it administers. onlyAuthorized accepts poolAdmin by address in the same way, which covers the deposit and withdraw pauses.

    On those pools, the revoked address keeps:

    • Mode and limits: setPermissionless, setClaimBasedRepayment, setMaxRedemptionFeeBps, setMaxOpenRequests, and raising or clearing setDailyMintLimit.
    • Roles: setIssuer, setIssuerAdmin, setDepositManager and setPoolAdmin.
    • Pausing: pausePool, resumePool, addPoolPauser, removePoolPauser, and the deposit and withdraw pauses.
    • Lifecycle: transitionTo(Closed), which is permanent.

    The code and its own documentation disagree on what the check should be. The onlyProtocolAdmin Natspec says it "requires both protocol admin privileges and that the sender is the pool admin", which is an AND. The code is an OR.

    Scenario:

    1. Protocol admin A creates pool P, which sets poolAdmin = A.
    2. A's key is suspected compromised, so PROTOCOL_ADMIN is revoked from A.
    3. During the incident, the remaining admins pause P with pausePool().
    4. A calls resumePool() on P, then setPermissionless(true) and setDailyMintLimit(0). P is now open to anyone, with no mint limit.
    5. A keeps these powers until another protocol admin calls setPoolAdmin on P, and on every other pool A administers.

    Recommendation

    Consider the following:

    1. Re-checking the role on every call, with the check that matches the intended model:
      • Any protocol admin manages any pool (what the code does today): using isProtocolAdmin(msg.sender) alone.
      • Each pool is managed only by its own admin (what the Natspec describes): using isProtocolAdmin(msg.sender) && msg.sender == poolAdmin.
    2. Applying the same role check to poolAdmin in onlyAuthorized.
    3. Updating the Natspec to match whichever model is chosen.
  3. Revoking ISSUER_ROLE does not remove a pool's issuer

    Severity

    Severity: Medium

    Submitted by

    0xRajeev


    Description

    issuer() returns _settings.issuer without checking whether that address still holds ISSUER_ROLE. The role is checked only in initialize and setIssuer. Every issuer gate reads this value: _reqIssuer in the pool and onlyIssuer in LYTPoolAccessControl.

    The new code re-checks the roles of the issuer admin, the deposit manager and the pool pausers on every call, but not the issuer's. Its fallback makes this worse: when the issuer-admin override is revoked, issuerAdmin() falls back to the issuer, and still does not check the issuer's role.

    So after ISSUER_ROLE is revoked from a compromised issuer key, that key keeps:

    • Payouts. processRedemption in push mode, where the issuer chooses repayment and fees freely.
    • Liquidity. openClaims and transferToTreasuryWallet.
    • The allowlist. allowParticipant and removeParticipant.
    • Issuer-admin powers on any pool without a valid override, and deposit-manager powers on any pool without a deposit-manager override either.

    This lasts until a protocol admin calls setIssuer on every affected pool. The threat model puts "a revoked role that keeps working" in scope (§9).

    Scenario (push mode, permissionless):

    1. Issuer key I is suspected compromised, so the default admin revokes ISSUER_ROLE from I.
    2. The attacker deposits 1,000 USDC from address E and calls requestRedeem on the shares.
    3. The pool holds 2,000,000 USDC that the treasury sent for repayments.
    4. Using I, the attacker calls processRedemption(E, 2,000,000e6, 0, uuid). The destination is the lender E, so it passes _reqPayoutDestination. The pool sends all 2,000,000 USDC to E.

    Recommendation

    Consider the following:

    1. Re-checking the issuer's role when the issuer is resolved. For example, issuer() could return address(0) when _settings.issuer no longer holds ISSUER_ROLE, so every issuer gate fails closed.
    2. Applying the same check in the fallback branch of issuerAdmin().
  4. A blacklisted spender can still move other holders' shares with transferFrom

    Severity

    Severity: Medium

    Submitted by

    0xRajeev


    Description

    _update checks the blacklist on from and to only. transferFrom is inherited from ERC20Upgradeable without changes, so the spender (msg.sender) is never checked. A blacklisted address that holds allowances from other holders can still move their shares to any address that is not blacklisted. The threat model says a blacklisted address can do "nothing that moves shares" (§6), and invariant 9 relies on the blacklist being a full freeze.

    This matters most when the blacklisted address is a contract that holders have approved: a router, an aggregator, a vault adapter or a compromised integrator. Blacklisting it is the obvious emergency response, but its pull path keeps working. For example, USDC checks msg.sender in transferFrom for exactly this reason. Permissionless mode makes it matter more, because any address can now receive the shares and redeem them.

    Scenario:

    1. Holders have approved router R to move their pool shares, so that they can trade on a DEX pair.
    2. R is compromised, and the operator blacklists it.
    3. R calls transferFrom(holder, attacker, balance) for every approved holder. _update checks only holder and attacker, neither of which is blacklisted, so every transfer succeeds.
    4. In a permissionless pool, attacker calls requestRedeem on the shares, or sells them on a DEX pair.

    Recommendation

    Consider the following:

    1. Overriding transferFrom (or _spendAllowance) to revert when spender is blacklisted, matching USDC.
    2. Rejecting approve calls from or to blacklisted addresses.
  5. Revoking the treasury or fee-collector role does not stop payments to that address

    State

    Acknowledged

    Severity

    Severity: Medium

    Submitted by

    0xRajeev


    Description

    The treasury wallet must hold FIAT_GATEWAY. The fee collector must hold FEE_COLLECTOR. Both roles are checked only when the address is set, in initialize and the two setters. treasuryWalletAddr() and feeCollectorAddress() return the stored address. They never check the role again.

    Revoking the role therefore changes nothing on the pools. Funds keep flowing to the old address:

    • Treasury. deposit sends every deposit there. depositFromTransfer and transferToTreasuryWallet send pool liquidity there.
    • Fee collector. claim, processRedemption and processRedemptions send every redemption fee there. Lenders call claim themselves, so fees keep flowing without any action by the issuer.

    The way to stop it is setTreasuryWalletAddr or setFeeCollectorAddress. The issuer admin calls them behind a timelock. They have to be called on every pool that uses the address. Payments continue until then. Pausing deposits stops deposit and depositFromTransfer. It does not stop transferToTreasuryWallet or fee payments. A pool pause stops everything. But it also blocks the setters, so the address cannot be replaced while the pool is paused.

    Scenario:

    1. The treasury wallet's key is compromised. The protocol admin revokes its FIAT_GATEWAY role.
    2. A user deposits 1,000,000 USDC. deposit sends it to the compromised wallet.
    3. The issuer admin schedules setTreasuryWalletAddr on each pool. Every deposit made during the timelock delay also goes to the compromised wallet.
    4. The operator can call pauseDeposits to stop new deposits. The issuer can still call transferToTreasuryWallet and send free pool liquidity to the same wallet.

    Recommendation

    Consider the following:

    1. Re-checking the role when the address is read. If the treasury wallet no longer holds FIAT_GATEWAY, making treasuryWalletAddr() revert, so deposits and sweeps stop at once.
    2. Doing the same for the fee collector and FEE_COLLECTOR.
    3. Alternatively, reading both addresses from the service configuration, so one update covers every pool.

    OpenTrade

    These are set one time, the role is place to ensure both the protocol admin and the issuer admin agree to the assignment. These are in Fireblocks wallets with multiple signers. We can pause the Pool or the whole protocol not just the deposits because this scenario would be a considered a severe breach and the recovery would be tightly managed.

    Cantina

    Acknowledged.

Low Risk39 findings

  1. Anyone can fill the redemption queue or keep it from emptying in permissionless mode

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Any shareholder can open requests from as many addresses as they like. That allows three things:

    • Filling the queue. An attacker can take every maxOpenRequests slot, so honest requestRedeem calls revert with TooManyOpenRequests. Each time a slot is freed, the attacker can take it again.
    • Blocking admin transitions. setClaimBasedRepayment and transitionTo(Closed) require activeWithdrawCount() == 0, so a single open request blocks both.
    • Making recovery expensive.
      • Claim mode: cancelRedemption accepts only funded requests, so the issuer has to fund and open the attacker's requests before clearing them.
      • Push mode: pausing withdrawals also blocks processRedemption and processRedemptions. The only way to empty the set is then to cancel every request, including honest ones.

    Each slot costs the attacker minRedeemAssets of capital until payout, plus the fee on it. The capital itself comes back. Recovery is possible because an operator or poolAdmin can call pauseWithdraws (invariant 15), but this procedure is not documented.

    Scenario (maxOpenRequests = 500, minRedeemAssets = 1,000 USDC, fee 0.1%):

    1. The attacker deposits 500,000 USDC and spreads the shares across 5 addresses, because each address is capped at 101 requests.
    2. The attacker opens 500 requests of 1,000 USDC each.
    3. An honest lender's requestRedeem reverts with TooManyOpenRequests.
    4. Each time a request is paid, the attacker takes the freed slot in the next block. One full cycle costs them 500 USDC in fees.
    5. The protocol admin's setClaimBasedRepayment(false) reverts with RequestsStillOpen for as long as the attacker keeps going.

    Recommendation

    Consider the following:

    1. Removing the cap while the pool is permissionless. Not applying maxOpenRequests while the pool is permissionless, or removing it. Nothing onchain walks the open set then. openClaims and claim run in constant time. The mode-switch and close guards read activeWithdrawCount(). getActiveWithdraws already returns an empty list, and the server builds the open set from events. Without a cap, no one can be locked out of requesting.
    2. Guarding the way back. Making setPermissionless(false) revert while activeWithdrawCount() is above what getActiveWithdraws can safely walk. This is the only reason the cap exists. Admins then drain the queue before switching.
    3. Pricing each request. Sizing minRedeemAssets and the fee against the attacker's cost of capital, not only against gas. Without a cap, these are what make spam expensive.
    4. Clearing the queue. Removing withdrawStateActive from processRedemptions, so that push mode can drain the queue while withdrawals are paused. Letting cancelRedemption remove requests above the cutoff in claim mode, adjusting the cumulative accounting to match (for example, with a cancelledAboveCutoff term). These address the blocked mode switch and close, which removing the cap does not.
    5. Operations. Turning on claim mode before permissionless mode, as the plan requires. Documenting the recovery procedure in the runbook: pauseWithdraws, then fund and claim or cancel.

    OpenTrade

    We have decided to monitor the open requests and set max to a very large number so that there is no blocking.

    Cantina

    Acknowledged.

  2. Any depositor can use up the shared daily mint limit

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    dailyMintLimit is a single per-pool counter shared by deposit, depositOffChain, depositFromTransfer and adjustment credits. The spec asks for exactly that (plan decision 7). With permissionless mode on, though, anyone can use up the whole day's limit. The deposit manager then cannot record client funds that have already arrived, and corrective credits revert until 00:00 UTC.

    The attacker's capital earns yield and can be redeemed for the fee, so this griefing is cheap and can be repeated every day. It can also be timed to land just before a known institutional deposit.

    Scenario (dailyMintLimit = 5,000,000 USDC):

    1. At 00:00 UTC the attacker deposits 5,000,000 USDC.
    2. A client's 2,000,000 USDC wire has already reached the treasury. The deposit manager's depositOffChain reverts with DailyMintLimitExceeded.
    3. A corrective addAdjustmentAmount credit and every other depositor's deposit also revert until the next UTC day.
    4. The attacker requests redemption, gets the capital back less the fee, and repeats the next day.

    Recommendation

    Consider keeping separate limits for permissionless deposit and for deposit-manager mints (including credits), or limiting each depositor per day.

    OpenTrade

    We are ok with this. Yes the protect is protocol wide, its really a protect for the startup phase, Starting with the pool with the existing investment really minimizes this, the interest accrued in a day gets out of wack the ration of the inflows to the total pool holding gets to high the net interest can drop. That said we will be also all over it when it goes live to get the stuff invest ASAP. we will have the cap set lower and increase it as the invest amount increases. I think Mitch indicated we are good with this.

    Cantina

    Acknowledged.

  3. Lowering the fee ceiling does not lower the current fee

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    setMaxRedemptionFeeBps does not compare the new ceiling with the current _redemptionFeeBps. A fee above the new ceiling stays in force until the issuer admin lowers it. That breaks invariant 14 (redemptionFeeBps <= maxRedemptionFeeBps) and defeats the purpose of the ceiling.

    Scenario:

    1. The ceiling is 100 bps, and the issuer admin has set the fee to 100 bps.
    2. The protocol admin lowers the ceiling to 20 bps.
    3. New requests are still charged 1% until the issuer admin decides to lower the fee.

    Recommendation

    Consider clamping the fee to the new ceiling and emitting RedemptionFeeBpsSet when the new ceiling is below the current fee, or reverting.

    OpenTrade

    setMaxRedemptionFeeBps is protection for a error in fee rate setting and will rarely if ever change

    Cantina

    Acknowledged.

  4. Pausing withdrawals blocks processRedemptions

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    processRedemptions has withdrawStateActive, but claim, openClaims and cancelRedemption do not. The spec says pausing withdrawals stops requestRedeem only. In claim mode, a withdraw pause therefore blocks the issuer's batch payout while lenders can still claim. In push mode, nothing can pay requests during a withdraw pause, because processRedemption already had the same modifier. A flooded queue then cannot be drained during a withdraw pause except by cancelling every request. changeRedemptionDestination carries the same modifier, so during a withdraw pause a payout whose destination has been removed from the allowlist cannot be repaired, only cancelled.

    Scenario:

    1. In claim mode, an operator calls pauseWithdraws during an incident.
    2. Lenders who claim are paid, but the issuer's processRedemptions([...]) reverts with PoolInvalidState.
    3. In push mode, every open request stays unpaid until withdrawals resume, and resuming lets an attacker refill the queue.

    Recommendation

    Consider removing withdrawStateActive from processRedemptions and changeRedemptionDestination, and from processRedemption too if repayments should continue during a withdraw pause.

    OpenTrade

    Commit #e93dd53.

    Cantina

    Commit #e93dd53 removes withdrawStateActive from processRedemption and processRedemptions, so the issuer can pay redemptions during a withdraw pause in both modes. But changeRedemptionDestination still carries the gate, so payouts can't be redirected while withdrawals are paused.

  5. previewRedeemRequest returns the amount after the fee, but requestRedeem returns the amount before it

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    previewRedeemRequest returns assets − fee, while requestRedeem and RedeemRequested report the full assets. Whenever a fee is set, the two differ by the whole fee. Integrators that compare them will misreport or reject redemptions. These include the Pendle wrapper, aggregators and the server.

    Scenario (fee 50 bps, shares worth 10,000 USDC):

    1. previewRedeemRequest(shares) returns 9,950.
    2. requestRedeem(shares) returns 10,000, and RedeemRequested logs 10,000.
    3. The lender is eventually paid 9,950. An integrator that checked the return value against the preview reverts or records the wrong amount.

    Recommendation

    Consider returning the amount after the fee from requestRedeem, or returning (assets, fee), and documenting which figure each function and event reports.

  6. requestRedeem has no minimum-output protection

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    requestRedeem(shares) fixes the payout at the rate and fee in effect when it executes, and the lender cannot cancel. A rate drop or a fee increase that is mined first fixes a lower payout than the lender saw.

    Scenario:

    1. A lender sees previewRedeemRequest = 9,950 USDC and submits requestRedeem.
    2. A −2% markdown lands first.
    3. The request fixes 9,751 USDC, and the lender cannot cancel it.

    Recommendation

    Consider adding requestRedeem(uint256 shares, uint256 minAssetsOut), which reverts if assets − fee < minAssetsOut, and keeping the one-argument version for existing callers.

    OpenTrade

    I think we should handle this operationally, we will rarely raise or lower the fee rate. I do not want to change the params to requestRedeem, because it would effect existing integrations that are in production

    We do not want to change the parameters of request redeem for backward compatibility reasons. Fees rarely change and exchange rates only go up.

    Cantina

    Acknowledged.

  7. The required order for enabling permissionless mode is not enforced onchain

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Threat model §13 requires the following steps before setPermissionless(true):

    • Claim mode turned on.
    • Nonzero maxOpenRequests and minRedeemAssets.
    • A dailyMintLimit set.

    It also notes that this order is not enforced onchain. However, it is better that it is enforced. At the defaults, permissionless mode allows:

    • An unbounded number of requests, each worth as little as 1 wei.
    • Unlimited minting with no fee, so front-running exchange-rate updates pays in full.

    If the mode is later switched off with a large open set, getActiveWithdraws and getLYTPoolOverviewState walk the whole set and can run out of gas (§11).

    Scenario:

    1. The protocol admin calls setPermissionless(true) on a pool that was just upgraded and still has every default.
    2. An attacker funds 200 addresses and opens 101 requests worth 1 wei each from every one of them, about 20,000 requests in total.
    3. Later the protocol admin calls setPermissionless(false). getLYTPoolOverviewState now runs out of gas, and the server's overview of the pool fails.

    Recommendation

    Consider:

    1. Making setPermissionless(true) revert unless maxOpenRequests, minRedeemAssets, dailyMintLimit and the redemption fee are all nonzero.
    2. Making setPermissionless(true) revert unless claim mode is on, if claim mode is required.
    3. Making setPermissionless(false) revert while activeWithdrawCount() is above what getActiveWithdraws can safely walk.

    OpenTrade

    Commit #731eb91.

    Cantina

    Commit #731eb91 fixes the concerns but relies on three operational commitments: Permissionless is never switched off, claim mode is never switched off, and the protections are never loosened.

  8. Whoever can pause can also unpause, and pool pausers get protocol-wide unpause

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Pause and resume use the same authority. Any listed pool pauser can lift a pool pause that the protocol admin set. This is documented as a deliberate choice.

    A pool pauser must hold the global PAUSER_ROLE. That role also gates setPaused in both directions. Gating setPaused this way predates this change, but now naming a per-pool pauser also gives that key the power to pause and unpause the whole protocol.

    A compromised pauser key can therefore undo an emergency pause, either on its own pools or across the whole protocol, until its role is revoked.

    Scenario:

    1. The protocol admin lists P as the pauser for pool A and Q as the pauser for B, which requires granting P and Q the PAUSER_ROLE across the protocol.
    2. During an incident affecting pools A and B, P and Q call pausePool() on A and B respectively.
    3. However, P's key has been unknowingly compromised.
    4. Using the compromised key, setPaused(false) is called, which unpauses both A and B.
    5. The pools stay open until the admin revokes PAUSER_ROLE from P and pauses again.

    Recommendation

    Consider:

    1. A separate POOL_PAUSER_ROLE for pool-level pausers that is separate from protocol-wide PAUSER_ROLE.
    2. UNPAUSER_ROLE and POOL_UNPAUSER_ROLE to specifically authorize unpause (typically considered more privileged than pause), for both setPaused(false) and resumePool.

    OpenTrade

    We are ok whith this , these are roles with multi signature fireblocks wallets.

    Cantina

    Acknowledged.

  9. Holders whose whole balance is below minRedeemAssets can never redeem

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    requestRedeemExecute reverts with RedeemBelowMinimum whenever assets < minRedeemAssets, even when the request is for the holder's entire free balance. A holder whose whole position is worth less than the minimum can never redeem. This affects small holders who bought on a DEX pair or received shares by transfer, and any holder left with a remainder after an earlier request. Their only exit is to sell or transfer the shares.

    The issuer admin sets the minimum with no upper bound. Raising it far enough therefore acts as a withdrawal freeze for every holder below that level, without going through pauseWithdraws (which the operator or poolAdmin controls) and without a WithdrawStateTransition event.

    Scenario (minRedeemAssets = 1,000 USDC):

    1. A holder owns shares worth 600 USDC, bought on a DEX pair.
    2. requestRedeem(allShares) reverts with RedeemBelowMinimum.
    3. The holder can never redeem through the pool, however long they wait.

    Recommendation

    Consider the following:

    1. Exempting a request for the holder's entire free balance (shares == _freeShares(lender)) from the minimum.
    2. Letting the protocol admin set a ceiling on minRedeemAssets, in the same way as for the redemption fee.

    OpenTrade

    Keeping this as is Actually, If we do #1, a user could just transfer 0.00001 to different wallets and still flood the redeems. doing #2 just makes setting a minRedeemAssets a 2 step process, dont change the ability just 2 different wallets we control

    Cantina

    Acknowledged.

  10. A lender can make the issuer's batch payout revert by claiming one of its requests first

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    processRedemptions is all-or-nothing: if any uuid in the batch is no longer open, _requireOpenRequest reverts with RedemptionNotFound and the whole batch fails. In claim mode, the lender of any opened request can claim it at any time. A lender who watches the mempool can therefore claim one of their requests just ahead of every batch that includes it. Each batch fails, and every other lender in it waits for the issuer to rebuild and resend.

    Each claim breaks one batch. But an attacker with many small opened requests, up to maxOpenRequests, can break batches over and over. The attacker loses nothing, because each claim pays out the request as they wanted anyway.

    Scenario:

    1. Claim mode. The issuer has opened 300 requests and sends processRedemptions with all 300. Twenty of them belong to the attacker.
    2. The attacker sees the transaction and calls claim on one of their 20 requests with a higher priority fee.
    3. The batch reverts. The issuer removes that uuid and sends again, and the attacker repeats this with the next of their requests.
    4. The other 280 lenders are paid only after up to 20 failed attempts.

    Recommendation

    Consider the following:

    1. Skipping any uuid that is no longer open in processRedemptions instead of reverting. For example, using continue when _activeWithdraws[uuid].stage != Requested.
    2. Still reverting for the other failures (blacklist, destination).
    3. Optionally, returning or emitting the uuids that were skipped.

    OpenTrade

    We are ok with this, there is no double payment, and process redemptions is the exception case for exceptionally long unclaimed assets. the issuer can always batch the process redemptions in smaller batches excluding the attacker

    Cantina

    Acknowledged.

  11. A single large request holds up every later request in claim mode

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    In claim mode, requests are paid strictly in block order:

    • openClaims opens a whole prefix of blocks from the cutoff, and requests in the same block are always opened together.
    • processRedemptions and cancelRedemption both refuse requests above the cutoff.

    So the treasury has no way to pay a request without first funding every request in an earlier block, and every request in the same block. That leads to two forms of griefing:

    • Holding up the queue. A large request (holders can buy shares on the DEX pairs) holds up every request after it until the treasury can fund it in full. Blocks can't be opened in part, and an unfunded request can't be skipped or cancelled.
    • Tying a victim to a large request. An attacker who sees a victim's requestRedeem in the mempool can send a large request in the same block. The victim's request can then only be opened once the attacker's is funded as well.

    Neither attack makes a profit. The attacker's request is a real redemption, and while it waits it earns nothing and pays the fee. But the attacker controls how long honest lenders wait, which the threat model rates as a liveness issue (§10).

    Scenario (claim mode, the treasury can raise 2,000,000 USDC a day):

    1. A victim requests 50,000 USDC at block b. The attacker front-runs it with a request for 20,000,000 USDC, also at block b.
    2. openClaims for block b needs 20,050,000 USDC of free balance.
    3. The victim can't be paid for about 10 days, and neither can any lender who requested after block b.

    Recommendation

    Consider the following:

    1. Ordering the queue by request instead of by block. Each request already has a unique cumulative position. It is cumulativeAssetsTotal after the request, which is already in its uuid preimage. This means storing it on the request, holding the cutoff as a cumulative amount, and making isClaimable read cumulativeAfter <= cumulativeAtCutoff. openClaims then opens exactly up to the named request, still in constant time. A victim is no longer tied to a larger request in the same block.
    2. Letting the issuer admin cancel requests above the cutoff by recording a cancelled amount that the next openClaims subtracts, to keep the rest of the queue moving.
    3. Alternatively, capping the size of a single request.

    OpenTrade

    Commit #abc757e.

    Cantina

    Commit #abc757e lets the protocol admin list lenders whose claim-mode requests stay out of the claim queue. The issuer pays those requests from free liquidity with processRedemption or processRedemptions, so a known large holder can no longer hold up everyone queued behind them. However, the concern from this finding is still open. An anonymous attacker uses an address that isn't listed, so their large request still enters the queue and still holds up every later request, including a victim's in the same block. The fix doesn't order the queue by request, cap the size of a single request, or allow cancelling queued requests above the cutoff. It also brings in two new points: 1) Excluded lenders rely entirely on the issuer, who sets their payout amount even in claim mode 2) Their payouts draw on the same free balance the treasury sends to fund the queue.

    OpenTrade

    We can manually select the redemption to push to the wallets as well as exclude a specific wallet till the fund to cover a redemption are available.

  12. A holder can evade blacklisting, delisting and debits

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    The protocol has three ways to act against a holder: blacklist (operator), removeParticipant (issuer) and an adjustment debit (issuer admin, behind a timelock). Each can be front-run or worked around:

    • Moving shares away. The recipient of a transfer does not need to be on the allowlist. A holder may see a blacklist or a debit coming in the mempool or in the timelock's CallScheduled event. They can then move their free shares to a new address. While the pool is permissionless, that address can redeem the shares straight away. Before this change, the new address could not redeem until the issuer allowlisted it, so the allowlist acted as a final check. Permissionless mode removes it.
    • removeParticipant does not freeze shares. _update never checks the allowlist. A holder removed from it can still transfer at any time, with no need to front-run. They can send shares to an allowlisted accomplice or sell them on a DEX pair. The recipient can then redeem. In a permissionless pool, removing a holder has no effect on them at all.
    • Locking shares in a request. A debit can only take free shares. A holder who calls requestRedeem during the timelock delay locks their shares in the request. The debit then reverts with BurnExceedsBalance.
    • Blacklisting makes the debit impossible. A debit burns through _update, which checks the blacklist on the sender. Blacklisting the holder to freeze their shares therefore makes a later debit revert. Debiting requires removing them from the blacklist, which frees the shares again.
    • Delisting makes the debit impossible too. addAdjustmentAmount checks _accessAllowed(lender) before it debits. Outside permissionless mode, a holder removed with removeParticipant can no longer be debited: the call reverts with NotMarketMaker. Debiting requires adding them back to the allowlist first.

    As a result, a corrective debit cannot be enforced against a holder who is paying attention. One example is a debit to reverse a mistaken mint or credit.

    Scenarios: In each one, the deposit manager has mistakenly credited 1,000,000 USDC of shares to H. The protocol wants them back.

    1. Moving shares ahead of a blacklist (permissionless pool).

    1. The operator sends blacklist(H).
    2. H sees it in the mempool and front-runs it with transfer(H2, shares). H2 is a fresh address. Receiving shares needs no allowlist.
    3. The blacklist lands on an address with no shares. H2 calls requestRedeem, which the permissionless pool allows. H2 is paid at settlement.
    4. Blacklisting H2 blocks that payout, but H2 can front-run the second blacklist the same way. Even if it lands, the shares are only frozen. They are not recovered, because a debit of H2 is ruled out too (scenario 4).

    2. removeParticipant does not need to be front-run.

    1. The pool is allowlisted. The issuer calls removeParticipant(H).
    2. H can no longer deposit, request or claim. But _update never checks the allowlist, so H can still transfer. H sends the shares to an allowlisted accomplice or sells them on a DEX pair. The accomplice or buyer redeems them.
    3. In a permissionless pool, removeParticipant has no effect on H at all.

    3. Locking shares in a request ahead of a debit.

    1. The issuer admin schedules addAdjustmentAmount(H, 1,000,000e6, 0) through its timelock.
    2. H sees CallScheduled and calls requestRedeem for all their shares, so H has no free shares left.
    3. The debit executes and reverts with BurnExceedsBalance.
    4. In push mode, the issuer admin can schedule cancelRedemption and the debit as one timelock batch. H cannot step in between, but this costs a second delay. In claim mode, cancelRedemption reverts with NotClaimable until the request is opened. Opening it lets H call claim and be paid before the batch executes. Stopping that claim takes a blacklist, which leads to scenario 4.

    4. Blacklisting rules out the debit.

    1. To stop H moving or locking the shares during the timelock delay, the operator blacklists H first. Transfers and requestRedeem now revert for H.
    2. The debit executes. _burn runs _update, whose notBlacklistedTransfer(H, address(0)) reverts.
    3. The debit can only succeed once H is unblacklisted. The operator's unblacklist and the issuer admin's debit come from different keys, so they are separate transactions. H can transfer or request in between, which brings back scenario 1 or 3.

    5. Delisting rules out the debit.

    1. The pool is allowlisted. The issuer calls removeParticipant(H) to stop H redeeming.
    2. The debit executes and reverts with NotMarketMaker, because addAdjustmentAmount checks _accessAllowed(H).
    3. The debit can only succeed once the issuer calls allowParticipant(H) again. H can then call requestRedeem before the debit lands, which brings back scenario 3.

    Recommendation

    Consider the following:

    1. Letting a debit burn from a blacklisted or delisted holder. This means skipping the blacklist check in _update when to == address(0) and the caller is the controller, and skipping the _accessAllowed check in addAdjustmentAmount for debits.
    2. Relying on "freeze first, then debit" as the enforcement sequence. Blacklisting the holder already blocks requests, because requestRedeem checks notBlacklisted(msg.sender). It also blocks transfers through _update. With the bypass above, the debit can run against the frozen holder without unblacklisting them. The shares are never free in between.
    3. Adding a freeze that blocks transfers and requests but still allows a debit.
    4. Giving debits a short-delay path, or freezing the holder first, if the issuer admin's timelock delay applies to debits. Then the schedule does not warn them.
    5. Sending enforcement transactions through a private mempool or relay so they are not visible before they land. Examples are Flashbots Protect and MEV Blocker on Ethereum. This covers blacklist, cancelRedemption and the timelock's execute of a debit. The front-running in scenarios 1, 3 and 4 assumes a public mempool. A private relay does not hide the timelock's CallScheduled event, which goes on chain when the call is scheduled. It also does not help with scenario 2, which needs no front-running. On a chain without a private relay, the bypass and the freeze above are the only fix.

    OpenTrade

    Commit #c6b335e.

    Cantina

    Commit #c6b335e lets a debit ignore the blacklist and no longer need the holder to be allowed. Rest of the concerns remain unfixed.

  13. Timelocked deposit recording is priced and checked at execution, not when funds arrive

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    The spec puts depositOffChain and depositFromTransfer behind their own timelock. Both functions mint assets × 1e18 / exchangeRate() using the rate when the call executes, not the rate when the client's funds arrived. The amount of USDC is fixed when the call is scheduled, but the price is not. As a result:

    • The client carries rate moves during the delay. On a Linear, Compounding or Term pool the client receives fewer shares the longer the delay. If a markdown lands during the delay, the client receives more shares, at the cost of the other holders. Both outcomes are visible in advance, so a client who knows about a markdown can time a wire to fall inside the window.
    • The scheduled call can fail at execution. The call reverts if dailyMintLimit has been used up by then. It also reverts if openClaims has already used the client's direct transfer, or transferToTreasuryWallet has swept it. The operation stays ready in the timelock, so it can be retried without a new delay. But the client holds no shares until the limit resets or the liquidity comes back.

    Scenarios: In each one, the deposit manager is a TimelockController with a 2-day delay.

    1. A markdown during the delay (Dynamic pool).

    1. A client wires 5,000,000 USDC at rate 1.00. depositOffChain(5,000,000e6, client, txHash) is scheduled.
    2. A −2% markdown is posted the next day.
    3. The call executes at rate 0.98 and mints 5,102,040 shares instead of 5,000,000. The extra 102,040 shares (about $100,000) come at the expense of existing holders.

    2. The daily mint limit is used up before execution (permissionless pool).

    1. dailyMintLimit is 10,000,000 USDC. A client wires 5,000,000 USDC, and depositOffChain(5,000,000e6, client, txHash) is scheduled.
    2. On the day the call becomes ready, other lenders' deposit calls have already used 6,000,000 USDC of the day's limit.
    3. The call executes and reverts with DailyMintLimitExceeded. The executor has to wait for the count to reset at 00:00 UTC and retry.
    4. Anyone can fill the limit with deposit just before each retry. The depositor receives shares for their deposit, so this costs them little. Each retry is pushed back a day, and the client carries the rate moves of every extra day, as in scenario 1.

    3. The direct transfer is swept before execution.

    1. A client transfers 5,000,000 USDC straight to the pool, and depositFromTransfer(5,000,000e6, client, txHash) is scheduled.
    2. During the delay, the issuer sweeps the pool with transferToTreasuryWallet(freeAssets()). In claim mode, openClaims could reserve the same balance for redemptions instead.
    3. The call executes and reverts with ClaimedAssetsReserved, because freeAssets() is now below 5,000,000 USDC.
    4. The executor can only retry once the treasury sends the funds back. The check is against the pool's whole free balance, not the client's transfer. So the call succeeds as soon as any 5,000,000 USDC of free liquidity is in the pool, whoever it came from.

    Recommendation

    Consider the following:

    1. Passing the share amount into these functions, or a rate fixed when the funds arrived, together with a bound on how far it may differ from the current rate.
    2. Alternatively, keeping the deposit-manager delay shorter than the interval between rate updates.
    3. Exempting mints of client funds already received from the shared daily limit.

    OpenTrade

    Deposit manager will be set to a 1 hour limit. These are for large unique transactions and will be handled manually.

    Cantina

    Acknowledged.

  14. A wrong rate or an unbacked mint can be sold on the DEX pairs at once, and share issuance is unbounded

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    DEX pairs hold the pool's shares. A share created by mistake or by a compromised key can be sold there in the same block, without going through the redemption queue. Redemption fees and payout delays do not apply on this path. Three gaps leave share issuance unbounded:

    • Rates have no bounds and no delay. Each rate setter only checks that the rate is nonzero. Automation can call them directly, so a new rate applies at once. Nothing limits how far or how often the rate moves. The Linear and Compounding setters also accept any per-day change.
    • The mint limit counts assets, not shares. _recordMint counts assets, but deposit mints assets × 1e18 / rate. At a wrong rate, 1 USDC within the limit can mint any number of shares.
    • The mint limit is off by default. dailyMintLimit starts at 0, which means no limit. In permissionless mode, the deposit manager can mint to any address, including a DEX pair.

    Before this change, only allowlisted market makers could deposit, and minted shares needed an allowlisted recipient. A wrong rate or a rogue mint could only be cashed out through someone the issuer had approved. Permissionless mode removes that check. A compromised automation key, an automation bug such as a decimals error, or a compromised deposit manager can now be exploited by anyone within one block.

    Scenarios:

    1. A wrong rate post (Dynamic pool, permissionless, dailyMintLimit = 5,000,000 USDC).

    1. Automation posts setExchangeRateDynamic(1e12) instead of 1e18, by mistake or through a compromised key.
    2. In the next transaction, anyone deposits 1,000 USDC and receives 1,000,000,000 shares. This uses only 1,000 USDC of the limit.
    3. They sell the shares into the DEX pairs for all of the pairs' other assets. Correcting the rate afterwards does not help the liquidity providers.

    2. A compromised deposit manager (permissionless, default limit).

    1. The attacker calls depositOffChain(100,000,000e6, pair, 0x0). The pair passes _accessAllowed because the pool is permissionless, and the limit is 0.
    2. The pair now holds more pool shares than its reserves record. The attacker calls swap to take the surplus as USDC.

    Recommendation

    Consider the following:

    1. Bounding rate updates. Adding protocol-admin settings for the largest change per update, in bps, and the minimum time between updates. Capping the Linear and Compounding per-day parameters. Allowing larger changes only through the timelocked issuer admin, not automation.
    2. Bounding shares as well as assets. Counting the shares minted per day, or rejecting a deposit when the rate is outside the update bound from a reference rate.
    3. Requiring a nonzero dailyMintLimit before setPermissionless(true), and adding a separate, smaller limit for the deposit manager.
    4. Monitoring and pausing. Watching rate updates and large mints, and keeping a pool pauser ready to call pausePool.

    OpenTrade

    We have bounded rate updates in our fireblocks cosigner. Checking #3 fix in https://github.com/opentrade-io/LytPool/commit/d9100634f8fc983073607e9a249d6996fbcd2516

    Cantina

    Commit # adds Recommendation (3).

  15. By default the issuer holds every issuer-admin and deposit-manager power

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    When no override is set, issuerAdmin() returns the issuer and depositManager() returns issuerAdmin(). That is the state of every new pool, and of any live pool where no override was set. createPool cannot set the overrides atomically, and setIssuerAdmin accepts the issuer's own address, provided it holds ISSUER_ADMIN_ROLE.

    In that state, one key, usually an operational EOA with no timelock, controls everything that is meant to need two keys or a delay:

    • The allowlist.
    • Redirecting payouts.
    • Every way of minting without USDC.
    • Rates, alongside automation.
    • Fees and minimums.
    • Cancellations and debits.

    The two-key rule for redirects, which the threat model relies on (§6), does not hold. The protocol starts in this state, and revoking an override's role later returns it there. Threat model §13 asks operators to set the override, but nothing enforces it.

    Scenario:

    1. The protocol admin creates a new pool, or upgrades a live pool that has no override.
    2. Until setIssuerAdmin and setDepositManager are called, the issuer can allowlist X, redirect any open redemption to X, and mint shares through depositOffChain, all with no delay.
    3. If the issuer key is compromised in that window, nothing stops it.

    Recommendation

    Consider the following:

    1. Making setIssuerAdmin and setDepositManager reject the issuer's address, and reject each other's.
    2. Letting the factory set both overrides when it creates a pool.
    3. Making setPermissionless(true) require both overrides to be set, valid and distinct from the issuer.
    4. Setting the overrides for the live pools in the same upgrade batch.
  16. Deposit transaction hashes are not recorded allowing one deposit to be credited twice

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Both functions take a transferTxHash that identifies the client's payment, but neither one stores it, so nothing stops the same payment being credited twice. depositOffChain even accepts a zero hash. A deposit call that fails at execution, for example on the daily mint limit, has to be retried. The timelock blocks running the same operation twice, but a call scheduled again with a new salt, a second scheduled copy, or a mistake by the deposit manager all mint again for the same funds.

    Two related gaps:

    • Across functions. The two functions hash different things, and neither keeps a record. So one client payment can be credited once by depositOffChain and again by depositFromTransfer.
    • Adjustments. addAdjustmentAmount takes no reference of any kind. A credit or debit scheduled twice, or scheduled again after a failure, is applied twice.

    Scenario:

    1. depositFromTransfer(5,000,000e6, client, txHash) is scheduled. It reverts at execution because the day's mint limit has been used up.
    2. The operator schedules it again with a new salt. Later, the first operation is also retried and succeeds after the limit resets.
    3. The second operation succeeds too. The client receives shares for 10,000,000 USDC against one 5,000,000 USDC transfer. Both deposits use the pool's free balance, which treasury funding can supply.

    Recommendation

    Consider the following:

    1. Storing every credited transferTxHash in one mapping shared by both deposit functions, and reverting when one is reused.
    2. Requiring a nonzero hash in depositOffChain.
    3. Giving addAdjustmentAmount a reference id, recorded the same way, so a correction can be applied only once.

    OpenTrade

    Acknowledged, no contract change. The contract isn't what stops a payment being credited twice. The deposit manager only mints after the client's payment has been verified and reconciled off-chain, and that check is what blocks a second credit for the same payment. This process has been running on our permissioned vaults in production for some time. Making a vault permissionless doesn't affect it: the flag only removes the allow-list check on the lender. The deposit manager still decides who is credited, how much, and how many times. The hash is emitted in PoolDeposit so every mint can be matched to a payment during reconciliation, and a duplicate would show up there too. depositOffChain accepts a zero hash because some off-chain payments have no on-chain transaction to reference.

    Cantina

    Acknowledged.

  17. Switching permissionless off strands requests made while it was on

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Every settlement path checks _accessAllowed(lender) again when it pays out, not just when the request is made. After setPermissionless(false), a request accepted while the pool was permissionless, from a lender who is not on the allowlist, can no longer be settled at all:

    • the lender's own claim reverts with NotMarketMaker
    • so does the issuer admin's claim on the lender's behalf
    • processRedemption and processRedemptions revert too. A request like this anywhere in a batch makes the whole batch revert.

    The only way out is cancelRedemption. In claim mode it first requires the request to be funded and opened. It then returns the shares to a holder who can no longer request redemption, because requestRedeem is gated by onlyMarketMaker. That holder can only sell or transfer. Until every such request is cancelled, the open set is not empty, so the protocol admin cannot switch modes or close the pool either.

    setClaimBasedRepayment refuses to run while requests are open. setPermissionless(false) has no equivalent check, and it does not honour requests it already accepted.

    Checking the allowlist again at payout predates this change, and it lets the issuer stop paying a lender it delists. The new problem is that it now also catches lenders who were never on the list.

    Scenario (claim mode, permissionless):

    1. L, who is not on the allowlist, buys shares on the Uniswap pair and requests redemption of 100,000 USDC.
    2. The protocol admin calls setPermissionless(false).
    3. The issuer funds and opens the range that includes L's request. L's claim reverts, and so does the issuer admin's claim for L. A processRedemptions batch that includes L reverts for everyone.
    4. The issuer admin cancels the request. L gets the shares back, cannot request again, and has to sell them on the DEX, at whatever discount the market sets.

    Recommendation

    Consider the following:

    1. Deciding explicitly how requests accepted while permissionless are treated, by either:
      • recording in _withdrawExtra whether the request was made while the pool was permissionless, and letting it settle without the allowlist check (the blacklist check stays) or
      • making setPermissionless(false) revert while any request from a lender not on the allowlist is open, the same way setClaimBasedRepayment does.
    2. Documenting the procedure for switching off: allowlisting or settling every open request first.

    OpenTrade

    Business wise we would never turn off permissionless, but it is not enforced in the code.

    Cantina

    Acknowledged.

  18. Switching between push and claim mode first requires emptying the queue, which in claim mode means funding all of it

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    setClaimBasedRepayment reverts with RequestsStillOpen unless activeWithdrawCount() == 0. That is what keeps the two modes' accounting apart. It also means a mode switch always lands on lenders whose requests have already been accepted, even when no one is attacking the pool, because every open request has to be closed by payment or cancellation before the switch:

    • Leaving claim mode needs the whole queue funded. In claim mode, cancelRedemption and processRedemptions accept only requests that openClaims has already funded. Closing every open request therefore needs liquidity in the pool equal to the entire outstanding queue, even if every request is then cancelled. The likeliest reason to leave claim mode is a shortfall: for example, to pay part of each request, or pay off chain, through push mode. In that case the switch can't be made. Until withdrawals are paused, new requests keep arriving.

    • Leaving push mode means paying or cancelling every request.

      • Paying needs withdrawals active, because both push-mode settlement functions check withdrawStateActive, and while withdrawals are active new requests keep arriving.
      • Cancelling takes away the lender's recorded payout and their place in the queue. They get their shares back and have to request again at the current rate and fee. If the rate has fallen since, they are paid less, and the issuer admin decides whose request is cancelled.

      This includes every request open since before the upgrade. It is a step the live pools must take before they can turn on claim mode, which threat model §13 requires before permissionless mode.

    • Finding the requests to close. While the pool is permissionless, getActiveWithdraws returns an empty array, and no other function lists the open requests. The requests blocking the switch can then be found only through the server's log index. If the index has missed one, the switch keeps reverting and there is no way to find the missing request on chain. Turning permissionless off to read the list has problems of its own: lenders who aren't on the allowlist can no longer be paid, and a large list may be too long to read.

    • Funded requests that are never claimed. In claim mode, a funded request that is never claimed has to be cancelled before the switch. That takes away a payout the lender was already entitled to.

    Scenarios:

    1. A shortfall in claim mode.

    1. Claim mode, permissionless. There are 40,000,000 USDC of open requests above the cutoff, and the treasury can fund 5,000,000 USDC this week.
    2. The issuer wants to switch to push mode to pay every lender pro rata. setClaimBasedRepayment(false) reverts with RequestsStillOpen.
    3. The operator pauses withdrawals. Requests above the cutoff can't be cancelled or repaid until they are funded. The switch has to wait until the treasury has 40,000,000 USDC in the pool, which is the shortfall the switch was meant to work around.

    2. Turning on claim mode on a live pool.

    1. A live push-mode pool has 120 open requests, some from before the upgrade, worth 8,000,000 USDC.
    2. To switch to claim mode, the issuer either pays them all with withdrawals active, while new requests keep arriving, or pauses withdrawals and cancels them. Cancelled lenders get their shares back. After a −2% markdown, a lender who re-requests receives 2% less than the original request recorded.

    Recommendation

    Consider the following:

    1. Letting claim mode's unfunded requests carry over into push mode. Allowing setClaimBasedRepayment(false) whenever unclaimedAssets == 0, even if requests above the cutoff are still open. Leaving claimCutoffBlock where it is in that direction, without resetting it to the current block, so that those requests stay not claimable and push-mode settlement never touches the reserve. Push-mode settlement can then pay them, in part or in full.
    2. Adding a paginated list of open requests (for example, getActiveWithdrawKeys(offset, limit)) that works while the pool is permissionless, so the requests blocking a switch can be found on chain.
    3. Keeping the empty-set check for push to claim. Carried-over push requests would either sit below a reset cutoff and be claimable unfunded, or, if the cutoff stays put, be priced against a stale running total: cumulativeAssetsByBlock still counts requests closed in push mode, so openClaims over-reserves liquidity that is never released. Instead, pausing withdrawals and settling every request in push mode (which requires removing withdrawStateActive from the push-mode settlement functions), rather than cancelling, so lenders keep their recorded payout.
    4. Document and validate the procedure for mode switches. Update Runbook especially for push to claim migration on live pools (and also vice-versa for emergency claim -> push switch) across all the possible conditions and constraints.

    OpenTrade

    Commit #b5aa068.

    Cantina

    Commit #b5aa068 documents the procedure.

  19. The pause blocks the admin changes needed to respond to an incident, including switching permissionless off

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Every controller setter has onlyNotPaused. The only exceptions are pausePool, resumePool, addPoolPauser and removePoolPauser. That is deliberate, and matches threat-model invariant 12. But it blocks the changes that make a pool safer as well as the ones that make it riskier:

    • setPermissionless(false).
    • Lowering dailyMintLimit or maxOpenRequests, or raising minRedeemAssets.
    • pauseDeposits and pauseWithdraws.
    • Replacing a compromised key with setIssuerAdmin, setDepositManager, setIssuer or setPoolAdmin.

    So the protocol admin cannot use a pause to fix the problem before reopening. It has to unpause first, and the pool is open while the fix is applied:

    • For a pool pause, the protocol admin can batch resumePool with the fix in one transaction, if it is a multisig that supports batching.
    • For a protocol-wide pause, unpausing takes the PAUSER_ROLE key, so it can't be combined with the protocol admin's fix. There is a window between the two transactions, visible in the mempool.

    Revoking a role in the service configuration does work while paused. But a revoked override's powers fall back to the issuer, and the replacement override cannot be set until after unpausing.

    Scenario (an incident through permissionless entry, such as front-running a rate update):

    1. A pauser calls setPaused(true) across the protocol.
    2. The protocol admin tries setPermissionless(false) and setDailyMintLimit(…). Both revert with Paused.
    3. The pauser calls setPaused(false). Before the protocol admin's changes land, the attacker deposits and requests again at the same rate.

    Recommendation

    Consider exempting from onlyNotPaused the changes that only make the pool more restrictive:

    • setPermissionless(false).
    • Lowering limits and caps, and raising the minimum.
    • pauseDeposits and pauseWithdraws.
    • Naming a replacement issuer admin or deposit manager.
  20. Server will be unable to fulfill redemptions in permissionless push mode because getActiveWithdraws() returns nothing

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    getActiveWithdraws returns an empty array whenever the pool is permissionless, whatever its repayment mode. The spec expects requests to be tracked from the logs only in claim mode. The plan lists a push-mode pool from getActiveWithdraws, and a claim-mode pool from its events.

    Threat model §13 says claim mode must be turned on before permissionless mode, but notes that this is not enforced. It also says that with permissionless on and claim mode off, getActiveWithdraws returns nothing and "open requests are readable only by uuid through withdrawExtra". That understates the gap:

    • No onchain source of uuids. activeWithdrawKeys is private and has no getter. The uuids appear only in RedeemRequested events.
    • withdrawExtra returns only part of a request. It returns requestedAtBlock and fee. The lender, assets, shares and fundsDestination are in _activeWithdraws, which is private and has no getter by uuid. They are available only from RedeemRequested and RedeemDestinationChanged.
    • The planned server lists none of these requests. It lists push-mode pools from getActiveWithdraws, which now returns nothing. Its event-based listing is only used in claim mode, and treats a request with no RedeemRequestClaimMode event as one made before the upgrade. Push-mode requests never emit that event.

    Settlement itself still works. processRedemptions needs only uuids, and processRedemption needs the lender and amounts too, all of which are in the events. But the issuer's normal workflow shows no pending redemptions. Requests made while the pool is permissionless in push mode are therefore not paid until someone builds the list from the logs.

    Scenario:

    1. The protocol admin calls setPermissionless(true) on a push-mode pool, which the code allows.
    2. Lenders request redemption. Each request emits RedeemRequested only.
    3. The server's withdrawal list for the pool, built from getActiveWithdraws, is empty, so the issuer sees nothing to repay.

    Recommendation

    Consider the following:

    1. Making permissionless mode require claim mode, as threat model §13 already assumes:
      • setPermissionless(true) reverts unless claim mode is on
      • setClaimBasedRepayment(false) reverts while the pool is permissionless.
    2. Alternatively, emptying getActiveWithdraws only in claim mode, and adding a paginated list of keys.

    OpenTrade

    Commit #d910063.

    Cantina

    Commit #d910063 makes setPermissionless(true) revert unless claim mode is on. However, setClaimBasedRepayment(false) does not revert while the pool is permissionless, which means this should be handled offchain to never go back to push mode while pool is permissionless.

  21. Linear, Dynamic and Term rates do not enforce a freshness/validity period

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    exchangeRate() refuses to extrapolate a Compounding rate for more than 7 days: it reverts with ExchangeRateInvalid, which stops deposits and requests until automation posts again. The other rate types have no equivalent limit:

    • Dynamic keeps the last posted rate for ever.
    • Linear keeps adding exchangeRateChangeRate every day for ever, whatever the underlying assets actually earn.
    • Term follows its straight line to maturity, then stays flat.

    If automation stops posting (an outage, a lost key, a paused job) or the underlying yield changes, the onchain rate drifts away from the real value of the assets, and the pool keeps accepting deposits and requests at it. In permissionless mode, anyone who can see the real NAV (from published reports, or the underlying T-bill or fund price) can trade against the gap:

    • On a stale Linear pool that keeps rising after the real yield falls, holders request redemptions at the inflated rate, and the treasury has to pay the recorded amounts.
    • On a stale Dynamic pool, traders deposit while the rate is below NAV. When automation catches up and posts the jump, they request redemption at the higher rate.

    Nothing on chain limits how long a stale rate stays tradeable, or how large the gap can grow. The protection exists for Compounding pools only.

    Scenario (Linear pool, permissionless):

    1. Automation posts a rate with a 5% APY slope, then stops (for example, its key is rotated and the job isn't restarted).
    2. The fund's actual yield falls to 3%. After 60 days the onchain rate is about 0.33% above NAV.
    3. Holders who track the published NAV request redemption of 20,000,000 USDC at the onchain rate, which fixes about $66,000 more than the assets are worth. The holders who stay bear the difference.
    4. Nothing on chain marks the rate as stale or stops the requests.

    Recommendation

    Consider the following:

    1. Applying one staleness limit to every rate type. For example, adding a protocol-admin setting maxRateAge (in days), and making exchangeRate() revert, or making deposit and requestRedeem revert, once today − exchangeRateSetDay > maxRateAge. Claims and repayments should keep working, because they use the recorded amounts.
    2. Alerting when automation misses a posting.

    OpenTrade

    This is not changed from the current pool operation, We manage the exchange rate setting offchain, since it had to match the value of off chain assets. We have Hypernative alerting in place if there are missed exchange rate updates. We also have redundant checking of exchange rate updates with bounds limiter utilizing Fireblock cosigner callbacks and policies.

    Cantina

    Acknowledged.

  22. Beacons accept any implementation address without checks

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    The three factories are the beacons for every pool, controller and allowlist proxy. Their setImplementation stores any address, including address(0), an EOA, or a mistyped address. OpenZeppelin's UpgradeableBeacon rejects an address with no code, but this custom beacon does not.

    If a beacon points at an address with no code, each BeaconProxy delegatecalls into nothing. The call succeeds and returns empty data:

    • Callers that decode a return value, such as balanceOf, revert.
    • Callers that use SafeERC20 treat the empty return as success, because the token address (the proxy) has code. transfer and transferFrom then report success without moving anything.

    The pool token's holders include an OFT adapter, which locks shares with safeTransferFrom before the bridge mints on the other chain. That lock would succeed without moving any shares, so the bridge would mint unbacked tokens on the destination chain. The Pendle wrapper and any other integrator that uses SafeERC20 would credit deposits that never happened.

    The deployer is trusted, and upgrades are outside the threat model's scope (§0). But production upgrades are applied by hand from a block explorer, which makes a mistyped address a realistic mistake.

    Scenario:

    1. During a production upgrade, the deployer pastes a wrong address into LYTPoolFactory.setImplementation, for example the address of the implementation's deploy transaction, which has no code.
    2. Every pool now delegatecalls into an address with no code.
    3. A user bridges 1,000,000 shares. The OFT adapter's safeTransferFrom returns success without moving any shares, and the remote chain mints 1,000,000 bridged shares that nothing backs.
    4. Until the beacon is fixed, every SafeERC20 caller sees deposits and transfers that never happened.

    Recommendation

    Consider the following:

    1. Reverting in setImplementation unless newImplementation.code.length > 0, as OpenZeppelin's UpgradeableBeacon does.
    2. Checking a version or type identifier on the new implementation (for example, IPoolBase.poolType() for the pool beacon), so that an implementation for the wrong beacon is rejected.

    OpenTrade

    This is a risk we take, We can not change the existing beacons and have multi sig and timelocks in place to ensure correct behavior of the upgrade process

    Cantina

    Acknowledged.

  23. Issuer can settle anonymous lenders' requests at amounts it chooses in permissionless push mode

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    processRedemption is blocked only in claim mode. In push mode it burns the lender's shares and pays whatever repayment and fees the issuer passes in. If the destination is zero, it pays nothing on chain at all, on the basis that the issuer settles off chain. A zero destination is allowed in push mode, and changeRedemptionDestination lets the issuer admin set one.

    Threat model §12 accepts the issuer choosing these amounts because "that path is push mode only". That reasoning relies on push mode being the existing permissioned flow, where every lender is allowlisted, known and paid under an offchain agreement. Permissionless push mode is also reachable (threat model §13), and there the same function applies to anonymous holders who have no offchain relationship with the issuer:

    • Any amount. The issuer can close a request for less than it recorded, or for nothing, and burn the shares. The lender's recorded assets and fee are ignored.
    • Offchain with no counterparty. The issuer admin can set the destination to zero. The issuer can then burn the shares and log a RedeemRepay for an "offchain" payment to someone it cannot identify or pay.
    • No way for lenders to see or contest it. The lender cannot cancel or claim the request, and in this mode getActiveWithdraws returns nothing, so there is no onchain list of open requests either.

    In claim mode, and through processRedemptions, the code always pays exactly the recorded amounts. This combination of modes is the one place where an anonymous holder's redemption depends entirely on the issuer's honesty.

    Scenario (permissionless, push mode):

    1. A holder who bought shares on the LFJ pair requests redemption of 50,000 USDC.
    2. The issuer calls processRedemption(holder, 0, 0, uuid). The shares are burned and the holder receives nothing.
    3. Alternatively, the issuer admin redirects the request to address(0) and the issuer settles it "off chain", which emits a normal-looking RedeemRepay with nothing paid.

    Recommendation

    Consider the following:

    1. Making permissionless mode require claim mode.
    2. If permissionless push mode must stay, then while the pool is permissionless:
      • making processRedemption revert, or requiring repayment + fees to equal the recorded assets and fees to equal the recorded fee
      • rejecting a zero destination in changeRedemptionDestination, as claim mode already does.

    OpenTrade

    Push redemption is a an existing use case that has been in production, When payments are done offchain, they are verified offchain, and the values are record onchain.

    Cantina

    Acknowledged.

  24. Rate setters accept a stale value from a delayed or repeated transaction

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Each rate setter writes an absolute rate and sets exchangeRateSetDay to today. None of them checks whether the value is newer than the one already in place: there is no "as of" day, no expected previous rate, no sequence number and no deadline. Two different writers can call them, automation and the issuer admin, and the issuer admin is behind a timelock. A value computed earlier can therefore land later and silently replace a newer rate:

    • A timelocked post lands late. A rate the issuer admin schedules through its timelock is computed when it is scheduled, and runs a full delay later. By then automation may have posted newer rates. The timelocked value overwrites them.
    • A retried post lands late. An automation job that resends an unconfirmed update with a new nonce can land an old value after a newer one.
    • Linear, Compounding and Term reposts lose accrued days. Posting the same base again on a later day resets exchangeRateSetDay, which throws away the days accrued since the base was set. The rate drops by that many days of accrual.

    Anyone can profit from the rollback. The timelocked call is public in CallScheduled, and a resent transaction is visible in the mempool. Holders request redemption just before the stale value lands, fixing the higher rate. Others deposit just after, at the lower rate, and request redemption once automation corrects it. The other holders pay for both.

    Scenario (Linear pool, permissionless, 2-day issuer-admin timelock):

    1. Day 1: the issuer admin schedules setExchangeRateLinear(1.0500e18, step, rate) to correct the slope. Automation keeps posting daily.
    2. Day 3: the scheduled call executes. The base goes back to its day-1 value with exchangeRateSetDay = 3, so the rate falls by two days of accrual. On $50,000,000 at 5% APY that is about $13,700.
    3. Anyone who saw CallScheduled deposits right after execution and requests redemption after automation's next post, which restores the rate.

    Recommendation

    Consider the following:

    1. Giving each rate setter an asOfDay (or an expected current rate) and a deadline, and reverting if asOfDay < exchangeRateSetDay or if the deadline has passed.
    2. Routing all routine posting through one writer, and making issuer-admin overrides carry the same freshness check.
    3. Setting exchangeRateSetDay from asOfDay rather than from today for Linear, Compounding and Term pools, so a late post doesn't throw away accrued days.

    OpenTrade

    This is for term configuration of exchange rates, we do not have or plan to have any term lytPools in use. Outside of scope.

    Cantina

    Acknowledged.

  25. pauseDeposits does not stop adjustment credits which allows the deposit manager to keep minting while deposits are paused

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    deposit, depositOffChain and depositFromTransfer all carry depositStateActive. An adjustment credit carries no deposit-state check, in either the controller or the pool, even though it mints shares the same way. The code treats a credit as a mint everywhere else: it needs the deposit manager, and it counts against the daily mint limit (plan decision 7). The spec only says that adjustments continue during a withdraw pause. It says nothing about a deposit pause.

    So pauseDeposits stops new shares from every source except the one key most likely to be the problem. If the deposit manager is compromised or misbehaving, the operator's quickest response is pauseDeposits, which any operator or poolAdmin can call at once. The deposit manager can carry on minting through addAdjustmentAmount(lender, 0, credit) until the limit is reached, or without limit if none is set. Only a full pool pause stops it, and a pool pause also freezes transfers, the DEX pairs and the bridge.

    Scenario:

    1. Unusual depositOffChain mints are noticed from the deposit manager. The operator calls pauseDeposits.
    2. The deposit manager calls addAdjustmentAmount(attacker, 0, 10,000,000e6), and the shares are minted.
    3. The attacker sells the shares on a DEX pair. Stopping this takes pausePool or setPaused, which freezes every holder.

    Recommendation

    Consider the following:

    1. Applying depositStateActive to the credit branch of addAdjustmentAmount, in the pool or the controller, so a deposit pause stops every mint. Debits can stay outside it.
    2. If corrections must still be possible while deposits are paused, giving credits their own pause, or limiting them to the protocol admin while deposits are paused.

    OpenTrade

    For the compromised deposit manager scenario, we accept the remaining risk because of the controls already in place: The deposit manager is timelocked. A credit shows up in the timelock schedule before it can run, so we have time to act before any shares are minted. The key can be replaced while the pool is paused. The protocol admin can call setDepositManager to swap out a compromised key. Credits count against dailyMintLimit. The pool can't be made permissionless unless the limit is non-zero, so in that mode credits can't mint without limit. Pausing the pool stops credits completely. We accept that this is a bigger step than pauseDeposits. For a compromised deposit manager we think it's the right response. Limiting credits to the protocol admin while deposits are paused has some value. We've noted it as a possible future improvement, but it won't be in this release.

    Cantina

    Acknowledged.

  26. A USDC blacklist on the fee collector or treasury wallet halts settlement or deposits

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    USDC, the asset this protocol is built around, has its own blacklist. A USDC transfer to or from a blacklisted address reverts, whatever the amount, including zero. The pool pushes funds to two fixed addresses on every flow:

    • Fee collector. processRedemption always transfers fees to it, even when fees == 0. processRedemptions and claim transfer to it whenever the fee is nonzero. If Circle blacklists the fee collector, every push-mode processRedemption reverts, and every claim or batch payout that carries a fee reverts.
    • Treasury wallet. deposit and depositFromTransfer forward every deposit to it. If it is blacklisted, every deposit reverts.

    The only way to recover is setFeeCollectorAddress or setTreasuryWalletAddr. The issuer admin calls them, behind a timelock, and the new address has to hold the right role. Settlement or deposits stay frozen for at least the timelock's delay, and the pause can't be used to buy time, because those setters are blocked while the pool is paused. A lender destination blacklisted by USDC reverts only that request, which can be cancelled. But in a processRedemptions batch it reverts everything.

    Scenario:

    1. Circle blacklists the fee collector, for example because of an unrelated incident at the custodian.
    2. Every processRedemption reverts on its fee transfer, including those with fees = 0, and every claim with a nonzero fee reverts.
    3. The issuer admin schedules setFeeCollectorAddress(new). Redemptions stay frozen until the timelock delay passes.

    Recommendation

    Consider the following:

    1. Skipping the fee transfer when the fee is zero.
    2. Having fees accumulate in the pool, as a counter the fee collector withdraws (a pull pattern), instead of pushing them on every payout. A blocked fee collector then delays only fee collection.
    3. Letting the protocol admin replace the fee collector or treasury wallet without the issuer-admin timelock in an emergency.

    OpenTrade

    Commit #f8db8ab.

    Cantina

    Commit #f8db8ab adds skipping the fee transfer when the fee is zero. Other concerns still remain.

    OpenTrade

    If Circle blacklists our fee collector wallet we would pause the deposits till the fee collector address can be changed, alternatively we could temporarily set the fee rate to 0 till the fee collector address is resolved.

    Cantina

    Acknowledged.

  27. The issuer alone can block any lender's redemption by removing them from the allowlist in permissioned pools

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Every payout path checks _accessAllowed(lender). The spec asks only for the blacklist on the lender and the allowlist on the destination. In a permissioned pool, the issuer can call removeParticipant with a single key and no timelock. The removed lender's claim then reverts, and so does the issuer admin's claim on their behalf. A request already funded by openClaims stays reserved, and the lender can't cancel it or move the locked shares. This gives one key the same effect as a blacklist, without the operator role, timelock or second key that the threat model relies on (§6).

    Scenario (permissioned pool, claim mode):

    1. Lender L's 2,000,000 USDC request is funded and opened.
    2. The issuer calls removeParticipant(L).
    3. L's claim reverts. The funds stay reserved, and L has no onchain recourse unless the issuer adds L back or the issuer admin cancels the request.

    Recommendation

    Consider the following:

    1. Dropping the _accessAllowed(lender) check from the payout paths, as the spec describes, while keeping the blacklist check on the lender and the allowlist check on any destination other than the lender.
    2. Using the blacklist to stop a holder.

    OpenTrade

    The design deliberately makes the allowlist check on the lender at payout stricter than the spec's minimum, so the spec should be read as the floor, not the full rule. In a permissioned pool, the allowlist is how we enforce our commercial and legal relationship with each participant. We would only remove someone because that relationship changed, or because their legal or regulatory status became adverse. In those cases we need payouts to stop straight away, so putting removal behind a timelock or a second key would defeat its purpose. The removed lender's recourse is the legal agreement that governs their participation, not an onchain path. A removal can also be undone on chain: The issuer can re-admit the lender with allowParticipant, after which the funded request can be claimed as normal. The issuer admin can cancel the request with cancelRedemption, which releases the reserved liquidity and returns the locked shares to the lender. Permissionless pools are not affected, because _accessAllowed always passes there. We'll keep the _accessAllowed(lender) check on the payout paths as it is. For context, we don't currently run any permissioned claim-mode pools in production.

    Cantina

    Acknowledged.

  28. Reporting and max views ignore fixed payouts, the pause, the lifecycle state and the allowlist

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    • Requested shares are valued at today's rate. Shares locked in an open request stay in totalSupply and in the lender's balance until settlement. The views value them at the current rate, but the payout is fixed at the rate when the request was made. Once the rate moves, totalAssets() and the lender's assetBalanceOf are off by requestedShares × (current rate − request rate). In claim mode, where payouts are always exactly the recorded amounts, this difference is systematic.
    • Max views ignore gates. maxRedeemRequest used to revert while the pool was paused or not Active. It now returns the free shares in those cases, and it never checks the allowlist or minRedeemAssets. An integrator can therefore read a positive maximum for a call that will revert. maxWithdrawRequest also reports the amount before the fee, unlike previewRedeemRequest.

    Scenarios:

    1. A locked request after a markdown (Dynamic pool).

    1. Lender L holds 1,000,000 shares at a rate of 1.0. L calls requestRedeem for all of them, which fixes the payout at 1,000,000 USDC.
    2. A −2% markdown lands, and the rate is now 0.98.
    3. assetBalanceOf(L) returns 980,000 USDC, and totalAssets() counts the same 980,000. L will still be paid 1,000,000 USDC.
    4. A dashboard or the server reports L 20,000 USDC short, and understates what the pool owes by the same amount. After a rate rise, the views overstate both instead.

    2. A positive maximum while the pool is paused.

    1. A pool pauser calls pausePool().
    2. An integrator calls maxRedeemRequest(holder) and reads 50,000 shares.
    3. It calls requestRedeem(50,000), which reverts with PoolPaused.
    4. The same happens on a permissioned pool for a holder who bought on a DEX pair and is not on the allowlist. The maximum is positive, and requestRedeem reverts with NotMarketMaker.

    Recommendation

    Consider the following:

    1. Returning 0 from the max views whenever requestRedeem would revert for that owner. That covers paused, not Active, not allowed and below the minimum.
    2. Documenting that the asset views value locked shares at the current rate, or subtracting requestedShares and adding requestedAssets in the per-lender view.

    OpenTrade

    Commit #e8d7677 and Commit #1a5cd9b.

    Cantina

    Commit #e8d7677 and Commit #1a5cd9b fix the maxRedeemRequest check gates and maxWithdrawRequest fee aspects respectively. The asset view share valuation issue is neither fixed nor documented.

  29. Lifetime accounting statistics miss adjustments, transfers and fees, and a debit can burn nothing

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    • Lifetime figures. A credit mints shares but adds nothing to totalAssetsDeposited or the lender's assetsDeposited. A debit burns shares but adds nothing to totalAssetsWithdrawn or assetsWithdrawn. So the lifetime figures drift from the share supply by every adjustment ever made. This predates the change, but credits now count against the daily mint limit as if they were deposits, which makes the two treatments inconsistent.
    • The lifetime figures don't follow the shares. assetsDeposited and assetsWithdrawn stay with the address that deposited or redeemed, not with the shares. A holder who bought on a DEX pair, which permissionless mode makes common, shows assetsDeposited = 0 and an assetsWithdrawn larger than that. Any per-lender profit figure built from getLYTPoolAccountState is wrong for that holder.
    • Fees are recorded nowhere on chain. totalAssetsWithdrawn counts what destinations received, net of the fee, while totalAssetsDeposited counts gross deposits. No counter records fees paid, so deposited − withdrawn overstates what is still owed by every fee ever collected. Reconciliation has to rely on events, whose fields mean different things.
    • Rounding. A debit converts with convertToShares, which rounds down, in the lender's favour. When the rate is above 1e18, a debit worth less than one share burns zero shares but still emits AccountDebit(lender, debit) for the full amount.

    Recommendation

    Consider the following:

    1. Updating the deposited and withdrawn figures on credits and debits, or tracking adjustments in their own counters.
    2. Adding a totalFeesCollected counter.
    3. Documenting that the per-lender figures follow addresses, not shares.
    4. Rounding debits up (convertToSharesRoundUp), and reverting when a nonzero debit converts to zero shares.
  30. Mainnet RPC API keys are committed to the repository

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    This change commits Alchemy RPC URLs with embedded API keys:

    • an Ethereum mainnet key
    • an Avalanche mainnet key, used in two network entries
    • a new Sepolia key
    • the existing Fuji key, as the default RPC_URL in the new scripts/deployTimelock.ts.

    Other Sepolia and Fuji keys were already committed before this change. The keys are in the git history, so anyone with read access to the repository, or to any fork or mirror of it, can use them. They can exhaust the quota, which disrupts deployments and script-driven admin calls, or run up the bill. Deploy configuration is outside the threat model's scope (§0), so this is reported for the team's awareness.

    Recommendation

    Consider the following:

    1. Rotating every exposed key first. Removing them from the files does not remove them from the git history.
    2. Loading RPC URLs from environment variables, as the deploy mnemonic and account keys already are.
    3. Restricting each new key to known IPs or domains if the provider supports it.
  31. Rate setters don't validate their type-specific parameters allowing the rate to underflow or fall to zero

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    The rate setters check only that the base rate is nonzero. The type-specific parameters that decide where the rate goes next are not checked:

    • A Term maturity day in the past. exchangeRate() computes exchangeRateMaturityDay − exchangeRateSetDay, which underflows if the maturity day is before the day the rate was set.
    • A Compounding factor of zero, or close to it. From the next day, exchangeRate() multiplies the rate by the factor, so a factor of 0 gives a rate of 0, and a tiny factor gives a rate close to 0.
    • A Term maturity rate of zero. The rate slides down to 0 by the maturity day.

    A rate of zero makes convertToShares and deposit revert with division by zero, and makes every requestRedeem revert with ZeroAmount. An underflow makes every read of the rate revert. Either way, until a correct rate is posted, deposit, requestRedeem, the previews, the conversions, adjustments, and the overview and account views all revert. Claims are unaffected because they use the recorded amounts. A rate close to zero is worse: deposits mint huge numbers of shares. This predates the change, but automation can post any of these with no timelock.

    Recommendation

    Consider adding these checks in setExchangeRate, or in the controller's setters:

    1. Reverting when a Term pool's maturity day is before today, or its maturity rate is zero
    2. Reverting when a Compounding factor is outside a reasonable range around 1e18 (for example, ±1% a day)
    3. Bounding the Linear daily change the same way.

    OpenTrade

    Commit #2b1cf47.

    Cantina

    Commit #2b1cf47 adds a maturity-day check for Term pools per recommendation (1) and removes the duplicate requestGate(). However, this is still missing the below:

    • Term maturity rate isn't checked. A maturity rate of 0 makes the rate slide to 0 by the maturity day.
    • Compounding factor isn't bounded. A factor of 0 sets the rate to 0 from the next day. A tiny factor sets it close to 0, and then deposits mint huge numbers of shares.
    • Linear daily change isn't bounded.

    OpenTrade

    We are not using any lytpool configured for Term, compounding, or linear, so we will not address with this upgrade.

    Cantina

    Acknowledged.

  32. Turning permissionless on re-admits every address removed from the allowlist

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    While the pool is permissioned, removeParticipant is the softer of the two ways to exclude someone. The other is the blacklist. setPermissionless(true) makes _accessAllowed return true for every address, so every earlier removal stops having any effect. An address the issuer removed, for example on a compliance flag that did not justify a blacklist, can deposit, request and claim again straight away.

    Scenario:

    1. Market maker M is on the allowlist of a permissioned pool. A compliance review flags M, and the issuer calls removeParticipant(M). M's next deposit and requestRedeem revert with NotMarketMaker.
    2. Months later, the protocol admin calls setPermissionless(true).
    3. _accessAllowed(M) now returns true. In the next block M deposits 2,000,000 USDC and receives shares. M can also request redemption and claim.
    4. No event marks M as re-admitted. The issuer finds out only by reading the PoolDeposit logs.

    Recommendation

    Consider the following:

    1. Blacklisting every address that must stay excluded before setPermissionless(true), and documenting this.
    2. Alternatively, keeping a deny list in LYTPoolAccessControl that _accessAllowed respects even while the pool is permissionless.

    OpenTrade

    We are ok with this, because we have a shared blacklist, and in permissioned mode participants are market makers, removing them does not imply they can not act on the permissionless vault

    Cantina

    Acknowledged.

  33. Closing a pool freezes every outstanding share for good

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Closing is a trusted protocol-admin action. transitionTo(Closed) checks only that no request is open. However, it does not check totalSupply(). Closed is terminal. _update carries atState(Active), so in a closed pool shares can't be transferred, burned or debited. requestRedeem, processRedemption(s), claim and addAdjustmentAmount also require Active. Any share still in circulation when the pool closes therefore has no onchain exit, and redemption has to be settled off chain.

    Permissionless mode makes it matter more. Shares sit in contracts that can't redeem off chain: the DEX pairs, the Pendle wrapper and the OFT adapter. After a close, their liquidity is frozen, and so are LP positions and bridged balances that depend on it.

    Scenario:

    1. A pool winds down. Every open request is settled, and the protocol admin calls transitionTo(Closed).
    2. The Uniswap pair still holds 400,000 shares, and the OFT adapter holds 1,000,000 shares that back bridged tokens.
    3. _update now requires the pool to be Active, so those shares can't be transferred, redeemed or debited.
    4. The pair's liquidity and the bridged tokens are frozen for good, and LP holders have no onchain exit.

    Recommendation

    Consider one of these:

    1. Adding a wind-down state between Active and Closed (for example Winding):
      • startWindDown(finalRate, duration), called by the protocol admin from Active:
        • fixes the final exchange rate, which can't be changed afterwards
        • sets windDownEndsAt = block.timestamp + duration
        • enforces a minimum duration, for example duration >= MIN_WIND_DOWN (say 90 days), so holders, LPs and bridged holders have time to react
        • emits an event with the rate and the deadline.
      • In Winding:
        • deposits and new mints are blocked
        • transfers stay open, so integrators can unwind
        • requestRedeem, claim and settlement stay open, at a final fixed rate posted once and not changeable afterwards
        • holders can redeem whenever they show up, with no deadline, and the treasury funds claims as they arrive
        • Closed then becomes a formality that is reached only when the supply is zero, or not used at all
    2. Requiring totalSupply() == 0 in transitionTo(Closed). But the challenge is that some holders will never act, for example lost keys, bridged tokens nobody brings back, or miss the Winding event. So the supply may never reach zero, and closing then means freezing whatever is left.
    3. Documenting in the runbook that a pool may close only after every share, including those held by integrations, has been redeemed.

    OpenTrade

    This predates the permissionless work, but we agree it matters more now that DEX pairs, the Pendle wrapper and the OFT adapter can hold shares. We don't plan to add a separate Winding state, because a pool that stays Active with deposits paused already behaves the way you describe: New deposits stop. pauseDeposits blocks deposit, depositOffChain and depositFromTransfer. Transfers stay open. LPs, Pendle holders and bridged holders can unwind their positions. Redemptions keep working with no deadline. requestRedeem, openClaims, claim and settlement all continue as normal. We see closing as the last step of a wind-down, not the wind-down itself. Nothing forces us to close a pool. If some holders still haven't redeemed, the pool can stay Active with deposits paused for as long as needed. We'll update the runbook so the close procedure covers the whole wind-down: Give notice to holders and integrators. Hold a period with deposits paused and redemptions open. Unwind the DEX, Pendle and OFT positions. Close only once the outstanding supply has been redeemed or settled off chain under the pool's legal terms.

    Cantina

    Acknowledged.

  34. Request uuids are not unique across pools or chains

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    The uuid is keccak256(timestamp, lender, shares, assets, cumulativeAssetsTotal). It includes neither the pool's address nor block.chainid. cumulativeAssetsTotal starts at zero in every pool after the upgrade, so the first request in two pools from the same lender, in the same second and for the same amounts, gets the same uuid. A lender who controls all of those inputs can make such a collision on purpose, across the Avalanche and Ethereum pools or between two pools on one chain.

    Onchain, each pool keeps its own mapping, so there is no direct gain. But the spec builds the server's open-request set and the warehouse's redemption table from event logs joined on the uuid. If the indexer keys on the uuid alone, a RedeemClaimed or RedeemRepay in one pool can close or relabel the matching request in another, and the server may offer or schedule payment against the wrong request.

    Scenario:

    1. Right after the upgrade, cumulativeAssetsTotal is 0 in both the Avalanche and the Ethereum pool.
    2. Lender L sends requestRedeem to both pools in the same second, for the same shares and the same assets. The two requests get the same uuid.
    3. The Ethereum request is claimed, and RedeemClaimed(…, uuid, …) is emitted.
    4. An indexer that keys on the uuid alone marks the Avalanche request as closed too. The server stops offering L a claim that is still owed.

    Recommendation

    Consider the following:

    1. Adding address(this) and block.chainid to the uuid preimage.
    2. Keying every offchain record on the pool, chain and uuid together.
  35. The recorded mainnet timelock has a 5-second delay, and one EOA holds every role in it

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    The ignition journal records a TimelockController deployed on Ethereum mainnet (chain ID 1) at 0x71E0774373fdD15ea6e3a6aCEBFEeaA0A1b48b73 with these constructor arguments:

    • minDelay = 5 seconds.
    • The deployer EOA 0xAd795e380725d85E0c62da50B91126a12043922a as the only proposer and the only executor.
    • The same EOA as admin, which gives it DEFAULT_ADMIN_ROLE on the timelock.

    Both deployment paths, the ignition module and scripts/deployTimelock.ts, default to a 5-second delay with the deployer in every role. The repository does not show which pools use this timelock. If it is, or becomes, an issuer-admin or deposit-manager override, the single EOA can schedule and run any timelocked action within seconds. That would remove the delay that the threat model's two-key rule and the spec's deposit timelock assume.

    Deploy configuration is outside the threat model's scope (§0). This is reported because the in-scope findings depend on it.

    Scenario:

    1. The timelock at 0x71E0…8b73 is set as a pool's issuer-admin override.
    2. The single EOA's key is compromised.
    3. The attacker schedules changeRedemptionDestination for a large open request, pointing it at an address already on the allowlist. It executes the call 5 seconds later.
    4. No holder or monitor has time to react before the payout is redirected.

    Recommendation

    Consider the following:

    1. Checking on chain whether 0x71E0…8b73 is set as any pool's override.
    2. Redeploying the timelock with a delay chosen for the role. For the deposit manager, weighing the delay against the interval between rate updates. A delay shorter than that interval exposes a scheduled deposit to at most one rate change.
    3. Using a multisig as proposer, a separate executor or the open executor, and admin = address(0).
    4. Making the deploy scripts require minDelay explicitly instead of defaulting it to 5.

    OpenTrade

    We are changing this as the testing period ends, It will be 48 Hours once publicly released. Deployer is a Fireblocks MPC wallet and EOAs are Fireblock EOAs.

    Cantina

    Acknowledged.

  36. Permissionless mode allows debits and deposit-manager mints reach the DEX pairs, the Pendle wrapper and the OFT adapter

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    addAdjustmentAmount, depositOffChain and depositFromTransfer decide which holders they can act on through _accessAllowed(lender). While the pool is permissioned, that means allowlisted holders only. The Uniswap pairs, the Pendle wrapper and the OFT adapter were never allowlisted, so they could not be debited or credited. setPermissionless(true) makes _accessAllowed true for every address, which brings all of them in range:

    • Debits. The issuer admin can burn shares held by an integrator contract. A debit on the OFT adapter removes shares that back tokens already bridged to the other chain, so the bridged supply is no longer fully backed. A debit on a pair leaves it holding fewer shares than its recorded reserves, which skews its price until it is resynced.
    • Mints. The deposit manager can credit an integrator contract, or name one as the lender in either deposit function. Shares minted straight to a V2-style pair are surplus that anyone can take with skim, or that move the price on the next sync.

    Threat model §11 lists this as a pattern to report: "addAdjustmentAmount debit or credit reaching an AMM pair, the Pendle wrapper or the OFT adapter once _accessAllowed is true for all."

    Scenarios:

    1. A debit on the bridge adapter.

    1. The Avalanche pool is permissionless. The OFT adapter holds 3,000,000 shares that back 3,000,000 bridged tokens on Ethereum.
    2. The issuer admin's debit names the adapter, for example by mistake when correcting another holder.
    3. The adapter now holds 2,000,000 shares. The bridged tokens on Ethereum are 1,000,000 short of backing, and the last holders to bridge back cannot be paid.

    2. A mistaken credit to a pair.

    1. The pool is permissionless. The deposit manager records a 500,000 USDC client deposit and enters the Uniswap pair's address as the lender.
    2. Shares worth 500,000 USDC are minted to the pair.
    3. Anyone calls skim on the pair and receives those shares before a corrective debit can be scheduled.

    Recommendation

    Consider the following:

    1. Keeping the allowlist check on adjustment debits and credits, and on the lender in depositOffChain and depositFromTransfer, even while the pool is permissionless.
    2. Alternatively, having these functions refuse a short denylist of integrator addresses (the pairs, the wrapper and the adapter) whatever the permissionless flag says.

    OpenTrade

    Commit #5749f4d.

    Our internal Fireblocks policies restrict us from sending to any address that is not already whitelisted for external credits/debits. New addresses can only be whitelisted by a quorum of signers.

    Cantina

    Documented in Runbook. Acknowledged.

  37. Preview functions skip most of the checks of requestRedeem allowing them to quote redemptions that will revert

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    previewRedeemRequest checks only that shares is nonzero, that withdrawals are Active, that shares is no more than the caller's free shares, and the per-lender request cap. requestRedeem also enforces these, which the preview skips:

    • the pool is not paused (onlyNotPaused)
    • the pool is Active (atState)
    • the caller is allowed (onlyMarketMaker), unless the pool is permissionless
    • the caller is not blacklisted (notBlacklisted)
    • assets is nonzero. The preview returns 0 instead of reverting
    • the open-request cap (maxOpenRequests)
    • the minimum request size (minRedeemAssets)

    previewWithdrawRequest calls previewRedeemRequest, so it has the same gaps. The preview therefore returns a positive quote in many cases where the matching requestRedeem reverts.

    Front ends, the server and integrators that use the preview to decide whether to offer a redemption will show quotes the lender can't act on.

    Scenario (permissionless pool):

    1. The pool has minRedeemAssets = 1,000 USDC and maxOpenRequests = 500, and the queue is full.
    2. A holder who bought shares worth 600 USDC on a DEX pair calls previewRedeemRequest(shares). It returns 597 USDC (after a 50 bps fee).
    3. The front end shows the quote and the holder submits requestRedeem(shares). It reverts with TooManyOpenRequests.
    4. The holder retries once a slot frees up. It now reverts with RedeemBelowMinimum, because 600 USDC is below the minimum. The preview kept returning a positive quote throughout.

    Recommendation

    Consider making the preview functions apply the same checks as requestRedeem, either by sharing one internal validation function or by reverting (or returning 0, with the reason documented) whenever requestRedeem would revert. That covers the pause, the lifecycle state, the allowlist, the blacklist, a zero value, the open-request cap and the minimum size.

  38. Exchange rate can't be corrected while the pool is paused to cause reopening at the rate in force before the pause

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    Every rate setter has onlyNotPaused, so while the pool or the protocol is paused, neither automation nor the issuer admin can post a new rate. Meanwhile exchangeRate() keeps moving with time: Linear, Compounding and Term rates step up every day. When the pool resumes, it reopens at whatever rate was in force before the pause, carried forward:

    • A pause for a rate incident can't be fixed before reopening. If the pool was paused because a markdown or a wrong rate was coming, or had just been posted, the corrected rate can only be posted after resumePool or setPaused(false). Resuming and posting the rate are done by different keys: the protocol admin or a pauser resumes, and automation or the issuer admin posts the rate. So they can't happen in one transaction, and in between, anyone can request redemption at the stale rate or deposit before a recovery.
    • A Compounding rate that expires during a long pause. If the pause lasts more than 7 days, exchangeRate() reverts when the pool resumes, until automation posts again. Deposits, requests, previews and the views all revert. The next post is a catch-up jump.
    • Scheduled rate changes land at reopening. Rate changes queued in the issuer-admin timelock during the pause can't run until the pool resumes. They then land at once, possibly stale.

    The same lock-out applies to the settings that make a pool safer. The rate matters most because it is what reopening exposes.

    Scenarios:

    1. A pause for a rate incident (Dynamic pool, permissionless).

    1. Automation mistakenly posts setExchangeRateDynamic(1.10e18) instead of 1.01e18.
    2. To stop anyone using the wrong rate, the pauser calls pausePool in the next block.
    3. Automation tries to post the correct 1.01e18. It reverts with Paused, so the wrong rate stays in force.
    4. The protocol admin calls resumePool. In the blocks before automation's correction lands, holders call requestRedeem at 1.10, fixing payouts about 9% above the real value. The holders who stay bear the difference.

    2. A Compounding rate expires during a long pause (Compounding pool).

    1. Automation posts on day 0. The pauser calls pausePool on day 1 during an incident.
    2. Automation's daily posts revert with Paused. The pool is resumed on day 10.
    3. More than 7 days have passed since the last post, so exchangeRate() reverts with ExchangeRateInvalid. Every deposit, requestRedeem, preview and overview call reverts until automation posts again.
    4. Automation's next post covers ten days of accrual at once. Anyone watching can deposit just before it lands and request redemption just after.

    3. Scheduled rate changes land at reopening (Linear pool, 2-day issuer-admin timelock).

    1. On day 1, the issuer admin schedules setExchangeRateLinear(1.0500e18, step, rate). On day 2 the pauser calls pausePool.
    2. On day 3 the timelock delay ends, but the call reverts with Paused and stays ready in the timelock.
    3. On day 6 the protocol admin calls resumePool. The executor runs the queued call, which resets the base to its day-1 value with exchangeRateSetDay = 6, so five days of accrual are lost.
    4. Anyone who saw the queued call in CallScheduled deposits just after it executes and requests redemption once automation restores the rate.

    Recommendation

    Consider the following:

    1. Exempting the rate setters from onlyNotPaused, so the correct rate can be posted while the pool is paused and is already in force when it reopens.
      • Alternatively, letting the protocol admin post a rate as part of resuming, for example with resumePool(newRate).
    2. Preferring pauseDeposits and pauseWithdraws over a full pause for a rate incident. The rate setters still work then, and transfers and claims carry on.
  39. The issuer admin alone can redirect a payout to any address already on the allowlist

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    0xRajeev


    Description

    The threat model says a redirect needs two keys (§6). The issuer admin changes the destination with changeRedemptionDestination. The issuer approves the destination with allowParticipant. Payouts then check the destination with _reqPayoutDestination.

    The allowlist has a second job. It is also the list of approved participants. Every market maker and institutional lender the issuer has approved is already on it. Any of them is therefore a valid payout destination for any other lender's request. The issuer admin can redirect a payout to one of them without any action by the issuer.

    The two-key rule therefore holds only for new destinations. For an address already on the list, the issuer admin acts alone. The list does not shrink when the pool turns permissionless. Every address approved before then stays a valid destination.

    Scenario:

    1. Market maker M is on the allowlist from earlier business with the issuer.
    2. The issuer admin key is compromised. M colludes, or M's key is also compromised.
    3. The issuer admin calls changeRedemptionDestination(lender, uuid, M) on an open 2,000,000 USDC request.
    4. In claim mode, once the request is funded, the issuer admin calls claim on the lender's behalf. The 2,000,000 USDC goes to M. The issuer took no action beyond its routine funding.
    5. In push mode the issuer's routine processRedemptions batch pays M the same way, unless someone notices the RedeemDestinationChanged event first.

    Recommendation

    Consider the following:

    1. Keeping a separate list of approved payout destinations, letting only the issuer write to it, and checking it in changeRedemptionDestination and _reqPayoutDestination in place of the participant allowlist.
    2. Alternatively, requiring the lender's own approval for any redirect away from their address.

    OpenTrade

    We have whitelist address in our Fireblocks accounts to manage this.

    Cantina

    Acknowledged.

Informational16 findings

  1. Redemption funding and transfer deposits draw on the same unlabeled balance

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    The only part of the pool's balance that is set aside is unclaimedAssets, the liquidity openClaims has reserved for opened claims. Everything else is one undifferentiated freeAssets() balance, and nothing records what any part of it is for:

    • treasury funding sent for claims that haven't been opened yet
    • treasury funding sent for push-mode repayments (push mode never reserves anything, so unclaimedAssets is 0 there)
    • clients' direct transfers waiting to be recorded by depositFromTransfer
    • donations and rebase gains.

    Every flow that uses the pool's balance can therefore spend money meant for another:

    • Claim mode. depositFromTransfer can credit treasury claim funding to a client, after which openClaims reverts with ClaimsNotFunded. In the other direction, openClaims can count a client's transfer as claim funding, after which that client's depositFromTransfer reverts with ClaimedAssetsReserved.
    • Push mode. processRedemption and processRedemptions pay out of the whole balance. They can spend a client's direct transfer before depositFromTransfer records it. That deposit then reverts, or is covered by other funds, such as repayment funding meant for other lenders.
    • Every mode. transferToTreasuryWallet can sweep any of it. depositFromTransfer checks only that some free liquidity of that size exists, not that the client's transfer arrived.

    The callers are all trusted roles, and claims are never underfunded: openClaims checks the balance when it runs. The cost is operational, with reverts and funds that have to be sent again. But it also removes the onchain link between a client's transfer and the shares credited for it.

    Scenarios:

    1. Claim mode.

    1. The treasury sends 1,000,000 USDC to fund claims.
    2. Before openClaims runs, the deposit manager calls depositFromTransfer(1,000,000e6, client, txHash) for a client transfer that hasn't arrived yet. The call passes the freeAssets() check and forwards the 1,000,000 USDC to the treasury.
    3. openClaims reverts with ClaimsNotFunded, and the treasury has to send the funding again.

    2. Push mode.

    1. A client transfers 500,000 USDC straight to the pool, and depositFromTransfer is scheduled through the deposit-manager timelock.
    2. During the delay, the issuer calls processRedemptions for 500,000 USDC of requests. The payouts come out of the client's transfer.
    3. The deposit call reverts with ClaimedAssetsReserved, or succeeds only once the treasury tops up the pool.

    Recommendation

    Consider the following:

    1. Tracking each inflow in its own counter, and limiting every outflow to the part it is entitled to:
      • Claim funding. Adding fundClaims(amount), which pulls from the treasury and increases a claimFunding counter. openClaims moves amounts from claimFunding into unclaimedAssets. In push mode, processRedemption(s) draws on the same counter (or on a separate repaymentFunding one).
      • Transfer deposits. Recording each client transfer as it is recognised (for example, a pendingTransfers[txHash] amount, or a pendingTransferTotal). Only depositFromTransfer may consume it, for the amount recorded.
      • Everything else. Limiting transferToTreasuryWallet and any other outflow to balance − unclaimedAssets − claimFunding − pendingTransferTotal.
    2. At minimum, documenting in the runbook the order of funding, opening and recording deposits.

    OpenTrade

    Commit #.

    Cantina

    Commit # documents these aspects in the Runbook.

  2. The per-lender request limit allows 101 open requests, not 100

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    Redeem functions check outstandingRequests > 100 before incrementing the count, so the real limit is 101.

    Scenario:

    1. A lender has 100 open requests.
    2. The check 100 > 100 passes, so they open a 101st request.
    3. Their 102nd request reverts with InvalidRedemptions.

    Recommendation

    Consider the following:

    1. Using >= 100, or naming the bound as a constant (for example, MAX_OUTSTANDING_REQUESTS_PER_LENDER) and stating whether it is inclusive.
    2. Applying the same change at both call sites.
  3. Changes to the issuer, issuer admin, pool admin, treasury and fee collector emit no events

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    The new setters all emit an event with the old and new value, as the spec requires, and so does setDepositManager. The setters for the most sensitive addresses emit nothing. These include clearIssuerAdmin, which is added in this change. Two of them redirect money: setTreasuryWalletAddr changes where every deposit goes, and setFeeCollectorAddress changes where every fee goes. Monitoring that is meant to spot a compromised key cannot see any of these changes in the logs.

    Recommendation

    Consider emitting an event with the old and new value from each of these setters, matching DepositManagerSet.

  4. Access-control Natspec disagrees with the code

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    Three modifiers are documented differently from what they check:

    • onlyAuthorized is documented as "operators only, allows only addresses with the OPERATOR_ROLE". It also accepts poolAdmin by address.
    • onlyIssuerAdmin is documented as "the timelocked issuer admin". When no valid override is set, it accepts the issuer, which has no timelock.
    • LYTPoolAccessControl.onlyIssuer is documented as checking "the pool's admin". It checks the issuer.

    Operators who rely on these comments will misjudge who can call these functions.

    Recommendation

    Consider correcting the Natspec to describe the checks that actually run, including the fallbacks.

  5. The redemption-fee ceiling can be set to 100%

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    setMaxRedemptionFeeBps accepts any value up to BPS_DENOMINATOR (10,000). With the ceiling at 100%, the issuer admin can set a fee that takes a request's whole value, and lenders cannot cancel requests made at that fee. previewWithdrawRequest already treats a 100% fee as invalid (it reverts with ZeroAmount), but requestRedeem accepts it.

    A misconfiguration, or a compromised protocol admin combined with a compromised issuer admin, would take every redemption made while the fee is in place. With a short timelock, lenders get no meaningful warning.

    Recommendation

    Consider the following:

    1. Capping the ceiling well below 100% with a constant (for example, 500 bps).
    2. Rejecting a fee that would leave the lender with nothing.
  6. Keeping push and claim modes' accounting apart relies on assumptions the code never checks

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    Push mode and claim mode share unclaimedAssets, claimCutoffBlock and the cumulative totals. They stay separate because of three facts that hold today but that the code never checks:

    1. The reserve is empty on switching to push mode. setClaimBasedRepayment(false) checks that no requests are open, and relies on that to mean unclaimedAssets == 0. It never checks the reserve itself.

    2. Nothing is claimable in push mode. processRedemptions and cancelRedemption reduce unclaimedAssets whenever isClaimable(uuid) is true, without checking the mode first. That is safe only because the switch resets the cutoff to the current block, so no push-mode request can fall below it.

    3. The cutoff resets only when nothing is open. LYTPool.setClaimCutoffToCurrent moves the cutoff to the current block without reserving anything. That makes every open request claimable without funding. It is safe only because its one caller, setClaimBasedRepayment, checks the open set first. The function itself doesn't check. The spec's test scenario uses it as the step that funds and opens claims, which is exactly the misuse to avoid.

    A future change could break any of these facts. Examples would be a cancel path above the cutoff, partial claims, or an upgrade path that carries requests across a switch. Push-mode settlement would then draw on a reserve that no longer exists, or treat leftover reserved liquidity as free.

    Scenario:

    1. A later upgrade adds an admin function that calls setClaimCutoffToCurrent to "reset" a stuck queue. It does not check the open set first.
    2. It runs while 3,000,000 USDC of requests are open above the cutoff, with nothing reserved for them.
    3. Those requests now read as claimable. Their lenders claim against unclaimedAssets that was reserved for other lenders, or the claims underflow and revert.
    4. Nothing in the current code stops this, because the safety lives only in the one existing caller.

    Recommendation

    Consider the following:

    1. Making setClaimBasedRepayment(false) revert unless pool.unclaimedAssets() == 0.
    2. Checking the mode first in processRedemptions and cancelRedemption, and touching unclaimedAssets only in claim mode.
    3. Making setClaimCutoffToCurrent revert unless activeWithdrawCount() == 0.
  7. LYTPool is labelled as ERC-4626 but implements only part of it, and declares events it never emits

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    LYTPool does not inherit IERC4626, and nothing in the repository claims it complies. But the code groups its conversion functions under an "ERC-4626 Methods" heading, ships an unused IERC4626.sol, and ILYTPool declares ERC-4626's Deposit and Withdraw events. Compared with the standard:

    ERC-4626 requirementLYTPool
    asset, totalAssets, convertToShares, convertToAssetsPresent
    maxDeposit, previewDeposit, mint, maxMint, previewMint, withdraw, maxWithdraw, previewWithdraw, redeem, maxRedeem, previewRedeemMissing. Redemptions are asynchronous (requestRedeem, then settlement)
    deposit(assets, receiver) mints to any receiverReverts with PoolInvalidState unless receiver == msg.sender
    deposit emits Deposit(sender, owner, assets, shares)Emits PoolDeposit. The Deposit and Withdraw events declared in ILYTPool are never emitted
    totalAssets is the total of the underlying asset the vault managesconvertToAssets(totalSupply()): the value of all shares at the administrator-set rate, including shares locked in requests at today's rate. It is not what the pool holds or manages
    convertToShares and convertToAssets must not revert except on overflowThey revert whenever exchangeRate() does: a Compounding rate more than 7 days old, or a Term maturity day in the past

    The asynchronous redemption flow is closer to ERC-7540, but it doesn't implement that either. requestRedeem(shares) takes no controller or owner, and there are no views for pending or claimable requests.

    Tools and integrators that see the heading, the interface file or the events may treat the pool as an ERC-4626 vault:

    • a generic ERC-4626 adapter (such as Pendle's standard wrapper for ERC-4626 vaults) would call functions that don't exist, or rely on a totalAssets that is not a real balance
    • indexers that listen for the standard Deposit event would see no deposits.

    Scenario:

    1. An integrator sees the "ERC-4626 Methods" heading and the Deposit event in ILYTPool, and plugs the pool into a generic ERC-4626 adapter.
    2. The adapter calls maxDeposit and previewRedeem, which don't exist, and its calls revert.
    3. An analytics indexer listening for the standard Deposit event records no deposits at all, because the pool emits PoolDeposit instead.

    Recommendation

    Consider the following:

    1. Removing the "ERC-4626" label, the unused IERC4626.sol, and the Deposit and Withdraw events that are never emitted, or emitting them.
    2. Documenting that LYTPool is a custom vault with asynchronous redemptions and an administrator-set rate.
    3. Confirming that the Pendle wrapper and any other integrator call the pool's own functions rather than a generic ERC-4626 adapter.
    4. Implementing the full interface if ERC-4626 or ERC-7540 compatibility is wanted, including max* and preview* views that return 0 instead of reverting when the action isn't allowed.
  8. The pool assumes a standard ERC-20 liquidity asset, but nothing enforces that

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    The protocol supports USDC, USDT and EURC pools. Threat model §7 assumes USDC only, but puts in scope "code that would break under a whitelisted asset that does not" behave like one.

    The pool handles all three tokens' calling conventions. It uses SafeERC20 for every transfer and never calls approve. The risks come from what the token issuers can do:

    • EURC is the same code as USDC. It adds nothing new.
    • USDT adds two powers. Tether can turn on a transfer fee, which is 0 today and capped below 20 bps with a per-transfer maximum. Tether can also blacklist an address and destroy its balance with destroyBlackFunds. USDC and EURC can only freeze a balance.
    Token behaviourEffect on the pool
    Fee on transfer (USDT)deposit forwards more than it received, and takes the difference from the claim reserve. depositFromTransfer credits the full amount for a short transfer. Payouts arrive short
    Balance destroyed or reduced (USDT, rebasing tokens)The balance falls below unclaimedAssets. The last claimants revert
    Pause or blacklist (all three)A token pause freezes every flow. A blacklisted treasury or fee collector halts deposits or settlement
    Upgradeable (all three)Any of the above can appear later, with no change to the pool

    Other non-standard behaviours (callbacks, reverting on zero transfers, positive rebases) don't apply to the supported assets. But nothing stops the operator from approving such a token.

    Scenarios (USDT pool on Ethereum):

    1. Tether turns on the fee.

    1. Tether sets a 10 bps fee, capped at 49 USDT per transfer.
    2. A lender deposits 1,000,000 USDT. The pool receives 999,951 and forwards 1,000,000 to the treasury. The missing 49 comes out of the claim reserve.
    3. After enough deposits, the balance falls below unclaimedAssets, and the last funded claims revert.

    2. Tether destroys the pool's balance.

    1. The pool holds 3,000,000 USDT reserved for claims.
    2. Tether blacklists the pool and calls destroyBlackFunds.
    3. Every claim reverts until the pool is unblacklisted and the treasury funds the reserve again.

    Recommendation

    Consider the following:

    1. Documenting which assets may be approved with setLiquidityAsset, and recording USDT's fee switch and fund destruction as accepted risks.
    2. Pulling deposits straight to the treasury, so a transfer fee never touches the reserve.
    3. Crediting what actually arrived in depositFromTransfer.
    4. Monitoring USDT's basisPointsRate and maximumFee, and calling pauseDeposits if either becomes nonzero.

    OpenTrade

    We do thorough testing before any new liquidity asset is added, Not a concern

    Cantina

    Acknowledged.

  9. Mints never check for zero shares allowing a deposit to be taken without any shares being issued

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    Each mint path computes shares = assets × 1e18 / exchangeRate(), which rounds down, and rejects only assets == 0. None of them rejects shares == 0. Whenever assets × 1e18 < rate, the call still:

    • adds the full assets to totalAssetsDeposited, assetsDeposited and the daily mint count
    • for deposit, pulls assets from the lender and forwards them to the treasury
    • mints zero shares and emits PoolDeposit for the full amount.

    At normal rates the loss is under one unit of the asset. That rounding is out of scope (threat model §9). But the redemption side gained a zero check in this change (requestRedeemExecute rejects assets == 0), and the mint side has no matching check. At a rate that is very wrong, whether posted by mistake or by a compromised key, every deposit below rate / 1e18 units is taken in full and issues nothing.

    Scenario:

    1. Automation posts a Dynamic rate of 1e30 instead of 1e18, by mistake.
    2. A lender deposits 500,000 USDC. 500,000e6 × 1e18 / 1e30 rounds down to 0 shares.
    3. The pool takes the 500,000 USDC and forwards it to the treasury. It mints no shares and emits PoolDeposit for the full amount.
    4. The lender holds nothing to redeem. Recovering the funds depends on the issuer acting off chain.

    Recommendation

    Consider reverting when shares == 0 in every mint path, mirroring the assets == 0 check on requests.

  10. Centralization risk: privileged roles can take, freeze or misprice user funds

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    LYTPool is a custodial product. Deposits go straight to an offchain treasury, an administrator sets the share price, and redemptions are paid only when the issuer funds or settles them. Holders therefore depend completely on a set of privileged keys. Permissionless mode extends that dependence to anonymous holders who have no offchain agreement with the issuer and no legal recourse.

    The table below lists each role, what it can do, and how it could abuse that power, whether by misusing it on purpose or because its key is compromised. "Bound" is any limit the code enforces on the role.

    RoleHow it is assignedCapabilitiesAbuseBound in the code
    DEFAULT_ADMIN_ROLE (service configuration)The deployer of the service configurationGrants and revokes every global roleGives itself or an accomplice any role below. Revokes the honest key-holdersNone
    DEPLOYER_ROLEGranted by the default adminsetImplementation on all three beacons. UUPS upgrades of the service configuration and registryReplaces the code of every pool, controller and allowlist on the network at once. That can mint, burn, take every pool's USDC, or brick everything. An implementation with no code makes every token call report false successNone. No timelock, and the change takes effect at once
    PROTOCOL_ADMIN, or a pool's poolAdminGlobal role, or the address that created the pool (not re-checked)Sets the issuer, issuer admin, deposit manager and pool admin. Toggles permissionless and claim mode. Sets the fee ceiling, the queue cap and the mint limit. Manages pool pausers, pauses and resumes. transitionTo(Closed). createPoolInstalls its own keys as issuer, issuer admin or deposit manager. Raises the fee ceiling to 100%. Clears the mint limit. Switches permissionless off to strand anonymous lenders. Closes a pool with shares outstanding, which freezes them for good. Sets maxOpenRequests to 1Mode switches and closing need an empty queue. Some role setters require the right role
    OPERATOR_ROLEGranted by the default adminBlacklists any address. Pauses and resumes deposits and withdrawals. Approves liquidity assets. Sets the factory and registry addresses. addPoolFreezes any holder, a DEX pair, the Pendle wrapper or the OFT adapter by blacklisting it. Approves a non-standard asset. Points the registry or factory at an attacker's contract. Resumes withdrawals in the middle of an incidentNone
    PAUSER_ROLE and pool pausersGranted by the default admin. Pool pausers are also listed per pool by the protocol adminProtocol-wide setPaused. pausePool and resumePool on its poolsFreezes every transfer, claim and DEX or bridge flow indefinitely. Lifts a pause set during an incidentThe pool pauser list and a role check on every call
    Issuer_settings.issuer. The role is checked only when it is setPush-mode processRedemption with amounts it chooses. openClaims. processRedemptions. transferToTreasuryWallet. The allowlist (allowParticipant and removeParticipant)Settles requests for nothing, or "off chain". Never funds or settles, so the queue starves. Removes a lender from the allowlist to block a funded claim. Allowlists a destination for a redirect. Acts as issuer admin and deposit manager when no overrides are setfreeAssets limits sweeps. Push payouts are blocked in claim mode
    Issuer adminOverride holding ISSUER_ADMIN_ROLE, otherwise the issuerRedirects payouts. cancelRedemption. Claims on a lender's behalf. Debits. Sets the redemption fee, minRedeemAssets, the fee collector and the treasury wallet. Tightens the mint limit. Sets rates (alongside automation)Redirects payouts to an address the issuer allowlists. Cancels particular lenders' requests over and over. Debits any holder, including integrators. Charges the maximum fee. Raises minRedeemAssets so nobody can redeem. Sets the mint limit to 1 wei, blocking every deposit. Points the treasury or fee collector elsewhere. Posts a wrong rateThe fee ceiling. The mint limit can only be tightened. Redirects need an allowlisted destination. A timelock, if one is configured
    Deposit managerOverride holding DEPOSIT_MANAGER_ROLE, otherwise the issuer admindepositOffChain, depositFromTransfer, adjustment creditsMints unbacked shares to itself or into a DEX pair, and sells them. Credits the same payment twice. Credits one client's transfer to another address. Keeps minting through credits while deposits are pauseddailyMintLimit, which counts assets rather than shares and is off by default. A timelock, if one is configured
    AutomationAUTOMATION_ROLEAll rate setters, and the indicative and collateral ratesPosts any rate at once, with no bound and no timelock. Mints at a near-zero rate and dumps on the DEX pairs, or inflates the rate before redeeming. Rolls the rate back with a stale value. Leaves a rate stale or breaks itRates must be nonzero. Compounding rates expire after 7 days
    Treasury wallet_settings.treasuryWalletAddr, which needs FIAT_GATEWAY when it is setReceives every deposit and every sweepKeeps deposits and never funds redemptions. Being USDC-blacklisted stops every depositNone. Offchain custody
    Fee collector_settings.feeCollectorAddress, which needs FEE_COLLECTOR when it is setReceives every redemption feeBeing USDC-blacklisted stops settlementNone
    Timelock proposer, executor and adminRecorded mainnet timelock: one EOA in every role, with a 5-second delaySchedules and runs every action of the issuer admin or deposit manager it servesTurns timelocked roles into instant single-key rolesminDelay, which is 5 seconds as deployed

    Users can't defend themselves on chain. A lender cannot cancel a request (threat model §12). Shares locked in a request can't be moved. There is no deadline, no escape hatch and no minimum-output protection on requests. Payouts depend completely on the issuer and the treasury acting. The threat model (§7) treats these roles as trusted, and the bounds above are the only limits the code enforces.

    Scenario:

    1. The DEPLOYER_ROLE key is compromised.
    2. The attacker calls setImplementation on the pool beacon with code that sends each pool's USDC to them. The change takes effect at once, with no timelock.
    3. In one block, every pool on that network loses its reserved liquidity, and the attacker can mint shares at will. Holders have no onchain recourse, and no delay in which to exit.

    Recommendation

    Consider the following to increase trust and transparency -

    • Keys: Hold privileged roles (e.g. DEFAULT_ADMIN_ROLE, DEPLOYER_ROLE, PROTOCOL_ADMIN and the operator) behind multisigs of different/distributed/isolated hardware keys at thresholds commensurate with privileges, with timelocks long enough for holders to react.
    • Bounds: Add onchain limits where they are missing: e.g. bounds on rate changes, share-denominated mint limits, a fixed ceiling on fees, a maximum minRedeemAssets, and re-checking roles on every call.
    • Monitoring: Monitor every privileged call onchain to detect unexpected behavior.
    • Incident Response: Document an IR playbook and go over wargaming scenarios to ensure detection, coordination, and response preparedness.

    OpenTrade

    These are set up with timelock contracts with multisig fireblocks wallets, monitored by Hypernative. This is a risk has been very clear with customers.

    Cantina

    Acknowledged.

  11. Removing a liquidity asset from the approved list has no effect on pools that already use it

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    isLiquidityAsset is checked in one place only: LYTPoolFactory.createPool, when a pool is created. After that, the pool keeps the asset in _liquidityAsset and never consults the approved list again. Calling setLiquidityAsset(asset, false) therefore only stops new pools from using the asset. Every existing pool on that asset keeps accepting deposits, funding claims and paying out in it.

    The approval is checked when the pool is created, but the deposits, claims and payouts that depend on it happen afterwards, so removing it doesn't reach them. Removing an asset is the natural response when it stops being safe: a depeg, a compromised or upgraded token contract, a fee switch turned on, or a regulatory action. The operator might expect removal alone to stop new money flowing into the affected pools, but it doesn't. Only a pause or pauseDeposits on each pool does.

    Scenario:

    1. A pool is created with stablecoin S while S is approved.
    2. S loses its peg. The operator calls setLiquidityAsset(S, false).
    3. Lenders keep calling deposit with S and receiving shares at the administrator-set rate, which assumes S is worth its face value. Nothing stops this until each pool's deposits are paused.

    Recommendation

    Consider the following:

    1. Checking isLiquidityAsset(_liquidityAsset) in the deposit paths (deposit, depositOffChain, depositFromTransfer), or having setLiquidityAsset(asset, false) pause deposits on every pool using the asset.
    2. At minimum, documenting that removing an asset only affects new pools, and that live pools must be paused separately.

    OpenTrade

    This is an extreme case is we have to delist a liquidity asset, we would have to freeze pools first and liquidate all assets. We will manage this off chain with our processes.

    Cantina

    Acknowledged.

  12. Cancelling a funded request that the lender never claims gives the lender a free option on the rate

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    Lenders cannot cancel their own requests. That rule exists so a lender can't hold a free option: fix a payout, then walk away from it if the rate rises (threat model §12). A lender can still get that option by never claiming a funded request:

    • If the rate falls after the request, the lender claims the fixed, higher payout.
    • If the rate rises, the lender doesn't claim. The request stays open, the reserve stays locked, and the open request blocks a mode switch and closing the pool. Sooner or later the issuer admin has to clear it. If it does so with cancelRedemption, the lender gets their shares back, now worth more than the fixed payout, and can request again at the higher rate.

    The lender keeps the downside protection and gets the upside back. Their only cost is the yield given up while waiting. Threat model §14 accepts that a holder may never claim, and names cancelRedemption as the way out, but it doesn't consider what cancelling returns to that holder.

    A better way out already exists. claim can be called by the issuer admin on the lender's behalf, which pays the recorded amount to the recorded destination and closes the request on the terms it was made. cancelRedemption is needed only for requests that can't be paid: a blacklisted lender or destination, a lender who is no longer allowed once permissionless is switched off, or a destination removed from the allowlist.

    Scenario (Dynamic pool, permissionless, claim mode):

    1. A lender requests redemption of 1,000,000 shares at rate 1.00, which fixes 1,000,000 USDC. The treasury funds it and the issuer opens it.
    2. The rate rises to 1.05. The lender doesn't claim.
    3. Months later, the protocol admin wants to switch modes. The issuer admin clears the open requests with cancelRedemption.
    4. The lender gets back 1,000,000 shares, now worth 1,050,000 USDC, and requests again. Had the rate fallen instead, the lender would have claimed the fixed 1,000,000 USDC.

    Recommendation

    Consider the following:

    1. Settling funded requests that are never claimed with claim on the lender's behalf, not cancelRedemption, and putting this in the runbook.
    2. Restricting cancelRedemption on a funded request to cases where claim would revert (the lender or destination is blacklisted or not allowed), so a payable request can't be cancelled.

    OpenTrade

    Commit #06730a1.

    Cantina

    Commit #06730a1 documents (1) in the Runbook.

  13. Solidity conventions and code-quality issues

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    Inconsistent application of Solidity conventions make the code harder for readability, integration and monitoring.

    AreaIssueWhere
    Naming: internal functionsInternal functions have no leading underscore, unlike the new _req*, _feeOn and _recordMint helpersLYTPool.requestRedeemExecute, LYTPool.convertToSharesRoundUp, LYTPoolFactory.initializePool
    Naming: state variablesA private variable has no underscore, and a public one has oneLYTPool.activeWithdrawKeys (private), LYTPoolRegistry._pools (public)
    Naming: typesStructs and enums carry the I prefix that by convention marks interfacesILYTPoolWithdraw, ILYTPoolWithdrawExtra, ILYTPoolFlags, ILYTPoolAccounting, ILYTPoolLifeCycleState and others
    Misleading errorsOne error is reused for unrelated failures, so a revert doesn't tell monitors or integrators what went wrongNotMarketMaker for a caller not on the allowlist, a destination not on the allowlist, a lender mismatch and a caller with no shares. PoolInvalidState for msg.sender != lender in deposit. InvalidAccess for zero amounts, bad lender addresses and a failed set update
    Unused declarationsErrors that are declared but never used, and an unused libraryLYTPool: RedemptionDoesNotCoverDeposits, PoolNotActive, InvalidLoan, CrossChainError, NoAdjustmentsToZeroBalance. LYTPoolController: NotProtocolAdminOrIssuer, PoolSettingsInvalid. LYTPoolServiceConfiguration: UpgradeRevert. LYTPool imports SafeCast with using SafeCast for uint256 but never calls it
    Mixed error stylesrequire with revert strings sits beside custom errorsBlacklistable, LYTPoolAccessControl, LYTPoolServiceConfiguration, the three factories, BeaconProxyFactory, DeployerUUPSUpgradeable
    Boolean comparisonsComparisons with boolean literals_serviceConfiguration.paused() == false in all three factories
    Magic numbersLiterals with no named constantThe per-lender cap 100. 1e18 repeated as the rate scale. 1 days for the day calculation. BPS_DENOMINATOR shows the intended pattern
    Duplicated logicThe share calculation is written out by hand in three deposit paths instead of calling convertToSharesdeposit, depositOffChain, depositFromTransfer
    Visibilitypublic where external is enough, because the function is never called internallyLYTPool: deposit, depositOffChain, depositFromTransfer, requestRedeem, addAdjustmentAmount, setExchangeRate, setIndicativeAndCollateralRates, transferToTreasuryWallet. LYTPoolController: the four rate setters
    Redundant parameterdeposit(assets, lender) takes a lender that must equal msg.senderLYTPool.deposit

    Scenario:

    1. A monitor alerts on requestRedeem reverts with NotMarketMaker.
    2. The error is the same for a caller not on the allowlist, a destination not on the allowlist, a lender mismatch and a caller with no shares.
    3. During an incident, the team can't tell from the revert which check failed. They have to trace each failing transaction by hand.

    Recommendation

    Consider the following:

    1. Prefixing internal and private functions and state variables with _, and dropping the I prefix from structs and enums.
    2. Giving each failure its own custom error, removing the unused errors and the SafeCast import, and replacing the remaining revert strings with custom errors.
    3. Introducing named constants for 100, 1e18 and the day length, and reusing convertToShares in the deposit paths.
    4. Marking functions external wherever they aren't called internally.

    OpenTrade

    would rather error on the side of stability than code hygene. We should keep these the same and migrate to conventions if we make changes

    Cantina

    Acknowledged.

  14. isClaimable and the daily mint counters can mislead offchain readers

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    Two public views return values that are accurate as stored, but not what a reader would take them to mean:

    • isClaimable reports funding, not whether claim will succeed. It returns true when a request is open and below the cutoff. claim can still revert for such a request: the pool may be paused or Closed, the lender may be blacklisted or no longer allowed, or the destination may be blacklisted or removed from the allowlist. The spec has the server call isClaimable before offering a claim. Lenders are then offered claims that revert.
    • The daily mint counters keep yesterday's total until someone mints. _recordMint resets mintTotalInDay only when the first mint of a new UTC day happens. Until then, the public mintTotalInDay still holds the previous day's total, and mintCurrentDay the previous day. A reader that works out today's remaining capacity as dailyMintLimit − mintTotalInDay gets yesterday's figure. A day that has minted nothing can look full, and the deposit manager or a front end may hold back deposits that would succeed.

    Scenario:

    1. Yesterday the pool minted its full dailyMintLimit of 5,000,000 USDC. Today nothing has been minted yet.
    2. A front end reads mintTotalInDay = 5,000,000 and shows that the limit has been reached, so it turns away a deposit that would succeed.
    3. Separately, the server reads isClaimable(uuid) = true for a lender who has since been removed from the allowlist. It offers the claim, and the lender's claim reverts with NotMarketMaker.

    Recommendation

    Consider the following:

    1. Adding a view such as mintedToday(), which returns 0 when block.timestamp / 1 days > mintCurrentDay, and pointing readers to it.
    2. Either making isClaimable also check what claim checks (pause, lifecycle state, blacklist, allowlist and destination), or adding a canClaim(uuid) view that does, and documenting that isClaimable reports funding only.

    OpenTrade

    Not needeed at this point, consider for the future

    Cantina

    Acknowledged.

  15. LYTPoolRegistry can list a pool no factory deployed, and delisting does not stop a pool

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    No contract reads the registry onchain. LYTPoolFactory.createPool now only writes to it. So isAPool, isPoolRegistered and pools() matter only to offchain tools and to integrators. Those readers are likely to treat a listing as proof that a pool is genuine and active. The two items below weaken that proof. Neither puts funds at risk.

    1. The operator can list any address as a pool. addPool accepts calls from the current factory or from any OPERATOR_ROLE holder. It does not check the address it is given. The address can be zero, an EOA, or a contract no factory deployed. A compromised or careless operator can list a fake pool, and a front end may then send users to it. The operator branch has no clear purpose, since createPool already registers each new pool.
    2. Removing a pool only edits the list. A removed pool keeps working. Its deposits, withdrawals, claims and roles are unaffected. Integrators who check isPoolRegistered may assume a delisted pool is stopped. Stopping it takes a separate pause through its controller.

    Scenario:

    1. A compromised operator calls LYTPoolRegistry.addPool(fakePool), where fakePool is a contract that copies the real pool's interface and keeps deposits.
    2. A front end that lists pools from isPoolRegistered shows fakePool beside the real ones.
    3. Users deposit into it. Their USDC goes to the attacker, and no real shares are minted.

    Recommendation

    Consider the following:

    1. Removing the operator branch from addPool. If it is needed to back-fill pools from legacy factories, checking the address against the current and legacy factories.
    2. Documenting that delisting does not stop a pool, and putting the pause step in the runbook.

    OpenTrade

    this is a helper function, and the list of pools is not cirical for LytPool implementations

    Cantina

    Acknowledged.

  16. The issuer cannot remove an address from the allowlist during a protocol-wide pause

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    0xRajeev


    Description

    allowParticipant and removeParticipant both carry onlyNotPaused. The modifier reads only the protocol-wide flag in the service configuration. It ignores the per-pool pause.

    So during a protocol-wide pause the issuer cannot delist anyone. Delisting can only happen after the pause is lifted. Lifting it takes the PAUSER_ROLE key, and delisting takes the issuer's key. The two cannot run in one transaction. In between, the address can still deposit, request redemption and claim.

    The gate protects nothing. Changing the list moves no value. Removing an address only makes the pool safer.

    The gate is also inconsistent. It blocks list changes during a protocol-wide pause but allows them during a pool pause. During a pool pause, the issuer can still add an address, which is the risky direction. During a protocol-wide pause, the issuer cannot remove one, which is the safe direction.

    Two workarounds exist. The operator can blacklist the address, which is not pause-gated. Or the admins can hold the pool shut with pausePool, lift the protocol-wide pause, delist the address, and then call resumePool. Neither is documented.

    Scenario:

    1. Allowlisted market maker X is involved in an incident. The pauser calls setPaused(true).
    2. The issuer calls removeParticipant(X). It reverts.
    3. The pauser calls setPaused(false). The transaction is visible in the mempool.
    4. X deposits or requests redemption before the issuer's removeParticipant(X) lands.

    Recommendation

    Consider the following:

    1. Removing onlyNotPaused from removeParticipant, while keeping it on allowParticipant, since widening the list is the risky direction.
    2. Having allowParticipant also check the pool's own pause, through the controller's paused(), so that a pool pause blocks it too.
    3. Documenting the pool-pause sequence in the incident runbook.

    OpenTrade

    Pool pause would be done first, and the participants can me managed., Will consider it for the next release

    Cantina

    Acknowledged.