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
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
depositandrequestRedeem:depositmints shares at the currentexchangeRate().requestRedeemfixes 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.
openClaimsonly requires the request's block to have passed, andprocessRedemptionshas 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 after Net to the attacker Front-running a −2% markdown 7 days ≈ +$190,000 (loss avoided) +2% recovery after a markdown 7 days ≈ +$194,000 Weekly Dynamic update (+0.096%) 1 day ≈ +$8,200 Weekly Dynamic update (+0.096%) 7 days ≈ $0 Daily Linear step 5–7 days Negative Scenario 1: front-running a markdown (Dynamic pool, rate
1.0, defaults in place):- 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.
- The holder calls
requestRedeem(10,000,000 shares), which fixesassets = 10,000,000 USDC. setExchangeRateDynamic(0.98e18)is mined.- 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.98after the markdown):- Automation submits
setExchangeRateDynamic(1.0e18)to reverse the markdown. - The attacker deposits 10,000,000 USDC at
0.98and receives 10,204,081 shares. - The update is mined, and in the next block the attacker calls
requestRedeem, which fixesassets = 10,204,081 USDC. - After a 7-day payout, the attacker nets about $194,000.
Scenario 3: depositing before a routine update (+0.096% weekly):
- The attacker deposits 10,000,000 USDC just before the update. They call
requestRedeemjust after it, which fixes 10,009,600 USDC. - If the treasury pays the next day, the attacker nets about $8,200. They can repeat this every week.
- 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 requestedAttoILYTPoolWithdrawExtraand set it toblock.timestampinrequestRedeemExecute. It packs into the slotrequestedAtBlockalready uses. Use time rather than blocks, because blocks per day differ between chains. - Add a setting. Append a protocol-admin setting
minClaimDelayto 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
processRedemptionsfor each request, and inprocessRedemptiontoo if push mode should follow the same rule. - Leave
cancelRedemptionandclaimunchanged. Cancel is the recovery path, andclaimalready depends onopenClaims. Requests made before the field existed read 0, so they pass the check.
- Record the request time. Add
- Operations. Before posting a known markdown, call
pauseWithdraws. Before posting a recovery or catch-up, callpauseDeposits. 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. ApauseWithdrawssent 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 inCallScheduledfor the whole delay, so post them through automation instead.
OpenTrade
Fixed in Commit #4889725
Cantina
Concerns with this proposed fix:
- Push mode isn't covered because only
openClaimschecks the delay.processRedemptionandprocessRedemptionsdon't check it in push mode. But this should be ok if there is another fix that allows permissionless pools only in claim mode. - 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.
- It defaults to 0. Pools get no protection until the protocol admin sets it on each one.
- Nothing requires it before going permissionless.
setPermissionless(true)doesn't check that the delay is set. - No size is recommended. The delay has to be at least the interval between rate updates, measured in that chain's fastest blocks.
- 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.
- The reverse case is untouched. A holder who requests just before a markdown still fixes the old value. The delay only postpones the payout.
- Changing the delay affects requests already queued.
openClaimsreads 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.
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 inhardhat.config.ts. It also makes it the fallback forDEPLOY_MNEMONICwheneverMNEMONIC_DEPLOYis not set (process.env.MNEMONIC_DEPLOY ?? UNSECURE_MNEMONIC). Theqaavalanchenetwork that usesDEPLOY_MNEMONICis Avalanche mainnet (chainId: 43114).The deployment recorded in
ignition/deployments/qaavalanchewas made with keys derived from that public mnemonic:- Index 0,
0xdF12A200570031De615a9bC72041e37bcfe89CF5. It deployed the service configuration proxy and ran itsinitialize, which grants the callerDEFAULT_ADMIN_ROLE. It also deployed the registry, the three factories and the implementations. - Index 2,
0xAc1Cd9077A664fce06BdCCe9609E3f3a3A611516. It calledsetImplementationon all three beacons, so it holdsDEPLOYER_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…9CF5and0xAc1C…1516still holdDEFAULT_ADMIN_ROLEorDEPLOYER_ROLE(or any other role) in theqaavalancheservice configuration0x7a80…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_DEPLOYis 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
Revoking an override's role hands its powers to the issuer
Severity
- Severity: Medium
Submitted by
0xRajeev
Description
issuerAdmin()re-checksISSUER_ADMIN_ROLEon the override, and returns_settings.issueronce that role is revoked.depositManager()works the same way, falling back toissuerAdmin(). 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:
- On pool P, the issuer admin override is timelock
T(which holdsISSUER_ADMIN_ROLE), and the issuer isI. - The protocol admin revokes
ISSUER_ADMIN_ROLEfromT, intending to name a replacement later. issuerAdmin()now returnsIon every pool whereTis the override.IcallsallowParticipant(X), thenchangeRedemptionDestination(lender, uuid, X)on an open 1,000,000 USDC request.IcallsprocessRedemptions([uuid]), and X receives the payout. No timelock or second key was involved.
Recommendation
Consider:
- 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. - 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.
A pool's poolAdmin keeps protocol-admin powers after its role is revoked
Severity
- Severity: Medium
Submitted by
0xRajeev
Description
Every
onlyProtocolAdminfunction is gated by:return isProtocolAdmin(msg.sender) || msg.sender == poolAdmin;The second clause accepts a pool's
poolAdminby address alone, without checking whether it still holdsPROTOCOL_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.onlyAuthorizedacceptspoolAdminby 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 clearingsetDailyMintLimit. - Roles:
setIssuer,setIssuerAdmin,setDepositManagerandsetPoolAdmin. - 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
onlyProtocolAdminNatspec 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:
- Protocol admin
Acreates pool P, which setspoolAdmin = A. A's key is suspected compromised, soPROTOCOL_ADMINis revoked fromA.- During the incident, the remaining admins pause P with
pausePool(). AcallsresumePool()on P, thensetPermissionless(true)andsetDailyMintLimit(0). P is now open to anyone, with no mint limit.Akeeps these powers until another protocol admin callssetPoolAdminon P, and on every other poolAadministers.
Recommendation
Consider the following:
- 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.
- Any protocol admin manages any pool (what the code does today): using
- Applying the same role check to
poolAdmininonlyAuthorized. - Updating the Natspec to match whichever model is chosen.
Revoking ISSUER_ROLE does not remove a pool's issuer
Severity
- Severity: Medium
Submitted by
0xRajeev
Description
issuer()returns_settings.issuerwithout checking whether that address still holdsISSUER_ROLE. The role is checked only ininitializeandsetIssuer. Every issuer gate reads this value:_reqIssuerin the pool andonlyIssuerinLYTPoolAccessControl.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_ROLEis revoked from a compromised issuer key, that key keeps:- Payouts.
processRedemptionin push mode, where the issuer choosesrepaymentandfeesfreely. - Liquidity.
openClaimsandtransferToTreasuryWallet. - The allowlist.
allowParticipantandremoveParticipant. - 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
setIssueron every affected pool. The threat model puts "a revoked role that keeps working" in scope (§9).Scenario (push mode, permissionless):
- Issuer key
Iis suspected compromised, so the default admin revokesISSUER_ROLEfromI. - The attacker deposits 1,000 USDC from address
Eand callsrequestRedeemon the shares. - The pool holds 2,000,000 USDC that the treasury sent for repayments.
- Using
I, the attacker callsprocessRedemption(E, 2,000,000e6, 0, uuid). The destination is the lenderE, so it passes_reqPayoutDestination. The pool sends all 2,000,000 USDC toE.
Recommendation
Consider the following:
- Re-checking the issuer's role when the issuer is resolved. For example,
issuer()could returnaddress(0)when_settings.issuerno longer holdsISSUER_ROLE, so every issuer gate fails closed. - Applying the same check in the fallback branch of
issuerAdmin().
A blacklisted spender can still move other holders' shares with transferFrom
Severity
- Severity: Medium
Submitted by
0xRajeev
Description
_updatechecks the blacklist onfromandtoonly.transferFromis inherited fromERC20Upgradeablewithout 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.senderintransferFromfor exactly this reason. Permissionless mode makes it matter more, because any address can now receive the shares and redeem them.Scenario:
- Holders have approved router
Rto move their pool shares, so that they can trade on a DEX pair. Ris compromised, and the operator blacklists it.RcallstransferFrom(holder, attacker, balance)for every approved holder._updatechecks onlyholderandattacker, neither of which is blacklisted, so every transfer succeeds.- In a permissionless pool,
attackercallsrequestRedeemon the shares, or sells them on a DEX pair.
Recommendation
Consider the following:
- Overriding
transferFrom(or_spendAllowance) to revert when spender is blacklisted, matching USDC. - Rejecting
approvecalls from or to blacklisted addresses.
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 holdFEE_COLLECTOR. Both roles are checked only when the address is set, ininitializeand the two setters.treasuryWalletAddr()andfeeCollectorAddress()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.
depositsends every deposit there.depositFromTransferandtransferToTreasuryWalletsend pool liquidity there. - Fee collector.
claim,processRedemptionandprocessRedemptionssend every redemption fee there. Lenders callclaimthemselves, so fees keep flowing without any action by the issuer.
The way to stop it is
setTreasuryWalletAddrorsetFeeCollectorAddress. 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 stopsdepositanddepositFromTransfer. It does not stoptransferToTreasuryWalletor 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:
- The treasury wallet's key is compromised. The protocol admin revokes its
FIAT_GATEWAYrole. - A user deposits 1,000,000 USDC.
depositsends it to the compromised wallet. - The issuer admin schedules
setTreasuryWalletAddron each pool. Every deposit made during the timelock delay also goes to the compromised wallet. - The operator can call
pauseDepositsto stop new deposits. The issuer can still calltransferToTreasuryWalletand send free pool liquidity to the same wallet.
Recommendation
Consider the following:
- Re-checking the role when the address is read. If the treasury wallet no longer holds
FIAT_GATEWAY, makingtreasuryWalletAddr()revert, so deposits and sweeps stop at once. - Doing the same for the fee collector and
FEE_COLLECTOR. - 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
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
maxOpenRequestsslot, so honestrequestRedeemcalls revert withTooManyOpenRequests. Each time a slot is freed, the attacker can take it again. - Blocking admin transitions.
setClaimBasedRepaymentandtransitionTo(Closed)requireactiveWithdrawCount() == 0, so a single open request blocks both. - Making recovery expensive.
- Claim mode:
cancelRedemptionaccepts only funded requests, so the issuer has to fund and open the attacker's requests before clearing them. - Push mode: pausing withdrawals also blocks
processRedemptionandprocessRedemptions. The only way to empty the set is then to cancel every request, including honest ones.
- Claim mode:
Each slot costs the attacker
minRedeemAssetsof capital until payout, plus the fee on it. The capital itself comes back. Recovery is possible because an operator orpoolAdmincan callpauseWithdraws(invariant 15), but this procedure is not documented.Scenario (
maxOpenRequests = 500,minRedeemAssets = 1,000 USDC, fee 0.1%):- The attacker deposits 500,000 USDC and spreads the shares across 5 addresses, because each address is capped at 101 requests.
- The attacker opens 500 requests of 1,000 USDC each.
- An honest lender's
requestRedeemreverts withTooManyOpenRequests. - 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.
- The protocol admin's
setClaimBasedRepayment(false)reverts withRequestsStillOpenfor as long as the attacker keeps going.
Recommendation
Consider the following:
- Removing the cap while the pool is permissionless. Not applying
maxOpenRequestswhile the pool is permissionless, or removing it. Nothing onchain walks the open set then.openClaimsandclaimrun in constant time. The mode-switch and close guards readactiveWithdrawCount().getActiveWithdrawsalready returns an empty list, and the server builds the open set from events. Without a cap, no one can be locked out of requesting. - Guarding the way back. Making
setPermissionless(false)revert whileactiveWithdrawCount()is above whatgetActiveWithdrawscan safely walk. This is the only reason the cap exists. Admins then drain the queue before switching. - Pricing each request. Sizing
minRedeemAssetsand the fee against the attacker's cost of capital, not only against gas. Without a cap, these are what make spam expensive. - Clearing the queue. Removing
withdrawStateActivefromprocessRedemptions, so that push mode can drain the queue while withdrawals are paused. LettingcancelRedemptionremove requests above the cutoff in claim mode, adjusting the cumulative accounting to match (for example, with acancelledAboveCutoffterm). These address the blocked mode switch and close, which removing the cap does not. - 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.
Any depositor can use up the shared daily mint limit
State
- Acknowledged
Severity
- Severity: Low
Submitted by
0xRajeev
Description
dailyMintLimitis a single per-pool counter shared bydeposit,depositOffChain,depositFromTransferand 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):- At 00:00 UTC the attacker deposits 5,000,000 USDC.
- A client's 2,000,000 USDC wire has already reached the treasury. The deposit manager's
depositOffChainreverts withDailyMintLimitExceeded. - A corrective
addAdjustmentAmountcredit and every other depositor'sdepositalso revert until the next UTC day. - The attacker requests redemption, gets the capital back less the fee, and repeats the next day.
Recommendation
Consider keeping separate limits for permissionless
depositand 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.
Lowering the fee ceiling does not lower the current fee
State
- Acknowledged
Severity
- Severity: Low
Submitted by
0xRajeev
Description
setMaxRedemptionFeeBpsdoes 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:
- The ceiling is 100 bps, and the issuer admin has set the fee to 100 bps.
- The protocol admin lowers the ceiling to 20 bps.
- 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
RedemptionFeeBpsSetwhen 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.
Pausing withdrawals blocks processRedemptions
Severity
- Severity: Low
Submitted by
0xRajeev
Description
processRedemptionshaswithdrawStateActive, butclaim,openClaimsandcancelRedemptiondo not. The spec says pausing withdrawals stopsrequestRedeemonly. 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, becauseprocessRedemptionalready had the same modifier. A flooded queue then cannot be drained during a withdraw pause except by cancelling every request.changeRedemptionDestinationcarries the same modifier, so during a withdraw pause a payout whose destination has been removed from the allowlist cannot be repaired, only cancelled.Scenario:
- In claim mode, an operator calls
pauseWithdrawsduring an incident. - Lenders who claim are paid, but the issuer's
processRedemptions([...])reverts withPoolInvalidState. - In push mode, every open request stays unpaid until withdrawals resume, and resuming lets an attacker refill the queue.
Recommendation
Consider removing
withdrawStateActivefromprocessRedemptionsandchangeRedemptionDestination, and fromprocessRedemptiontoo if repayments should continue during a withdraw pause.OpenTrade
Cantina
Commit #e93dd53 removes
withdrawStateActivefromprocessRedemptionandprocessRedemptions, so the issuer can pay redemptions during a withdraw pause in both modes. ButchangeRedemptionDestinationstill carries the gate, so payouts can't be redirected while withdrawals are paused.previewRedeemRequest returns the amount after the fee, but requestRedeem returns the amount before it
Severity
- Severity: Low
Submitted by
0xRajeev
Description
previewRedeemRequestreturnsassets − fee, whilerequestRedeemandRedeemRequestedreport the fullassets. 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):
previewRedeemRequest(shares)returns 9,950.requestRedeem(shares)returns 10,000, andRedeemRequestedlogs 10,000.- 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.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:
- A lender sees
previewRedeemRequest = 9,950 USDCand submitsrequestRedeem. - A −2% markdown lands first.
- The request fixes 9,751 USDC, and the lender cannot cancel it.
Recommendation
Consider adding
requestRedeem(uint256 shares, uint256 minAssetsOut), which reverts ifassets − 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.
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
maxOpenRequestsandminRedeemAssets. - A
dailyMintLimitset.
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,
getActiveWithdrawsandgetLYTPoolOverviewStatewalk the whole set and can run out of gas (§11).Scenario:
- The protocol admin calls
setPermissionless(true)on a pool that was just upgraded and still has every default. - An attacker funds 200 addresses and opens 101 requests worth 1 wei each from every one of them, about 20,000 requests in total.
- Later the protocol admin calls
setPermissionless(false).getLYTPoolOverviewStatenow runs out of gas, and the server's overview of the pool fails.
Recommendation
Consider:
- Making
setPermissionless(true)revert unlessmaxOpenRequests,minRedeemAssets,dailyMintLimitand the redemption fee are all nonzero. - Making
setPermissionless(true)revert unless claim mode is on, if claim mode is required. - Making
setPermissionless(false)revert whileactiveWithdrawCount()is above whatgetActiveWithdrawscan safely walk.
OpenTrade
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.
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 gatessetPausedin both directions. GatingsetPausedthis 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:
- The protocol admin lists
Pas the pauser for pool A andQas the pauser for B, which requires grantingPandQthePAUSER_ROLEacross the protocol. - During an incident affecting pools A and B,
PandQcallpausePool()on A and B respectively. - However,
P's key has been unknowingly compromised. - Using the compromised key,
setPaused(false)is called, which unpauses both A and B. - The pools stay open until the admin revokes
PAUSER_ROLEfromPand pauses again.
Recommendation
Consider:
- A separate
POOL_PAUSER_ROLEfor pool-level pausers that is separate from protocol-widePAUSER_ROLE. UNPAUSER_ROLEandPOOL_UNPAUSER_ROLEto specifically authorize unpause (typically considered more privileged than pause), for bothsetPaused(false)andresumePool.
OpenTrade
We are ok whith this , these are roles with multi signature fireblocks wallets.
Cantina
Acknowledged.
Holders whose whole balance is below minRedeemAssets can never redeem
State
- Acknowledged
Severity
- Severity: Low
Submitted by
0xRajeev
Description
requestRedeemExecutereverts withRedeemBelowMinimumwheneverassets < 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 orpoolAdmincontrols) and without aWithdrawStateTransitionevent.Scenario (
minRedeemAssets = 1,000 USDC):- A holder owns shares worth 600 USDC, bought on a DEX pair.
requestRedeem(allShares)reverts withRedeemBelowMinimum.- The holder can never redeem through the pool, however long they wait.
Recommendation
Consider the following:
- Exempting a request for the holder's entire free balance (
shares == _freeShares(lender)) from the minimum. - 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.
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
processRedemptionsis all-or-nothing: if any uuid in the batch is no longer open,_requireOpenRequestreverts withRedemptionNotFoundand the whole batch fails. In claim mode, the lender of any opened request canclaimit 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:
- Claim mode. The issuer has opened 300 requests and sends
processRedemptionswith all 300. Twenty of them belong to the attacker. - The attacker sees the transaction and calls
claimon one of their 20 requests with a higher priority fee. - The batch reverts. The issuer removes that uuid and sends again, and the attacker repeats this with the next of their requests.
- The other 280 lenders are paid only after up to 20 failed attempts.
Recommendation
Consider the following:
- Skipping any uuid that is no longer open in
processRedemptionsinstead of reverting. For example, usingcontinuewhen_activeWithdraws[uuid].stage != Requested. - Still reverting for the other failures (blacklist, destination).
- 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.
A single large request holds up every later request in claim mode
State
Severity
- Severity: Low
Submitted by
0xRajeev
Description
In claim mode, requests are paid strictly in block order:
openClaimsopens a whole prefix of blocks from the cutoff, and requests in the same block are always opened together.processRedemptionsandcancelRedemptionboth 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
requestRedeemin 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):
- A victim requests 50,000 USDC at block
b. The attacker front-runs it with a request for 20,000,000 USDC, also at blockb. openClaimsfor blockbneeds 20,050,000 USDC of free balance.- The victim can't be paid for about 10 days, and neither can any lender who requested after block
b.
Recommendation
Consider the following:
- Ordering the queue by request instead of by block. Each request already has a unique cumulative position. It is
cumulativeAssetsTotalafter the request, which is already in its uuid preimage. This means storing it on the request, holding the cutoff as a cumulative amount, and makingisClaimablereadcumulativeAfter <= cumulativeAtCutoff.openClaimsthen 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. - Letting the issuer admin cancel requests above the cutoff by recording a cancelled amount that the next
openClaimssubtracts, to keep the rest of the queue moving. - Alternatively, capping the size of a single request.
OpenTrade
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
processRedemptionorprocessRedemptions, 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.
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
blacklistor a debit coming in the mempool or in the timelock'sCallScheduledevent. 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. removeParticipantdoes not freeze shares._updatenever 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
requestRedeemduring the timelock delay locks their shares in the request. The debit then reverts withBurnExceedsBalance. - 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.
addAdjustmentAmountchecks_accessAllowed(lender)before it debits. Outside permissionless mode, a holder removed withremoveParticipantcan no longer be debited: the call reverts withNotMarketMaker. 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).
- The operator sends
blacklist(H). Hsees it in the mempool and front-runs it withtransfer(H2, shares).H2is a fresh address. Receiving shares needs no allowlist.- The blacklist lands on an address with no shares.
H2callsrequestRedeem, which the permissionless pool allows.H2is paid at settlement. - Blacklisting
H2blocks that payout, butH2can front-run the second blacklist the same way. Even if it lands, the shares are only frozen. They are not recovered, because a debit ofH2is ruled out too (scenario 4).
2.
removeParticipantdoes not need to be front-run.- The pool is allowlisted. The issuer calls
removeParticipant(H). Hcan no longer deposit, request or claim. But_updatenever checks the allowlist, soHcan still transfer.Hsends the shares to an allowlisted accomplice or sells them on a DEX pair. The accomplice or buyer redeems them.- In a permissionless pool,
removeParticipanthas no effect onHat all.
3. Locking shares in a request ahead of a debit.
- The issuer admin schedules
addAdjustmentAmount(H, 1,000,000e6, 0)through its timelock. HseesCallScheduledand callsrequestRedeemfor all their shares, soHhas no free shares left.- The debit executes and reverts with
BurnExceedsBalance. - In push mode, the issuer admin can schedule
cancelRedemptionand the debit as one timelock batch.Hcannot step in between, but this costs a second delay. In claim mode,cancelRedemptionreverts withNotClaimableuntil the request is opened. Opening it letsHcallclaimand be paid before the batch executes. Stopping that claim takes a blacklist, which leads to scenario 4.
4. Blacklisting rules out the debit.
- To stop
Hmoving or locking the shares during the timelock delay, the operator blacklistsHfirst. Transfers andrequestRedeemnow revert forH. - The debit executes.
_burnruns_update, whosenotBlacklistedTransfer(H, address(0))reverts. - The debit can only succeed once
His unblacklisted. The operator'sunblacklistand the issuer admin's debit come from different keys, so they are separate transactions.Hcan transfer or request in between, which brings back scenario 1 or 3.
5. Delisting rules out the debit.
- The pool is allowlisted. The issuer calls
removeParticipant(H)to stopHredeeming. - The debit executes and reverts with
NotMarketMaker, becauseaddAdjustmentAmountchecks_accessAllowed(H). - The debit can only succeed once the issuer calls
allowParticipant(H)again.Hcan then callrequestRedeembefore the debit lands, which brings back scenario 3.
Recommendation
Consider the following:
- Letting a debit burn from a blacklisted or delisted holder. This means skipping the blacklist check in
_updatewhento == address(0)and the caller is the controller, and skipping the_accessAllowedcheck inaddAdjustmentAmountfor debits. - Relying on "freeze first, then debit" as the enforcement sequence. Blacklisting the holder already blocks requests, because
requestRedeemchecksnotBlacklisted(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. - Adding a
freezethat blocks transfers and requests but still allows a debit. - 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.
- 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,cancelRedemptionand the timelock'sexecuteof a debit. The front-running in scenarios 1, 3 and 4 assumes a public mempool. A private relay does not hide the timelock'sCallScheduledevent, 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
Cantina
Commit #c6b335e lets a debit ignore the blacklist and no longer need the holder to be allowed. Rest of the concerns remain unfixed.
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
depositOffChainanddepositFromTransferbehind their own timelock. Both functions mintassets × 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
dailyMintLimithas been used up by then. It also reverts ifopenClaimshas already used the client's direct transfer, ortransferToTreasuryWallethas 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
TimelockControllerwith a 2-day delay.1. A markdown during the delay (Dynamic pool).
- A client wires 5,000,000 USDC at rate 1.00.
depositOffChain(5,000,000e6, client, txHash)is scheduled. - A −2% markdown is posted the next day.
- 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).
dailyMintLimitis 10,000,000 USDC. A client wires 5,000,000 USDC, anddepositOffChain(5,000,000e6, client, txHash)is scheduled.- On the day the call becomes ready, other lenders'
depositcalls have already used 6,000,000 USDC of the day's limit. - The call executes and reverts with
DailyMintLimitExceeded. The executor has to wait for the count to reset at 00:00 UTC and retry. - Anyone can fill the limit with
depositjust 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.
- A client transfers 5,000,000 USDC straight to the pool, and
depositFromTransfer(5,000,000e6, client, txHash)is scheduled. - During the delay, the issuer sweeps the pool with
transferToTreasuryWallet(freeAssets()). In claim mode,openClaimscould reserve the same balance for redemptions instead. - The call executes and reverts with
ClaimedAssetsReserved, becausefreeAssets()is now below 5,000,000 USDC. - 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:
- 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.
- Alternatively, keeping the deposit-manager delay shorter than the interval between rate updates.
- 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.
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.
_recordMintcountsassets, butdepositmintsassets × 1e18 / rate. At a wrong rate, 1 USDC within the limit can mint any number of shares. - The mint limit is off by default.
dailyMintLimitstarts 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).- Automation posts
setExchangeRateDynamic(1e12)instead of1e18, by mistake or through a compromised key. - 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.
- 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).
- The attacker calls
depositOffChain(100,000,000e6, pair, 0x0). The pair passes_accessAllowedbecause the pool is permissionless, and the limit is 0. - The pair now holds more pool shares than its reserves record. The attacker calls
swapto take the surplus as USDC.
Recommendation
Consider the following:
- 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.
- 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.
- Requiring a nonzero
dailyMintLimitbeforesetPermissionless(true), and adding a separate, smaller limit for the deposit manager. - 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).
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 anddepositManager()returnsissuerAdmin(). That is the state of every new pool, and of any live pool where no override was set.createPoolcannot set the overrides atomically, andsetIssuerAdminaccepts the issuer's own address, provided it holdsISSUER_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:
- The protocol admin creates a new pool, or upgrades a live pool that has no override.
- Until
setIssuerAdminandsetDepositManagerare called, the issuer can allowlistX, redirect any open redemption toX, and mint shares throughdepositOffChain, all with no delay. - If the issuer key is compromised in that window, nothing stops it.
Recommendation
Consider the following:
- Making
setIssuerAdminandsetDepositManagerreject the issuer's address, and reject each other's. - Letting the factory set both overrides when it creates a pool.
- Making
setPermissionless(true)require both overrides to be set, valid and distinct from the issuer. - Setting the overrides for the live pools in the same upgrade batch.
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
transferTxHashthat identifies the client's payment, but neither one stores it, so nothing stops the same payment being credited twice.depositOffChaineven 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
depositOffChainand again bydepositFromTransfer. - Adjustments.
addAdjustmentAmounttakes no reference of any kind. A credit or debit scheduled twice, or scheduled again after a failure, is applied twice.
Scenario:
depositFromTransfer(5,000,000e6, client, txHash)is scheduled. It reverts at execution because the day's mint limit has been used up.- The operator schedules it again with a new salt. Later, the first operation is also retried and succeeds after the limit resets.
- 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:
- Storing every credited
transferTxHashin one mapping shared by both deposit functions, and reverting when one is reused. - Requiring a nonzero hash in
depositOffChain. - Giving
addAdjustmentAmounta 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.
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. AftersetPermissionless(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
claimreverts withNotMarketMaker - so does the issuer admin's
claimon the lender's behalf processRedemptionandprocessRedemptionsrevert 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, becauserequestRedeemis gated byonlyMarketMaker. 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.setClaimBasedRepaymentrefuses 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):
L, who is not on the allowlist, buys shares on the Uniswap pair and requests redemption of 100,000 USDC.- The protocol admin calls
setPermissionless(false). - The issuer funds and opens the range that includes
L's request.L'sclaimreverts, and so does the issuer admin'sclaimforL. AprocessRedemptionsbatch that includesLreverts for everyone. - The issuer admin cancels the request.
Lgets the shares back, cannot request again, and has to sell them on the DEX, at whatever discount the market sets.
Recommendation
Consider the following:
- Deciding explicitly how requests accepted while permissionless are treated, by either:
- recording in
_withdrawExtrawhether 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 waysetClaimBasedRepaymentdoes.
- recording in
- 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.
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
setClaimBasedRepaymentreverts withRequestsStillOpenunlessactiveWithdrawCount() == 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,
cancelRedemptionandprocessRedemptionsaccept only requests thatopenClaimshas 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.
- Paying needs withdrawals active, because both push-mode settlement functions check
-
Finding the requests to close. While the pool is permissionless,
getActiveWithdrawsreturns 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.
- 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.
- The issuer wants to switch to push mode to pay every lender pro rata.
setClaimBasedRepayment(false)reverts withRequestsStillOpen. - 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.
- A live push-mode pool has 120 open requests, some from before the upgrade, worth 8,000,000 USDC.
- 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:
- Letting claim mode's unfunded requests carry over into push mode. Allowing
setClaimBasedRepayment(false)wheneverunclaimedAssets == 0, even if requests above the cutoff are still open. LeavingclaimCutoffBlockwhere 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. - 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. - 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:
cumulativeAssetsByBlockstill counts requests closed in push mode, soopenClaimsover-reserves liquidity that is never released. Instead, pausing withdrawals and settling every request in push mode (which requires removingwithdrawStateActivefrom the push-mode settlement functions), rather than cancelling, so lenders keep their recorded payout. - 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
Cantina
Commit #b5aa068 documents the procedure.
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 arepausePool,resumePool,addPoolPauserandremovePoolPauser. 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
dailyMintLimitormaxOpenRequests, or raisingminRedeemAssets. pauseDepositsandpauseWithdraws.- Replacing a compromised key with
setIssuerAdmin,setDepositManager,setIssuerorsetPoolAdmin.
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
resumePoolwith the fix in one transaction, if it is a multisig that supports batching. - For a protocol-wide pause, unpausing takes the
PAUSER_ROLEkey, 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):
- A pauser calls
setPaused(true)across the protocol. - The protocol admin tries
setPermissionless(false)andsetDailyMintLimit(…). Both revert withPaused. - 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
onlyNotPausedthe changes that only make the pool more restrictive:setPermissionless(false).- Lowering limits and caps, and raising the minimum.
pauseDepositsandpauseWithdraws.- Naming a replacement issuer admin or deposit manager.
Server will be unable to fulfill redemptions in permissionless push mode because getActiveWithdraws() returns nothing
Severity
- Severity: Low
Submitted by
0xRajeev
Description
getActiveWithdrawsreturns 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 fromgetActiveWithdraws, 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,
getActiveWithdrawsreturns nothing and "open requests are readable only by uuid throughwithdrawExtra". That understates the gap:- No onchain source of uuids.
activeWithdrawKeysis private and has no getter. The uuids appear only inRedeemRequestedevents. withdrawExtrareturns only part of a request. It returnsrequestedAtBlockandfee. The lender,assets,sharesandfundsDestinationare in_activeWithdraws, which is private and has no getter by uuid. They are available only fromRedeemRequestedandRedeemDestinationChanged.- 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 noRedeemRequestClaimModeevent as one made before the upgrade. Push-mode requests never emit that event.
Settlement itself still works.
processRedemptionsneeds only uuids, andprocessRedemptionneeds 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:
- The protocol admin calls
setPermissionless(true)on a push-mode pool, which the code allows. - Lenders request redemption. Each request emits
RedeemRequestedonly. - The server's withdrawal list for the pool, built from
getActiveWithdraws, is empty, so the issuer sees nothing to repay.
Recommendation
Consider the following:
- Making permissionless mode require claim mode, as threat model §13 already assumes:
setPermissionless(true)reverts unless claim mode is onsetClaimBasedRepayment(false)reverts while the pool is permissionless.
- Alternatively, emptying
getActiveWithdrawsonly in claim mode, and adding a paginated list of keys.
OpenTrade
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.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 withExchangeRateInvalid, 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
exchangeRateChangeRateevery 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):
- Automation posts a rate with a 5% APY slope, then stops (for example, its key is rotated and the job isn't restarted).
- The fund's actual yield falls to 3%. After 60 days the onchain rate is about 0.33% above NAV.
- 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.
- Nothing on chain marks the rate as stale or stops the requests.
Recommendation
Consider the following:
- Applying one staleness limit to every rate type. For example, adding a protocol-admin setting
maxRateAge(in days), and makingexchangeRate()revert, or makingdepositandrequestRedeemrevert, oncetoday − exchangeRateSetDay > maxRateAge. Claims and repayments should keep working, because they use the recorded amounts. - 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.
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
setImplementationstores any address, includingaddress(0), an EOA, or a mistyped address. OpenZeppelin'sUpgradeableBeaconrejects an address with no code, but this custom beacon does not.If a beacon points at an address with no code, each
BeaconProxydelegatecalls into nothing. The call succeeds and returns empty data:- Callers that decode a return value, such as
balanceOf, revert. - Callers that use
SafeERC20treat the empty return as success, because the token address (the proxy) has code.transferandtransferFromthen report success without moving anything.
The pool token's holders include an OFT adapter, which locks shares with
safeTransferFrombefore 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 usesSafeERC20would 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:
- 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. - Every pool now delegatecalls into an address with no code.
- A user bridges 1,000,000 shares. The OFT adapter's
safeTransferFromreturns success without moving any shares, and the remote chain mints 1,000,000 bridged shares that nothing backs. - Until the beacon is fixed, every
SafeERC20caller sees deposits and transfers that never happened.
Recommendation
Consider the following:
- Reverting in
setImplementationunlessnewImplementation.code.length > 0, as OpenZeppelin'sUpgradeableBeacondoes. - 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.
Issuer can settle anonymous lenders' requests at amounts it chooses in permissionless push mode
State
- Acknowledged
Severity
- Severity: Low
Submitted by
0xRajeev
Description
processRedemptionis blocked only in claim mode. In push mode it burns the lender's shares and pays whateverrepaymentandfeesthe 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, andchangeRedemptionDestinationlets 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
assetsandfeeare ignored. - Offchain with no counterparty. The issuer admin can set the destination to zero. The issuer can then burn the shares and log a
RedeemRepayfor 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
getActiveWithdrawsreturns 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):
- A holder who bought shares on the LFJ pair requests redemption of 50,000 USDC.
- The issuer calls
processRedemption(holder, 0, 0, uuid). The shares are burned and the holder receives nothing. - Alternatively, the issuer admin redirects the request to
address(0)and the issuer settles it "off chain", which emits a normal-lookingRedeemRepaywith nothing paid.
Recommendation
Consider the following:
- Making permissionless mode require claim mode.
- If permissionless push mode must stay, then while the pool is permissionless:
- making
processRedemptionrevert, or requiringrepayment + feesto equal the recordedassetsandfeesto equal the recorded fee - rejecting a zero destination in
changeRedemptionDestination, as claim mode already does.
- making
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.
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
exchangeRateSetDayto 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):
- Day 1: the issuer admin schedules
setExchangeRateLinear(1.0500e18, step, rate)to correct the slope. Automation keeps posting daily. - 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. - Anyone who saw
CallScheduleddeposits right after execution and requests redemption after automation's next post, which restores the rate.
Recommendation
Consider the following:
- Giving each rate setter an
asOfDay(or an expected current rate) and a deadline, and reverting ifasOfDay < exchangeRateSetDayor if the deadline has passed. - Routing all routine posting through one writer, and making issuer-admin overrides carry the same freshness check.
- Setting
exchangeRateSetDayfromasOfDayrather 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.
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,depositOffChainanddepositFromTransferall carrydepositStateActive. 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
pauseDepositsstops 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 ispauseDeposits, which any operator orpoolAdmincan call at once. The deposit manager can carry on minting throughaddAdjustmentAmount(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:
- Unusual
depositOffChainmints are noticed from the deposit manager. The operator callspauseDeposits. - The deposit manager calls
addAdjustmentAmount(attacker, 0, 10,000,000e6), and the shares are minted. - The attacker sells the shares on a DEX pair. Stopping this takes
pausePoolorsetPaused, which freezes every holder.
Recommendation
Consider the following:
- Applying
depositStateActiveto the credit branch ofaddAdjustmentAmount, in the pool or the controller, so a deposit pause stops every mint. Debits can stay outside it. - 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.
A USDC blacklist on the fee collector or treasury wallet halts settlement or deposits
State
Severity
- Severity: Low
Submitted by
0xRajeev
Description
USDC, the asset this protocol is built around, has its own blacklist. A USDC
transferto or from a blacklisted address reverts, whatever the amount, including zero. The pool pushes funds to two fixed addresses on every flow:- Fee collector.
processRedemptionalways transfersfeesto it, even whenfees == 0.processRedemptionsandclaimtransfer to it whenever the fee is nonzero. If Circle blacklists the fee collector, every push-modeprocessRedemptionreverts, and every claim or batch payout that carries a fee reverts. - Treasury wallet.
depositanddepositFromTransferforward every deposit to it. If it is blacklisted, every deposit reverts.
The only way to recover is
setFeeCollectorAddressorsetTreasuryWalletAddr. 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 aprocessRedemptionsbatch it reverts everything.Scenario:
- Circle blacklists the fee collector, for example because of an unrelated incident at the custodian.
- Every
processRedemptionreverts on its fee transfer, including those withfees = 0, and every claim with a nonzero fee reverts. - The issuer admin schedules
setFeeCollectorAddress(new). Redemptions stay frozen until the timelock delay passes.
Recommendation
Consider the following:
- Skipping the fee transfer when the fee is zero.
- 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.
- Letting the protocol admin replace the fee collector or treasury wallet without the issuer-admin timelock in an emergency.
OpenTrade
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.
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 callremoveParticipantwith 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 byopenClaimsstays 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):
- Lender
L's 2,000,000 USDC request is funded and opened. - The issuer calls
removeParticipant(L). L'sclaimreverts. The funds stay reserved, andLhas no onchain recourse unless the issuer addsLback or the issuer admin cancels the request.
Recommendation
Consider the following:
- 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. - 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.
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
totalSupplyand 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'sassetBalanceOfare off byrequestedShares × (current rate − request rate). In claim mode, where payouts are always exactly the recorded amounts, this difference is systematic. - Max views ignore gates.
maxRedeemRequestused 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 orminRedeemAssets. An integrator can therefore read a positive maximum for a call that will revert.maxWithdrawRequestalso reports the amount before the fee, unlikepreviewRedeemRequest.
Scenarios:
1. A locked request after a markdown (Dynamic pool).
- Lender
Lholds 1,000,000 shares at a rate of1.0.LcallsrequestRedeemfor all of them, which fixes the payout at 1,000,000 USDC. - A −2% markdown lands, and the rate is now
0.98. assetBalanceOf(L)returns 980,000 USDC, andtotalAssets()counts the same 980,000.Lwill still be paid 1,000,000 USDC.- A dashboard or the server reports
L20,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.
- A pool pauser calls
pausePool(). - An integrator calls
maxRedeemRequest(holder)and reads 50,000 shares. - It calls
requestRedeem(50,000), which reverts withPoolPaused. - 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
requestRedeemreverts withNotMarketMaker.
Recommendation
Consider the following:
- Returning 0 from the max views whenever
requestRedeemwould revert for that owner. That covers paused, not Active, not allowed and below the minimum. - Documenting that the asset views value locked shares at the current rate, or subtracting
requestedSharesand addingrequestedAssetsin the per-lender view.
OpenTrade
Commit #e8d7677 and Commit #1a5cd9b.
Cantina
Commit #e8d7677 and Commit #1a5cd9b fix the
maxRedeemRequestcheck gates andmaxWithdrawRequestfee aspects respectively. The asset view share valuation issue is neither fixed nor documented.Lifetime accounting statistics miss adjustments, transfers and fees, and a debit can burn nothing
State
Severity
- Severity: Low
Submitted by
0xRajeev
Description
- Lifetime figures. A credit mints shares but adds nothing to
totalAssetsDepositedor the lender'sassetsDeposited. A debit burns shares but adds nothing tototalAssetsWithdrawnorassetsWithdrawn. 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.
assetsDepositedandassetsWithdrawnstay with the address that deposited or redeemed, not with the shares. A holder who bought on a DEX pair, which permissionless mode makes common, showsassetsDeposited = 0and anassetsWithdrawnlarger than that. Any per-lender profit figure built fromgetLYTPoolAccountStateis wrong for that holder. - Fees are recorded nowhere on chain.
totalAssetsWithdrawncounts what destinations received, net of the fee, whiletotalAssetsDepositedcounts gross deposits. No counter records fees paid, sodeposited − withdrawnoverstates 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 above1e18, a debit worth less than one share burns zero shares but still emitsAccountDebit(lender, debit)for the full amount.
Recommendation
Consider the following:
- Updating the deposited and withdrawn figures on credits and debits, or tracking adjustments in their own counters.
- Adding a
totalFeesCollectedcounter. - Documenting that the per-lender figures follow addresses, not shares.
- Rounding debits up (
convertToSharesRoundUp), and reverting when a nonzero debit converts to zero shares.
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_URLin the newscripts/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:
- Rotating every exposed key first. Removing them from the files does not remove them from the git history.
- Loading RPC URLs from environment variables, as the deploy mnemonic and account keys already are.
- Restricting each new key to known IPs or domains if the provider supports it.
Rate setters don't validate their type-specific parameters allowing the rate to underflow or fall to zero
State
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()computesexchangeRateMaturityDay − 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
convertToSharesanddepositrevert with division by zero, and makes everyrequestRedeemrevert withZeroAmount. 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:- Reverting when a Term pool's maturity day is before today, or its maturity rate is zero
- Reverting when a Compounding factor is outside a reasonable range around
1e18(for example, ±1% a day) - Bounding the Linear daily change the same way.
OpenTrade
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.
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,
removeParticipantis the softer of the two ways to exclude someone. The other is the blacklist.setPermissionless(true)makes_accessAllowedreturn 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:
- Market maker
Mis on the allowlist of a permissioned pool. A compliance review flagsM, and the issuer callsremoveParticipant(M).M's nextdepositandrequestRedeemrevert withNotMarketMaker. - Months later, the protocol admin calls
setPermissionless(true). _accessAllowed(M)now returns true. In the next blockMdeposits 2,000,000 USDC and receives shares.Mcan also request redemption and claim.- No event marks
Mas re-admitted. The issuer finds out only by reading thePoolDepositlogs.
Recommendation
Consider the following:
- Blacklisting every address that must stay excluded before
setPermissionless(true), and documenting this. - Alternatively, keeping a deny list in
LYTPoolAccessControlthat_accessAllowedrespects 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.
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 checktotalSupply(). Closed is terminal._updatecarriesatState(Active), so in a closed pool shares can't be transferred, burned or debited.requestRedeem,processRedemption(s),claimandaddAdjustmentAmountalso requireActive. 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:
- A pool winds down. Every open request is settled, and the protocol admin calls
transitionTo(Closed). - The Uniswap pair still holds 400,000 shares, and the OFT adapter holds 1,000,000 shares that back bridged tokens.
_updatenow requires the pool to be Active, so those shares can't be transferred, redeemed or debited.- The pair's liquidity and the bridged tokens are frozen for good, and LP holders have no onchain exit.
Recommendation
Consider one of these:
- Adding a wind-down state between
ActiveandClosed(for exampleWinding):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,claimand 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
Closedthen becomes a formality that is reached only when the supply is zero, or not used at all
- Requiring
totalSupply() == 0intransitionTo(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. - 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.
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 norblock.chainid.cumulativeAssetsTotalstarts 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
RedeemClaimedorRedeemRepayin one pool can close or relabel the matching request in another, and the server may offer or schedule payment against the wrong request.Scenario:
- Right after the upgrade,
cumulativeAssetsTotalis 0 in both the Avalanche and the Ethereum pool. - Lender
LsendsrequestRedeemto both pools in the same second, for the same shares and the same assets. The two requests get the same uuid. - The Ethereum request is claimed, and
RedeemClaimed(…, uuid, …)is emitted. - An indexer that keys on the uuid alone marks the Avalanche request as closed too. The server stops offering
La claim that is still owed.
Recommendation
Consider the following:
- Adding
address(this)andblock.chainidto the uuid preimage. - Keying every offchain record on the pool, chain and uuid together.
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
TimelockControllerdeployed on Ethereum mainnet (chain ID 1) at0x71E0774373fdD15ea6e3a6aCEBFEeaA0A1b48b73with these constructor arguments:minDelay = 5seconds.- The deployer EOA
0xAd795e380725d85E0c62da50B91126a12043922aas the only proposer and the only executor. - The same EOA as
admin, which gives itDEFAULT_ADMIN_ROLEon 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:
- The timelock at
0x71E0…8b73is set as a pool's issuer-admin override. - The single EOA's key is compromised.
- The attacker schedules
changeRedemptionDestinationfor a large open request, pointing it at an address already on the allowlist. It executes the call 5 seconds later. - No holder or monitor has time to react before the payout is redirected.
Recommendation
Consider the following:
- Checking on chain whether
0x71E0…8b73is set as any pool's override. - 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.
- Using a multisig as proposer, a separate executor or the open executor, and
admin = address(0). - Making the deploy scripts require
minDelayexplicitly 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.
Permissionless mode allows debits and deposit-manager mints reach the DEX pairs, the Pendle wrapper and the OFT adapter
State
Severity
- Severity: Low
Submitted by
0xRajeev
Description
addAdjustmentAmount,depositOffChainanddepositFromTransferdecide 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_accessAllowedtrue 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 nextsync.
Threat model §11 lists this as a pattern to report: "
addAdjustmentAmountdebit or credit reaching an AMM pair, the Pendle wrapper or the OFT adapter once_accessAllowedis true for all."Scenarios:
1. A debit on the bridge adapter.
- The Avalanche pool is permissionless. The OFT adapter holds 3,000,000 shares that back 3,000,000 bridged tokens on Ethereum.
- The issuer admin's debit names the adapter, for example by mistake when correcting another holder.
- 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.
- The pool is permissionless. The deposit manager records a 500,000 USDC client deposit and enters the Uniswap pair's address as the lender.
- Shares worth 500,000 USDC are minted to the pair.
- Anyone calls
skimon the pair and receives those shares before a corrective debit can be scheduled.
Recommendation
Consider the following:
- Keeping the allowlist check on adjustment debits and credits, and on the lender in
depositOffChainanddepositFromTransfer, even while the pool is permissionless. - Alternatively, having these functions refuse a short denylist of integrator addresses (the pairs, the wrapper and the adapter) whatever the permissionless flag says.
OpenTrade
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.
Preview functions skip most of the checks of requestRedeem allowing them to quote redemptions that will revert
Severity
- Severity: Low
Submitted by
0xRajeev
Description
previewRedeemRequestchecks only thatsharesis nonzero, that withdrawals are Active, thatsharesis no more than the caller's free shares, and the per-lender request cap.requestRedeemalso 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) assetsis nonzero. The preview returns 0 instead of reverting- the open-request cap (
maxOpenRequests) - the minimum request size (
minRedeemAssets)
previewWithdrawRequestcallspreviewRedeemRequest, so it has the same gaps. The preview therefore returns a positive quote in many cases where the matchingrequestRedeemreverts.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):
- The pool has
minRedeemAssets = 1,000 USDCandmaxOpenRequests = 500, and the queue is full. - A holder who bought shares worth 600 USDC on a DEX pair calls
previewRedeemRequest(shares). It returns 597 USDC (after a 50 bps fee). - The front end shows the quote and the holder submits
requestRedeem(shares). It reverts withTooManyOpenRequests. - 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) wheneverrequestRedeemwould revert. That covers the pause, the lifecycle state, the allowlist, the blacklist, a zero value, the open-request cap and the minimum size.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. MeanwhileexchangeRate()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
resumePoolorsetPaused(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).
- Automation mistakenly posts
setExchangeRateDynamic(1.10e18)instead of1.01e18. - To stop anyone using the wrong rate, the pauser calls
pausePoolin the next block. - Automation tries to post the correct
1.01e18. It reverts withPaused, so the wrong rate stays in force. - The protocol admin calls
resumePool. In the blocks before automation's correction lands, holders callrequestRedeemat 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).
- Automation posts on day 0. The pauser calls
pausePoolon day 1 during an incident. - Automation's daily posts revert with
Paused. The pool is resumed on day 10. - More than 7 days have passed since the last post, so
exchangeRate()reverts withExchangeRateInvalid. Everydeposit,requestRedeem, preview and overview call reverts until automation posts again. - 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).
- On day 1, the issuer admin schedules
setExchangeRateLinear(1.0500e18, step, rate). On day 2 the pauser callspausePool. - On day 3 the timelock delay ends, but the call reverts with
Pausedand stays ready in the timelock. - On day 6 the protocol admin calls
resumePool. The executor runs the queued call, which resets the base to its day-1 value withexchangeRateSetDay = 6, so five days of accrual are lost. - Anyone who saw the queued call in
CallScheduleddeposits just after it executes and requests redemption once automation restores the rate.
Recommendation
Consider the following:
- 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).
- Alternatively, letting the protocol admin post a rate as part of resuming, for example with
- Preferring
pauseDepositsandpauseWithdrawsover a full pause for a rate incident. The rate setters still work then, and transfers and claims carry on.
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 withallowParticipant. 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:
- Market maker
Mis on the allowlist from earlier business with the issuer. - The issuer admin key is compromised.
Mcolludes, orM's key is also compromised. - The issuer admin calls
changeRedemptionDestination(lender, uuid, M)on an open 2,000,000 USDC request. - In claim mode, once the request is funded, the issuer admin calls
claimon the lender's behalf. The 2,000,000 USDC goes toM. The issuer took no action beyond its routine funding. - In push mode the issuer's routine
processRedemptionsbatch paysMthe same way, unless someone notices theRedeemDestinationChangedevent first.
Recommendation
Consider the following:
- Keeping a separate list of approved payout destinations, letting only the issuer write to it, and checking it in
changeRedemptionDestinationand_reqPayoutDestinationin place of the participant allowlist. - 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
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 liquidityopenClaimshas reserved for opened claims. Everything else is one undifferentiatedfreeAssets()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
unclaimedAssetsis 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.
depositFromTransfercan credit treasury claim funding to a client, after whichopenClaimsreverts withClaimsNotFunded. In the other direction,openClaimscan count a client's transfer as claim funding, after which that client'sdepositFromTransferreverts withClaimedAssetsReserved. - Push mode.
processRedemptionandprocessRedemptionspay out of the whole balance. They can spend a client's direct transfer beforedepositFromTransferrecords it. That deposit then reverts, or is covered by other funds, such as repayment funding meant for other lenders. - Every mode.
transferToTreasuryWalletcan sweep any of it.depositFromTransferchecks 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:
openClaimschecks 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.
- The treasury sends 1,000,000 USDC to fund claims.
- Before
openClaimsruns, the deposit manager callsdepositFromTransfer(1,000,000e6, client, txHash)for a client transfer that hasn't arrived yet. The call passes thefreeAssets()check and forwards the 1,000,000 USDC to the treasury. openClaimsreverts withClaimsNotFunded, and the treasury has to send the funding again.
2. Push mode.
- A client transfers 500,000 USDC straight to the pool, and
depositFromTransferis scheduled through the deposit-manager timelock. - During the delay, the issuer calls
processRedemptionsfor 500,000 USDC of requests. The payouts come out of the client's transfer. - The deposit call reverts with
ClaimedAssetsReserved, or succeeds only once the treasury tops up the pool.
Recommendation
Consider the following:
- 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 aclaimFundingcounter.openClaimsmoves amounts fromclaimFundingintounclaimedAssets. In push mode,processRedemption(s)draws on the same counter (or on a separaterepaymentFundingone). - Transfer deposits. Recording each client transfer as it is recognised (for example, a
pendingTransfers[txHash]amount, or apendingTransferTotal). OnlydepositFromTransfermay consume it, for the amount recorded. - Everything else. Limiting
transferToTreasuryWalletand any other outflow tobalance − unclaimedAssets − claimFunding − pendingTransferTotal.
- Claim funding. Adding
- At minimum, documenting in the runbook the order of funding, opening and recording deposits.
OpenTrade
Cantina
Commit # documents these aspects in the Runbook.
The per-lender request limit allows 101 open requests, not 100
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
Redeem functions check
outstandingRequests > 100before incrementing the count, so the real limit is 101.Scenario:
- A lender has 100 open requests.
- The check
100 > 100passes, so they open a 101st request. - Their 102nd request reverts with
InvalidRedemptions.
Recommendation
Consider the following:
- Using
>= 100, or naming the bound as a constant (for example,MAX_OUTSTANDING_REQUESTS_PER_LENDER) and stating whether it is inclusive. - Applying the same change at both call sites.
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 includeclearIssuerAdmin, which is added in this change. Two of them redirect money:setTreasuryWalletAddrchanges where every deposit goes, andsetFeeCollectorAddresschanges 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.Access-control Natspec disagrees with the code
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
Three modifiers are documented differently from what they check:
onlyAuthorizedis documented as "operators only, allows only addresses with the OPERATOR_ROLE". It also acceptspoolAdminby address.onlyIssuerAdminis documented as "the timelocked issuer admin". When no valid override is set, it accepts the issuer, which has no timelock.LYTPoolAccessControl.onlyIssueris 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.
The redemption-fee ceiling can be set to 100%
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
setMaxRedemptionFeeBpsaccepts any value up toBPS_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.previewWithdrawRequestalready treats a 100% fee as invalid (it reverts withZeroAmount), butrequestRedeemaccepts 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:
- Capping the ceiling well below 100% with a constant (for example, 500 bps).
- Rejecting a fee that would leave the lender with nothing.
Keeping push and claim modes' accounting apart relies on assumptions the code never checks
State
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
Push mode and claim mode share
unclaimedAssets,claimCutoffBlockand the cumulative totals. They stay separate because of three facts that hold today but that the code never checks:-
The reserve is empty on switching to push mode.
setClaimBasedRepayment(false)checks that no requests are open, and relies on that to meanunclaimedAssets == 0. It never checks the reserve itself. -
Nothing is claimable in push mode.
processRedemptionsandcancelRedemptionreduceunclaimedAssetswheneverisClaimable(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. -
The cutoff resets only when nothing is open.
LYTPool.setClaimCutoffToCurrentmoves 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:
- A later upgrade adds an admin function that calls
setClaimCutoffToCurrentto "reset" a stuck queue. It does not check the open set first. - It runs while 3,000,000 USDC of requests are open above the cutoff, with nothing reserved for them.
- Those requests now read as claimable. Their lenders claim against
unclaimedAssetsthat was reserved for other lenders, or the claims underflow and revert. - Nothing in the current code stops this, because the safety lives only in the one existing caller.
Recommendation
Consider the following:
- Making
setClaimBasedRepayment(false)revert unlesspool.unclaimedAssets() == 0. - Checking the mode first in
processRedemptionsandcancelRedemption, and touchingunclaimedAssetsonly in claim mode. - Making
setClaimCutoffToCurrentrevert unlessactiveWithdrawCount() == 0.
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
LYTPooldoes not inheritIERC4626, and nothing in the repository claims it complies. But the code groups its conversion functions under an "ERC-4626 Methods" heading, ships an unusedIERC4626.sol, andILYTPooldeclares ERC-4626'sDepositandWithdrawevents. Compared with the standard:ERC-4626 requirement LYTPoolasset,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 PoolInvalidStateunlessreceiver == msg.senderdepositemitsDeposit(sender, owner, assets, shares)Emits PoolDeposit. TheDepositandWithdrawevents declared inILYTPoolare never emittedtotalAssetsis 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 managesconvertToSharesandconvertToAssetsmust not revert except on overflowThey revert whenever exchangeRate()does: a Compounding rate more than 7 days old, or a Term maturity day in the pastThe asynchronous redemption flow is closer to ERC-7540, but it doesn't implement that either.
requestRedeem(shares)takes nocontrollerorowner, 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
totalAssetsthat is not a real balance - indexers that listen for the standard
Depositevent would see no deposits.
Scenario:
- An integrator sees the "ERC-4626 Methods" heading and the
Depositevent inILYTPool, and plugs the pool into a generic ERC-4626 adapter. - The adapter calls
maxDepositandpreviewRedeem, which don't exist, and its calls revert. - An analytics indexer listening for the standard
Depositevent records no deposits at all, because the pool emitsPoolDepositinstead.
Recommendation
Consider the following:
- Removing the "ERC-4626" label, the unused
IERC4626.sol, and theDepositandWithdrawevents that are never emitted, or emitting them. - Documenting that
LYTPoolis a custom vault with asynchronous redemptions and an administrator-set rate. - Confirming that the Pendle wrapper and any other integrator call the pool's own functions rather than a generic ERC-4626 adapter.
- Implementing the full interface if ERC-4626 or ERC-7540 compatibility is wanted, including
max*andpreview*views that return 0 instead of reverting when the action isn't allowed.
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
SafeERC20for every transfer and never callsapprove. 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 behaviour Effect on the pool Fee on transfer (USDT) depositforwards more than it received, and takes the difference from the claim reserve.depositFromTransfercredits the full amount for a short transfer. Payouts arrive shortBalance destroyed or reduced (USDT, rebasing tokens) The balance falls below unclaimedAssets. The last claimants revertPause 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.
- Tether sets a 10 bps fee, capped at 49 USDT per transfer.
- 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.
- After enough deposits, the balance falls below
unclaimedAssets, and the last funded claims revert.
2. Tether destroys the pool's balance.
- The pool holds 3,000,000 USDT reserved for claims.
- Tether blacklists the pool and calls
destroyBlackFunds. - Every claim reverts until the pool is unblacklisted and the treasury funds the reserve again.
Recommendation
Consider the following:
- Documenting which assets may be approved with
setLiquidityAsset, and recording USDT's fee switch and fund destruction as accepted risks. - Pulling deposits straight to the treasury, so a transfer fee never touches the reserve.
- Crediting what actually arrived in
depositFromTransfer. - Monitoring USDT's
basisPointsRateandmaximumFee, and callingpauseDepositsif either becomes nonzero.
OpenTrade
We do thorough testing before any new liquidity asset is added, Not a concern
Cantina
Acknowledged.
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 onlyassets == 0. None of them rejectsshares == 0. Wheneverassets × 1e18 < rate, the call still:- adds the full
assetstototalAssetsDeposited,assetsDepositedand the daily mint count - for
deposit, pullsassetsfrom the lender and forwards them to the treasury - mints zero shares and emits
PoolDepositfor 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 (
requestRedeemExecuterejectsassets == 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 belowrate / 1e18units is taken in full and issues nothing.Scenario:
- Automation posts a Dynamic rate of
1e30instead of1e18, by mistake. - A lender deposits 500,000 USDC.
500,000e6 × 1e18 / 1e30rounds down to 0 shares. - The pool takes the 500,000 USDC and forwards it to the treasury. It mints no shares and emits
PoolDepositfor the full amount. - The lender holds nothing to redeem. Recovering the funds depends on the issuer acting off chain.
Recommendation
Consider reverting when
shares == 0in every mint path, mirroring theassets == 0check on requests.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.
Role How it is assigned Capabilities Abuse Bound in the code DEFAULT_ADMIN_ROLE(service configuration)The deployer of the service configuration Grants and revokes every global role Gives itself or an accomplice any role below. Revokes the honest key-holders None DEPLOYER_ROLEGranted by the default admin setImplementationon 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 success None. No timelock, and the change takes effect at once PROTOCOL_ADMIN, or a pool'spoolAdminGlobal 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 maxOpenRequeststo 1Mode switches and closing need an empty queue. Some role setters require the right role OPERATOR_ROLEGranted by the default admin Blacklists 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 incident None PAUSER_ROLEand pool pausersGranted by the default admin. Pool pausers are also listed per pool by the protocol admin Protocol-wide setPaused.pausePoolandresumePoolon its poolsFreezes every transfer, claim and DEX or bridge flow indefinitely. Lifts a pause set during an incident The pool pauser list and a role check on every call Issuer _settings.issuer. The role is checked only when it is setPush-mode processRedemptionwith amounts it chooses.openClaims.processRedemptions.transferToTreasuryWallet. The allowlist (allowParticipantandremoveParticipant)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 set freeAssetslimits sweeps. Push payouts are blocked in claim modeIssuer admin Override 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 minRedeemAssetsso 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 manager Override 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 paused dailyMintLimit, which counts assets rather than shares and is off by default. A timelock, if one is configuredAutomation AUTOMATION_ROLEAll rate setters, and the indicative and collateral rates Posts 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 it Rates must be nonzero. Compounding rates expire after 7 days Treasury wallet _settings.treasuryWalletAddr, which needsFIAT_GATEWAYwhen it is setReceives every deposit and every sweep Keeps deposits and never funds redemptions. Being USDC-blacklisted stops every deposit None. Offchain custody Fee collector _settings.feeCollectorAddress, which needsFEE_COLLECTORwhen it is setReceives every redemption fee Being USDC-blacklisted stops settlement None Timelock proposer, executor and admin Recorded mainnet timelock: one EOA in every role, with a 5-second delay Schedules and runs every action of the issuer admin or deposit manager it serves Turns timelocked roles into instant single-key roles minDelay, which is 5 seconds as deployedUsers 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:
- The
DEPLOYER_ROLEkey is compromised. - The attacker calls
setImplementationon the pool beacon with code that sends each pool's USDC to them. The change takes effect at once, with no timelock. - 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_ADMINand 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.
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
isLiquidityAssetis checked in one place only:LYTPoolFactory.createPool, when a pool is created. After that, the pool keeps the asset in_liquidityAssetand never consults the approved list again. CallingsetLiquidityAsset(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
pauseDepositson each pool does.Scenario:
- A pool is created with stablecoin
SwhileSis approved. Sloses its peg. The operator callssetLiquidityAsset(S, false).- Lenders keep calling
depositwithSand receiving shares at the administrator-set rate, which assumesSis worth its face value. Nothing stops this until each pool's deposits are paused.
Recommendation
Consider the following:
- Checking
isLiquidityAsset(_liquidityAsset)in the deposit paths (deposit,depositOffChain,depositFromTransfer), or havingsetLiquidityAsset(asset, false)pause deposits on every pool using the asset. - 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.
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
cancelRedemptionas the way out, but it doesn't consider what cancelling returns to that holder.A better way out already exists.
claimcan 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.cancelRedemptionis 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):
- 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.
- The rate rises to 1.05. The lender doesn't claim.
- Months later, the protocol admin wants to switch modes. The issuer admin clears the open requests with
cancelRedemption. - 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:
- Settling funded requests that are never claimed with
claimon the lender's behalf, notcancelRedemption, and putting this in the runbook. - Restricting
cancelRedemptionon a funded request to cases whereclaimwould revert (the lender or destination is blacklisted or not allowed), so a payable request can't be cancelled.
OpenTrade
Cantina
Commit #06730a1 documents (1) in the Runbook.
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.
Area Issue Where Naming: internal functions Internal functions have no leading underscore, unlike the new _req*,_feeOnand_recordMinthelpersLYTPool.requestRedeemExecute,LYTPool.convertToSharesRoundUp,LYTPoolFactory.initializePoolNaming: state variables A private variable has no underscore, and a public one has one LYTPool.activeWithdrawKeys(private),LYTPoolRegistry._pools(public)Naming: types Structs and enums carry the Iprefix that by convention marks interfacesILYTPoolWithdraw,ILYTPoolWithdrawExtra,ILYTPoolFlags,ILYTPoolAccounting,ILYTPoolLifeCycleStateand othersMisleading errors One error is reused for unrelated failures, so a revert doesn't tell monitors or integrators what went wrong NotMarketMakerfor a caller not on the allowlist, a destination not on the allowlist, a lender mismatch and a caller with no shares.PoolInvalidStateformsg.sender != lenderindeposit.InvalidAccessfor zero amounts, bad lender addresses and a failed set updateUnused declarations Errors that are declared but never used, and an unused library LYTPool:RedemptionDoesNotCoverDeposits,PoolNotActive,InvalidLoan,CrossChainError,NoAdjustmentsToZeroBalance.LYTPoolController:NotProtocolAdminOrIssuer,PoolSettingsInvalid.LYTPoolServiceConfiguration:UpgradeRevert.LYTPoolimportsSafeCastwithusing SafeCast for uint256but never calls itMixed error styles requirewith revert strings sits beside custom errorsBlacklistable,LYTPoolAccessControl,LYTPoolServiceConfiguration, the three factories,BeaconProxyFactory,DeployerUUPSUpgradeableBoolean comparisons Comparisons with boolean literals _serviceConfiguration.paused() == falsein all three factoriesMagic numbers Literals with no named constant The per-lender cap 100.1e18repeated as the rate scale.1 daysfor the day calculation.BPS_DENOMINATORshows the intended patternDuplicated logic The share calculation is written out by hand in three deposit paths instead of calling convertToSharesdeposit,depositOffChain,depositFromTransferVisibility publicwhereexternalis enough, because the function is never called internallyLYTPool:deposit,depositOffChain,depositFromTransfer,requestRedeem,addAdjustmentAmount,setExchangeRate,setIndicativeAndCollateralRates,transferToTreasuryWallet.LYTPoolController: the four rate settersRedundant parameter deposit(assets, lender)takes alenderthat must equalmsg.senderLYTPool.depositScenario:
- A monitor alerts on
requestRedeemreverts withNotMarketMaker. - 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.
- 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:
- Prefixing internal and private functions and state variables with
_, and dropping theIprefix from structs and enums. - Giving each failure its own custom error, removing the unused errors and the
SafeCastimport, and replacing the remaining revert strings with custom errors. - Introducing named constants for
100,1e18and the day length, and reusingconvertToSharesin the deposit paths. - Marking functions
externalwherever 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.
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:
isClaimablereports funding, not whetherclaimwill succeed. It returns true when a request is open and below the cutoff.claimcan 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 callisClaimablebefore offering a claim. Lenders are then offered claims that revert.- The daily mint counters keep yesterday's total until someone mints.
_recordMintresetsmintTotalInDayonly when the first mint of a new UTC day happens. Until then, the publicmintTotalInDaystill holds the previous day's total, andmintCurrentDaythe previous day. A reader that works out today's remaining capacity asdailyMintLimit − mintTotalInDaygets 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:
- Yesterday the pool minted its full
dailyMintLimitof 5,000,000 USDC. Today nothing has been minted yet. - A front end reads
mintTotalInDay = 5,000,000and shows that the limit has been reached, so it turns away a deposit that would succeed. - Separately, the server reads
isClaimable(uuid) = truefor a lender who has since been removed from the allowlist. It offers the claim, and the lender'sclaimreverts withNotMarketMaker.
Recommendation
Consider the following:
- Adding a view such as
mintedToday(), which returns 0 whenblock.timestamp / 1 days > mintCurrentDay, and pointing readers to it. - Either making
isClaimablealso check whatclaimchecks (pause, lifecycle state, blacklist, allowlist and destination), or adding acanClaim(uuid)view that does, and documenting thatisClaimablereports funding only.
OpenTrade
Not needeed at this point, consider for the future
Cantina
Acknowledged.
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.createPoolnow only writes to it. SoisAPool,isPoolRegisteredandpools()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.- The operator can list any address as a pool.
addPoolaccepts calls from the current factory or from anyOPERATOR_ROLEholder. 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, sincecreatePoolalready registers each new pool. - Removing a pool only edits the list. A removed pool keeps working. Its deposits, withdrawals, claims and roles are unaffected. Integrators who check
isPoolRegisteredmay assume a delisted pool is stopped. Stopping it takes a separate pause through its controller.
Scenario:
- A compromised operator calls
LYTPoolRegistry.addPool(fakePool), wherefakePoolis a contract that copies the real pool's interface and keeps deposits. - A front end that lists pools from
isPoolRegisteredshowsfakePoolbeside the real ones. - Users deposit into it. Their USDC goes to the attacker, and no real shares are minted.
Recommendation
Consider the following:
- 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. - 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.
The issuer cannot remove an address from the allowlist during a protocol-wide pause
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
allowParticipantandremoveParticipantboth carryonlyNotPaused. 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_ROLEkey, 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 callresumePool. Neither is documented.Scenario:
- Allowlisted market maker
Xis involved in an incident. The pauser callssetPaused(true). - The issuer calls
removeParticipant(X). It reverts. - The pauser calls
setPaused(false). The transaction is visible in the mempool. Xdeposits or requests redemption before the issuer'sremoveParticipant(X)lands.
Recommendation
Consider the following:
- Removing
onlyNotPausedfromremoveParticipant, while keeping it onallowParticipant, since widening the list is the risky direction. - Having
allowParticipantalso check the pool's own pause, through the controller'spaused(), so that a pool pause blocks it too. - 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.