Organization
- @sprintertech
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Medium Risk
8 findings
8 fixed
0 acknowledged
Low Risk
7 findings
7 fixed
0 acknowledged
Informational
8 findings
4 fixed
4 acknowledged
High Risk1 finding
Gnosis Omnibridge rebalances bypass destination pool accounting
Severity
- Severity: High
Submitted by
slowfi
Description
For CCTP and CCTP v2 rebalances, the destination side
processRebalancepath measures the received amount and callsILiquidityPoolBase(destinationPool).deposit(depositAmount), which updates the destination pool accounting. The Gnosis Omnibridge initiation path is different:initiateRebalancewithdraws from the source pool and callsinitiateTransferGnosisOmnibridge, which relays tokens directly todestinationPool.For Ethereum to Gnosis Omnibridge transfers, there is no corresponding destination-side
processRebalancecall that can executedeposit()on the Gnosis pool. The bridge delivers tokens directly to the destination address, buttotalDeposited/pool accounting is never increased. The result is raw token balance at the pool without matching deposit accounting. Depending on pool type, that balance can be ignored for LP withdrawals, treated as surplus/profit, or left unavailable to the intended accounting path.A rebalancer can move liquidity out of the source pool while the destination pool does not account it as deposited liquidity. This can strand or misclassify rebalanced funds and can cause solvency/accounting mismatches between chains.
Recommendation
Consider avoiding to send Omnibridge rebalances directly to a liquidity pool unless the destination bridge path can atomically call
deposit(). Bridge to a destination-side Rebalancer or receiver contract, validate the received token, and have that contract callILiquidityPoolBase.deposit(depositAmount). Alternatively, disable Ethereum to Gnosis Omnibridge rebalancing routes until a destination accounting step exists.
Medium Risk8 findings
StashDex direct repayments can ignore accrued Aave interest
Severity
- Severity: Medium
Submitted by
slowfi
Description
StashDex tracks the nominal
amountOutborrowed from its configured pool astotalBorrowed. Its repayment path caps repayment to that nominal value, and the Aave-backed pool'srepayDirect()path caps direct repayment todirectDebt[borrowToken].For Aave-backed pools, variable debt accrues interest above the nominal direct-borrow principal. After StashDex repays only the tracked principal, both StashDex
totalBorrowedand pooldirectDebtcan become zero while the Aave position still has residual variable debt from accrued interest.Once StashDex's nominal debt is zero, its normal guards treat the token as fully repaid.
forward()can send remaining balances away, and_setPool()can allow the configured pool to be replaced, even though the old Aave position still carries interest generated by the StashDex borrow.The issue is specific to integrations that treat nominal direct-borrow principal as the complete repayment obligation while the backing pool accrues external debt interest.
Interest generated by StashDex direct borrows can be left on the liquidity pool/Aave position while StashDex considers its debt repaid. This reduces pool yield and can worsen the Aave health factor over time. It can also let StashDex route future repayments or borrows to a new pool while the old Aave pool still carries residual debt from the prior direct-borrow flow.
Recommendation
Do not let StashDex completion guards depend only on nominal principal when the backing pool can accrue Aave debt above that principal. StashDex repayments should route through a flow that uses all available repayment funds to close the relevant Aave debt, or the integration should explicitly require residual funds to be sent to the pool/Repayer until the Aave debt produced by the borrow is settled.
If future direct borrow integrations use Aave-backed pools, document that nominal
directDebtdoes not include all accrued Aave interest and that integrations need a separate repayment or reconciliation step for residual variable debt.Gnosis Omnibridge repayments can deliver the wrong USDC variant
Severity
- Severity: Medium
Submitted by
slowfi
Description
Gnosis Omnibridge does not let the initiator explicitly choose the output token in the same way as the other bridge adapters, so generic input/output-token validation is not available for this provider. The accepted issue is narrower: the route must account for the USDC representation that Omnibridge actually delivers on Gnosis.
The adapter already recognizes one side of the representation mismatch. On Gnosis -> Ethereum transfers, if the local token is
GNOSIS_USDCE, it first converts toGNOSIS_USDCXDAIbecause Omnibridge uses the older USDCxDAI representation. The reverse Ethereum -> Gnosis path did not perform the corresponding destination-side conversion before funds reached a pool that may account for USDCe.As a result, a repayment can deliver the Omnibridge USDC representation while the intended destination pool's accounting/debt expects the configured Gnosis asset. The transfer succeeds at the bridge-token level, but it does not necessarily settle the intended pool asset.
Cross chain repayments can arrive as the wrong Gnosis USDC representation. Funds may sit as an unsupported or non-accounted token while the original pool debt remains outstanding until the asset is converted or the route is corrected.
Recommendation
Model the USDCe/USDCxDAI conversion in both directions. If the destination pool expects USDCe, bridge to a destination-side receiver/Rebalancer that converts the Omnibridge representation before delivering or accounting the repayment. For routes where that normalization is not available, disallow the route for pools that only support the configured asset.
Aave withdrawProfit shortfall borrow skips token LTV checks
Severity
- Severity: Medium
Submitted by
slowfi
Description
Normal Aave-backed borrow paths enforce both the global health-factor check and the protocol's configured per-token LTV cap.
_afterBorrowLogic()calls_checkHealthFactor()and then_checkTokenLTV(totalCollateralBase, borrowToken)._afterBorrowManyLogic()does the same for each borrowed token._withdrawProfitLogic()contains another path that can create Aave variable debt. If the current token balance is lower than the profit amount to withdraw, it borrows the shortfall from Aave and then checks only_checkHealthFactor():if (int256(balance) < profit) { uint256 shortfall = uint256(profit) - balance; AAVE_POOL.borrow(address(token), shortfall, INTEREST_RATE_MODE_VARIABLE, NO_REFERRAL, address(this)); _checkHealthFactor(); currentDebt += shortfall;}This skips the per-token LTV cap that every ordinary borrow path enforces. The skipped check matters because token-specific LTV limits can be much stricter than the global health-factor constraint. In particular,
profitcan include surplus created fromdirectDebt, so the shortfall can be material and not just dust.WITHDRAW_PROFIT_ROLEcan cause the pool to borrow a token above its configured token LTV while still passing the global health-factor check. This weakens the configured risk controls and can concentrate variable debt in tokens that the admin intentionally capped at a lower exposure.Recommendation
After the shortfall borrow, enforce the same checks used by the ordinary borrow paths:
uint256 totalCollateralBase = _checkHealthFactor();_checkTokenLTV(totalCollateralBase, address(token));If profit-withdrawal borrows are intentionally exempt from token LTV limits, document that trust assumption explicitly and make the configuration reflect it.
Aave repayDirect reverts when direct debt exists but Aave debt is zero
Severity
- Severity: Medium
Submitted by
slowfi
Description
LiquidityPoolAave._repayTokenDirect()calculates the repayment amount from the pool'sdirectDebtmapping, decreases that mapping, pulls tokens from the caller, and unconditionally forwards the same amount into Aave through_executeRepay():uint256 outstandingDebt = $.directDebt[borrowToken];uint256 repayAmount = Math.min(outstandingDebt, maxRepayAmount);if (repayAmount == 0) return false; unchecked { $.directDebt[borrowToken] = outstandingDebt - repayAmount; }IERC20(borrowToken).safeTransferFrom(_msgSender(), address(this), repayAmount);_executeRepay(borrowToken, repayAmount);_executeRepay()then callsAAVE_POOL.repay(borrowToken, repayAmount, 2, address(this)). This call can revert when the pool has no Aave variable debt for that token, even ifdirectDebt[borrowToken] > 0.The state is reachable because direct debt and Aave debt are not always the same thing. A direct borrow can be served from local balance in an Aave long-term pool without creating matching Aave debt, or ordinary
repay()can close Aave variable debt while leaving direct debt unchanged. In either case, the direct borrower is expected to userepayDirect()to cleardirectDebt, butrepayDirect()can fail because there is no Aave debt left to repay.This contradicts the intended recovery model where
repayDirect()should still be able to reduce direct debt even if Aave debt was already closed.Direct debt can become impossible to settle through the normal direct-repayment path. Integrations such as StashDex can be stuck with outstanding nominal debt, unable to call
forward()or change pools cleanly, while the pool keeps stale direct debt accounting.Recommendation
Cap the amount sent to Aave by the current variable debt balance and skip the Aave call when that balance is zero.
repayDirect()should still reducedirectDebtand pull repayment tokens even if there is no remaining Aave variable debt:uint256 aaveDebt = HelperLib.balanceOfThis(vdToken);uint256 aaveRepayAmount = Math.min(repayAmount, aaveDebt);if (aaveRepayAmount > 0) { _executeRepay(borrowToken, aaveRepayAmount);}Gnosis AMB executeSignatures can skip rebalance accounting
Severity
- Severity: Medium
Submitted by
slowfi
Description
For Gnosis -> Ethereum Omnibridge transfers,
processTransferGnosisOmnibridge()finalizes the bridge message by callingETHEREUM_AMB.executeSignatures(message, signatures), measures the destination pool balance delta, and returns the received amount toRebalancer.processRebalance(). The Rebalancer then callsILiquidityPoolBase(destinationPool).deposit(depositAmount)to credit the destination pool accounting.The Gnosis AMB
executeSignaturesentry point is permissionless. Anyone who obtains the bridge message and validator signatures can execute the message directly on the AMB before the protocol'sprocessRebalance()transaction. Once executed, the message is marked processed and a later call toexecuteSignaturesfor the same message reverts.If an external caller finalizes the AMB message first, the tokens are delivered to
destinationPool, but the protocol's laterprocessRebalance()reverts insideexecuteSignatures()before it reachesdeposit(). The destination pool receives raw token balance without the matchingtotalDepositedaccounting update.This is related to, but separate from, direct Ethereum -> Gnosis Omnibridge rebalances that have no destination-side process step at all. Here the intended destination-side process step exists, but it can be front-run because the bridge finalization itself is not restricted to the Rebalancer.
Anyone can grief Gnosis -> Ethereum Omnibridge rebalances so that bridged principal reaches the pool but is never credited as deposited liquidity. The uncredited balance can later be ignored by withdrawal accounting or misclassified as surplus/profit, depending on the pool type and follow-up operations.
Recommendation
Consider do not requiring the
Rebalancerto be the first executor of the AMB message. Make Gnosis Omnibridge processing idempotent: if the message was already executed, the Rebalancer should still be able to verify the expected transfer and calldeposit()for the delivered amount. Alternatively, bridge to a destination-side receiver that records pending transfers and exposes a separate accounting-credit step that cannot be invalidated by permissionless AMB finalization.Attacker can force the operator requirement on a user's withdrawals via allowances
Severity
- Severity: Medium
Submitted by
Kankodu
Description
User A goes to the SHARES contract and approves User B for some tokens, so that User B can withdraw on behalf of User A. This is a perfectly valid use case, and it shouldn't require User A to set User B as an operator by calling
setOperatoronLiquidityHub.However, an attacker can force User A into that requirement by calling
requestRedeemwithshares = 1 wei,controller = User A,owner = attacker. The attacker requests a redeem for 1 wei of their own shares withcontroller = User A. This means that when User B wants to withdraw on behalf of User A, User B must now also have been set as an operator by User A (because thefromPending > 0branch is entered). Tx fails otherwise.The root cause is in
LiquidityHub._withdraw.requestRedeemlets anyone creditredeemRequests[controller]for an arbitrarycontroller, and_withdrawunconditionally routes any available pending amount through a branch that requires operator authorization.Since the injected 1 wei is never automatically cleared, the requirement persists: every allowance-based withdrawal on behalf of User A reverts with
Unauthorizeduntil User A additionally sets the spender as an operator (or the pending amount is redeemed away by an authorized party). The attack costs the attacker 1 wei of their own shares per targeted user and grants them nothing, but it degrades the SHARES-approval withdrawal flow for arbitrary victims.Proof of Concept
Add following test was added in
test/LiquidityHub.ts:it.only("Should allow an attacker to force operator requirement on withdraw from another user", async function () { const { lpToken, liquidityHub, usdc, deployer, user, user2, user3, USDC, LP, } = await loadFixture(deployAll); // User A (user) approves User B (user3) on the SHARES contract so User B can withdraw on their behalf. await usdc.connect(deployer).transfer(user, 10n * USDC); await usdc.connect(user).approve(liquidityHub, 10n * USDC); await liquidityHub.connect(user).deposit(10n * USDC, user); await lpToken.connect(user).approve(user3, 10n * LP); // Attacker requests a redeem for 1 wei of their own shares with controller = User A. await usdc.connect(deployer).transfer(user2, 1n); await usdc.connect(user2).approve(liquidityHub, 1n); await liquidityHub.connect(user2).deposit(1n, user2); await liquidityHub.connect(user2).requestRedeem(1n, user, user2); // User B can no longer withdraw on behalf of User A without being set as an operator by User A. await expect(liquidityHub.connect(user3).withdraw(1n * USDC, user2, user)) .to.be.revertedWithCustomError(liquidityHub, "Unauthorized");});Recommendation
In
_withdraw, only consume from the pending redeem request when the caller is the owner or an operator. When the caller is instead an approved spender (allowance on the SHARES contract), withdraw entirely from the owner's live share balance and leave the pending amount untouched, so an attacker-injected pending request cannot force the operator requirement.claimableRedeemRequest over reports causing fulfilRedeem to revert
Severity
- Severity: Medium
Submitted by
Kankodu
Description
When calculating how much assets are redeemable, the hub calls
LIQUIDITY_POOL.balance(IERC20(asset()))which simply returns the current token balance.However, when actual withdraw is called on
LiquidityPool, it only lets you withdraw no more than the internal storage$.totalDeposited.In case of
LiquidityPoolAave,LIQUIDITY_POOL.balance(IERC20(asset()))doesn't simply return the current balance but it still has a similar mismatch in some cases.This means that
claimableRedeemRequestsometimes over-reports.maxRedeemandmaxWithdrawhave the same bug as well.claimableRedeemRequestover-reporting has the effect offulfilRedeemfailing for every receiver.Concretely, whenever the pool holds funds above
totalDeposited(e.g. yield/profit that is physically in the pool but only withdrawable viawithdrawProfit, notwithdraw),claimableRedeemRequestcounts those funds as claimable. When such a claim is fulfilled,LiquidityHub._withdrawcallsLIQUIDITY_POOL.withdraw(receiver, assets)withassets > totalDeposited, which reverts withInsufficientLiquidity. BecausefulfilRedeemprocesses receivers in a single transaction using these over-reported amounts, the whole call reverts and every receiver fails.POC
Added below test in
test/LiquidityHub.ts.// Uses the real LiquidityPool (not the TestLiquidityPool mock) because the mock lets withdraw// transfer freely, whereas the real pool caps withdraw at its internal $.totalDeposited.describe("claimableRedeemRequest over-reports vs pool withdrawable", function () { const FULFIL_REDEEM_ROLE = toBytes32("FULFIL_REDEEM_ROLE"); const deployWithRealPool = async () => { const [deployer, admin, user] = await hre.ethers.getSigners(); const LIQUIDITY_ADMIN_ROLE = toBytes32("LIQUIDITY_ADMIN_ROLE"); const usdc = (await deploy("TestUSDC", deployer)) as TestUSDC; const USDC = 10n ** (await usdc.decimals()); const liquidityPoolImpl = (await deployX( "LiquidityPool", deployer, "LiquidityPoolReal", {}, usdc, networkConfig.BASE.WrappedNativeToken )) as LiquidityPool; const liquidityPoolInit = (await liquidityPoolImpl.initialize.populateTransaction( admin, admin, admin) ).data; const liquidityPoolProxy = (await deployX( "TransparentUpgradeableProxy", deployer, "TransparentUpgradeableProxyLiquidityPoolReal", {}, liquidityPoolImpl, admin, liquidityPoolInit )) as TransparentUpgradeableProxy; const liquidityPool = ( await getContractAt("LiquidityPool", liquidityPoolProxy, deployer) ) as LiquidityPool; const liquidityHubAddress = await getDeployXAddressBase( deployer, "TransparentUpgradeableProxyLiquidityHubReal", false); const lpToken = ( await deployX("SprinterUSDCLPShare", deployer, "SprinterUSDCLPShareReal", {}, liquidityHubAddress) ) as SprinterUSDCLPShare; const LP = 10n ** (await lpToken.decimals()); const liquidityHubImpl = ( await deployX("LiquidityHub", deployer, "LiquidityHubReal", {}, lpToken, liquidityPool) ) as LiquidityHub; const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction( usdc, admin, admin, admin, admin, getBigInt(MaxUint256) * USDC / LP) ).data; await deployX( "TransparentUpgradeableProxy", deployer, "TransparentUpgradeableProxyLiquidityHubReal", {}, liquidityHubImpl, admin, liquidityHubInit ); const liquidityHub = ( await getContractAt("LiquidityHub", liquidityHubAddress, deployer) ) as LiquidityHub; await liquidityPool.connect(admin).grantRole(LIQUIDITY_ADMIN_ROLE, liquidityHub); await liquidityHub.connect(admin).grantRole(FULFIL_REDEEM_ROLE, admin); return {deployer, admin, user, usdc, lpToken, liquidityHub, liquidityPool, USDC, LP}; }; it("claimableRedeemRequest over-reports, making fulfilRedeem fail for every receiver", async function () { const {deployer, admin, user, usdc, liquidityHub, liquidityPool, USDC, LP} = await loadFixture(deployWithRealPool); // User deposits 10 USDC. pool.totalDeposited = 10 USDC, pool balance = 10 USDC. await usdc.connect(deployer).transfer(user, 10n * USDC); await usdc.connect(user).approve(liquidityHub, 10n * USDC); await liquidityHub.connect(user).deposit(10n * USDC, user); // 1 USDC of yield lands in the pool (raw balance = 11 USDC) but is NOT part of totalDeposited, // and the adjuster credits it to the hub (totalAssets = 11 USDC). await usdc.connect(deployer).transfer(liquidityPool, 1n * USDC); await liquidityHub.connect(admin).adjustTotalAssets(1n * USDC, INCREASE); expect(await liquidityPool.balance(usdc)).to.equal(11n * USDC); expect(await liquidityPool.totalDeposited()).to.equal(10n * USDC); await liquidityHub.connect(user).requestRedeemWithFulfil(10n * LP); // claimableRedeemRequest reports all 10 LP because it uses balance() = 11 USDC: // availableShares = convertToShares(11 USDC) = 11 * 10 / 11 = 10 LP. // But the pool can withdraw at most totalDeposited = 10 USDC, which is only 9 LP worth. expect(await liquidityHub.claimableRedeemRequest(0n, user)).to.equal(10n * LP); // Because claimableRedeemRequest over-reports, redeem tries to withdraw 11 USDC from the pool, // exceeding totalDeposited (10 USDC), so fulfilRedeem reverts for the receiver. await expect(liquidityHub.connect(admin).fulfilRedeem([user])) .to.be.revertedWithCustomError(liquidityPool, "InsufficientLiquidity"); });});Recommendation
The amount that can actually be pulled out of the pool via
withdrawis bounded bytotalDeposited, not by the raw balance. Bound the available assets by both quantities everywhere the hub reasons about redeemable assets(claimableRedeemRequest,maxRedeem, andmaxWithdraw):uint256 availableAssets = Math.min( LIQUIDITY_POOL.balance(IERC20(asset())), LIQUIDITY_POOL.totalDeposited());Gnosis Repayer fix is incomplete in both directions
Severity
- Severity: Medium
Submitted by
slowfi
Description
The remediation for finding #4 adds the destination-side
processTransferGnosisOmnibridge()normalization, butRepayer.initiateRepay()still passes the logicaldestinationPooldirectly to Omnibridge. This bypasses the new processing path in one direction and conflicts with its adapter precondition in the other.Remediation reviewed: https://github.com/sprintertech/sprinter-stash-contracts/pull/275/changes/1597822aed663282ae7b56fdeb9640b08faea898
For Ethereum -> Gnosis, a caller supplies the configured Gnosis pool as
destinationPool. The route check succeeds and Omnibridge delivers USDCxDAI directly to that pool. Because the Gnosis Repayer never receives the tokens,processRepay()cannot call the transmuter to convert USDCxDAI to USDCe. The original wrong-USDC-representation condition therefore remains: the transfer can complete while the intended USDCe repayment is not accounted by the pool.For Gnosis -> Ethereum, a caller supplying the configured Ethereum pool cannot initiate a USDCe repayment. The adapter converts USDCe to USDCxDAI, but first requires
destinationPool == address(this), so the normal call reverts withInvalidDestinationPool. The fork test works around this by passing the Repayer itself instead of the configured destination pool. That is not equivalent:isRouteAllowed(address(this), ...)always returns true, the initiation event no longer identifies the logical pool, and the public parameter has different semantics only for this route.The existing Ethereum -> Gnosis unit test reinforces the first bug by expecting a direct bridge to the liquidity pool. Its bridge mock ignores the receiver argument, so it does not exercise whether the destination Repayer actually receives and normalizes the bridged token.
Impact
Ethereum -> Gnosis repayments can still deliver USDCxDAI to a pool that expects USDCe, leaving the intended debt outstanding. Gnosis -> Ethereum USDCe repayments revert when called with the configured destination pool. Thus the accepted finding remains exploitable in one direction and the route is unusable with its documented arguments in the other.
Recommendation
Keep
destinationPoolas the logical, route-checked destination, but useaddress(this)as the Omnibridge receiver in both directions, asRebalanceralready does. The destination Repayer can then normalize the token inprocessRepay()and transfer the result to the original pool. Preserve the logical pool in events and route validation. Add end-to-end tests for both directions using the configured pool argument and a bridge mock that delivers tokens to the requested receiver.
Low Risk7 findings
LiquidityHub zeroasset adjustment advertises impossible withdrawals
Severity
- Severity: Low
Submitted by
slowfi
Description
adjustTotalAssets(amount, false)can reduce the Hub's accountingtotalAssetsto zero while live shares still exist. In that state,_getTotalsForConversion()treats zero accounting assets as a bootstrap case and substitutessupplyAssets = 1:if (supplyAssets == 0) { supplyAssets = 1;}That fallback is safe for an empty vault bootstrap, but it is also applied when
totalSupply() > 0. As a result, after an authorized adjustment fromtotalAssets = 1tototalAssets = 0, a holder with 1 live share can still seemaxWithdraw(owner) == 1because conversions are performed against the virtualsupplyAssets = 1.The advertised withdrawal is impossible. A subsequent
withdraw(1, receiver, owner)enters_withdraw()and reverts with a Solidity panic at:$.totalAssets -= assets;because real accounting
totalAssetsis zero.The issue was reproduced by the ERC-7540 Hub Echidna harness with this minimized sequence:
- Mint 1 live share backed by 1 pool asset.
- Authorized
adjustTotalAssets(..., false)reduces Hub accounting assets from 1 to 0. maxWithdraw(owner)still returns 1.withdraw(1, ...)panics when subtracting from zerototalAssets.
After an authorized loss adjustment to zero, ERC-4626 views can advertise withdrawable assets that cannot be withdrawn. Users, integrations, and fulfilment logic can rely on
maxWithdraw()/maxRedeem()and still hit a low-level arithmetic panic. This is not permissionless theft because it requiresASSETS_ADJUST_ROLE, but it is an accounting/view consistency bug and can break exits after a full-loss adjustment.Recommendation
Do not apply the bootstrap virtual-asset fallback when shares already exist. If
totalAssets() == 0 && totalSupply() > 0, conversion and max-withdraw paths should treat shares as economically worthless and return zero withdrawable assets, oradjustTotalAssets()should reject decreasing accounting assets to zero while live or pending shares remain.ERC-7540 standard violations in LiquidityHub
Description
LiquidityHubimplements the asynchronous redemption flow of ERC-7540 when LiquidityPool does not have enough balance, but it does not conform to the standard in three places:-
The vault does not expose a
supportsInterface(EIP-165) that returnstruewhen the0x620ee8e4interfaceId is passed.supportsInterfaceis only inherited fromAccessControlUpgradeable, so today it returnstrueforIAccessControlandIERC165and nothing else. Without the async-redeem interfaceId, offchain components have no way to know that this vault supports async redemptions as specified by EIP-7540, and will classify it as a plain synchronous ERC-4626 vault. -
The vault does not expose a
shares()function as defined by ERC-7575. Shares are minted on a separate token contract (SHARES), not on the vault itself, so the vault address is not the share token. The token is only reachable through the non-standardSHARES()getter. Ashares()accessor allows offchain components to discover the ERC-20 share token that is minted when users deposit into this vault and without it, any integrator that assumes the ERC-4626 default (share token == vault address) reads balances and total supply from the wrong contract. -
setOperatordoes not return aboolas specified by ERC-7540. The implementation returns nothing, which changes the function's ABI.
Recommendation
Bring the vault into conformance with ERC-7540:
// 1. Advertise the async-redeem (and operator / ERC-7575) interfaces via ERC-165.function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == 0x620ee8e4 // ERC-7540 async redeem || interfaceId == 0xe3bc4e65 // ERC-7540 operator || interfaceId == 0x2f0a18c5 // ERC-7575 vault || super.supportsInterface(interfaceId);} // 2. Expose the ERC-20 share token per ERC-7575.function shares() public view returns (address) { return address(SHARES);} // 3. Return bool from setOperator per ERC-7540.function setOperator(address operator, bool approved) public returns (bool) { _getStorage().operators[_msgSender()][operator] = approved; emit OperatorSet(_msgSender(), operator, approved); return true;}-
Stablecoin pool direct borrows of non assets cannot be repaid
Severity
- Severity: Low
Submitted by
slowfi
Description
LiquidityPoolStablecoinallows borrowing any token held by the pool, but it inherits the base direct-repayment implementation, which only accepts the poolASSETStoken. IfDIRECT_BORROW_ROLEis ever granted on a stablecoin pool and the borrower usesborrowDirect()for another stablecoin, the resultingdirectDebt[token]cannot be cleared throughrepayDirect().The stablecoin pool removes the base borrow token restriction.
borrowDirect()is inherited fromLiquidityPoolBaseand records direct debt for the actual borrowed token onLiquidityPool.solcontract.However, the inherited
_repayDirect()only accepts a single repayment token and requires it to equalASSETS.So a direct borrow of, for example, PYUSD from a USDC stablecoin pool increments
directDebt[PYUSD], butrepayDirect([PYUSD], ...)reverts withInvalidAsset, andrepayDirect([USDC], ...)does not reduce the PYUSD debt.This also breaks the
StashDexrepayment integration if a stablecoin pool is configured as a token-out pool:StashDex.swap()can callborrowDirect(tokenOut, amountOut), butStashDex.repay(tokenOut)later callspool.repayDirect([tokenOut], ...)and reverts for anytokenOut != ASSETS.Even a manual token transfer back to the pool does not fix the accounting. The stablecoin pool's profit logic gates surplus withdrawals only on the USDC
ASSETSbalance plusdirectDebt[ASSETS]; once that condition is true, non-asset token balances are returned as withdrawable surplus without subtractingdirectDebt[token]. As a result, a manually returned PYUSD balance can still be withdrawn as profit whiledirectDebt[PYUSD]remains outstanding and uncleared.Current production StashDex config points its pooled tokens at the Aave pool, so this is not active in the shipped config. The contract-level integration is still inconsistent: granting
DIRECT_BORROW_ROLEon a stablecoin pool or routing StashDex through it can create direct debt that cannot be repaid through the intended direct-repayment path. Operators would need manual accounting or a contract change to clear the debt.Recommendation
Consider either, overriding
_repayDirect()inLiquidityPoolStablecointo accept and reduce direct debt for any borrowed stablecoin, or restrictborrowDirect()on this pool toASSETSonly. If multi-token direct borrowing is intentionally unsupported, make that explicit with an override that reverts before debt is recorded.ERC4626Adapter zero share deposits can consume withdrawable profit
Severity
- Severity: Low
Submitted by
slowfi
Description
ERC4626Adapter.depositWithPull()is permissionless and forwards the supplied assets into the target ERC4626 vault:function depositWithPull(uint256 amount) external override whenNotPaused() { ASSETS.safeTransferFrom(_msgSender(), address(this), amount); _deposit(_msgSender(), amount);}_deposit()then callsTARGET_VAULT.deposit(amount, address(this))and increases adaptertotalDepositedby the raw asset amount:TARGET_VAULT.deposit(amount, address(this));_getStorage().totalDeposited += amount;The adapter does not check how many vault shares were minted. If the ERC4626 vault rounds a very small deposit down to zero shares,
totalDepositedstill increases even though the adapter's vault share balance does not.This can shift previously withdrawable vault profit into deposited-principal accounting.
withdrawProfit()computes deposited backing throughTARGET_VAULT.previewWithdraw(totalDeposited)and preserves that many shares before redeeming the remainder as profit. If a zero-share deposit increasestotalDeposited, the adapter must reserve more shares for principal, reducing the amount that can be withdrawn as profit.The Echidna issue mode harness reproduced this with a target vault that had 20 ether of withdrawable profit. A permissionless
depositWithPull(1)minted zero vault shares but increasedtotalDepositedby 1. A laterwithdrawProfit([ASSETS])paid19.999999999999999999 ether, consuming 1 wei of previously withdrawable profit through the accounting shift.Any caller can make tiny deposits that increase adapter
totalDepositedwithout increasing the adapter's vault-share balance. This can grief profit withdrawal and distort adapter accounting by converting previously withdrawable profit into accounted principal.The direct demonstrated loss is bounded by the caller's deposited dust amount, so this is not direct theft of pool assets. The issue is still relevant because the function is permissionless and the adapter accounting should not treat a deposit as principal-backed if no vault shares were minted.
Recommendation
Use the share amount returned by
TARGET_VAULT.deposit()and reject zero share deposits:uint256 shares = TARGET_VAULT.deposit(amount, address(this));require(shares > 0, ZeroShares());_getStorage().totalDeposited += amount;Alternatively, check
TARGET_VAULT.previewDeposit(amount) > 0before pulling funds. If the adapter is only meant to receive operational rebalances, consider restrictingdepositWithPull()or adding a minimum deposit threshold that prevents permissionless dust from changing principal accounting.StashDex oracle sync omits input only route assets
Severity
- Severity: Low
Submitted by
slowfi
Description
deployPaxosOracle.tsand theupdate-paxos-oracle-assetstask register only tokens listed inconfig.StashDex.Pools.deployStashDex.ts, however, only requires each route'sTokenOutto have a configured pool. It accepts any configuredTokenIntoken:for (const {TokenOut} of stashDexConfig.Routes) { assert(stashDexConfig.Pools[TokenOut], `Route tokenOut ${TokenOut} has no pool configured in StashDex.Pools`);}At runtime,
StashDex.swap()prices both the input and output token through the immutable oracle before it transfers funds:uint256 valueIn = ORACLE.getAssetValue(_tokenToAssetId(tokenIn), amountIn * precision);uint256 valueOut = ORACLE.getAssetValue(_tokenToAssetId(tokenOut), amountOut * precision);If a future route uses a token only as
TokenIn, the StashDex deployment accepts the route but the Paxos oracle deploy/sync flow omits that asset. The route is then configured on-chain but unusable because the input-token oracle lookup reverts.A valid looking StashDex route can be deployed or retained while swaps through it always revert. The failure happens before the input transfer, so this is an availability/configuration issue rather than a direct fund-loss path.
Current visible route configs use symmetric pool tokens, so this is primarily a guardrail issue for future route changes.
Recommendation
Build the Paxos oracle desired asset set from every token used by StashDex routes and pools, not only
StashDex.Pools. Deployment and sync scripts should also assert that the configured oracle supports bothTokenInandTokenOutfor every allowed route before installing the route.Conditional virtual shares re-opens the ERC4626 share-price reset attack
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Kankodu
Description
OpenZeppelin's ERC4626 defends against share-price manipulation by adding virtual shares and virtual assets to every conversion:
convertToAssets = shares * (totalAssets + 1) / (totalSupply + 10**offset)convertToShares = assets * (totalSupply + 10**offset) / (totalAssets + 1)These virtual amounts are always present, so a "phantom" position permanently owns a slice of the vault and the effective exchange rate can never be reset to the base 1:1 rate, even after every real share is burned.
LiquidityHubhas different calculation. In_getTotalsForConversionthe virtual amounts are only applied when a total is exactly zero.Whenever both totals are non-zero (the normal operating state) the conversion is the naive
assets * totalSupply / totalAssets, with no virtual protection. As a result the share price can be driven back to the base rate. An attacker who momentarily controls all shares (Unlikely but possible if a flash loan provider has all the totalSupply) can redeem every share to bringtotalSupplyandtotalAssetsto0, then re-mint the same shares at the reset 1:1 rate and pocket the accumulated yield.The rate becomes elevated whenever yield is added via
depositProfitoradjustTotalAssets, which is the protocol's normal behaviour.The likelyhood is very low because it requires a flashloan provider to control all of the total shares AND all of the underlying assets are available to be withdrawn
Proof of Concept
Add the following test to
test/LiquidityHub.ts:it.only("PoC: price-reset attack drains accrued yield", async function () { const { lpToken, liquidityHub, usdc, deployer, user, user2, admin, USDC, LP, liquidityPool, } = await loadFixture(deployAll); // Victim (user) and attacker (user2) each deposit 10 USDC -> 10 LP. Price = 1 USDC/LP. await usdc.connect(deployer).transfer(user, 10n * USDC); await usdc.connect(deployer).transfer(user2, 10n * USDC); await usdc.connect(user).approve(liquidityHub, 10n * USDC); await usdc.connect(user2).approve(liquidityHub, 10n * USDC); await liquidityHub.connect(user).deposit(10n * USDC, user); await liquidityHub.connect(user2).deposit(10n * USDC, user2); // 20 USDC of real yield is added to the pool. Share price doubles to 2 USDC/LP. await usdc.connect(deployer).transfer(admin, 20n * USDC); await usdc.connect(admin).approve(liquidityHub, 20n * USDC); await liquidityHub.connect(admin).depositProfit(20n * USDC); expect(await liquidityHub.totalAssets()).to.equal(40n * USDC); expect(await liquidityHub.totalSupply()).to.equal(20n * LP); // Victim's 10 LP are worth 20 USDC. expect(await liquidityHub.convertToAssets(10n * LP)).to.equal(20n * USDC); // Attacker flash-borrows all of the victim's shares ( from some flashloan provider). await lpToken.connect(user).transfer(user2, 10n * LP); // 1) Redeem EVERY share -> totalSupply and totalAssets both hit 0, draining the vault. await liquidityHub.connect(user2).redeem(20n * LP, user2, user2); expect(await liquidityHub.totalSupply()).to.equal(0n); expect(await liquidityHub.totalAssets()).to.equal(0n); expect(await usdc.balanceOf(user2)).to.equal(40n * USDC); // 2) Price is reset to the base 1:1 rate. Re-mint the same 20 LP for only 20 USDC. await usdc.connect(user2).approve(liquidityHub, 20n * USDC); await liquidityHub.connect(user2).deposit(20n * USDC, user2); expect(await liquidityHub.totalSupply()).to.equal(20n * LP); // 3) Return the borrowed 10 LP to the victim (repay the flashloan). await lpToken.connect(user2).transfer(user, 10n * LP); // Attacker profit: deposited 10 USDC, walks away with 20 USDC cash + 10 LP. expect(await usdc.balanceOf(user2)).to.equal(20n * USDC); expect(await lpToken.balanceOf(user2)).to.equal(10n * LP); // The victim's shares were devalued from 20 USDC to 10 USDC: their yield was stolen. expect(await liquidityHub.convertToAssets(10n * LP)).to.equal(10n * USDC);});Recommendation
Apply the virtual shares and virtual assets unconditionally, exactly as OpenZeppelin does, instead of only when a total is zero:
function _getTotalsForConversion() internal view returns (uint256, uint256) { return (totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1);}With the offset always present, a full redemption leaves the virtual position owning the residual assets, so the effective rate never resets to base and re-minting costs at least what was redeemed, making the round-trip unprofitable.
StashDex upgrade can abandon legacy direct debt
Severity
- Severity: Low
Submitted by
slowfi
Description
The remediation for the StashDex/Aave interest issue changes StashDex from direct-debt accounting to the new
LiquidityPool.borrowWithRole()flow:That is directionally correct for future swaps, because new StashDex borrows no longer increase pool
directDebtand repayments are expected to route through the Repayer. However, the upgrade does not migrate or guard existing deployed state.Before the remediation, StashDex stored each token's
totalBorrowed, incremented it inswap(), repaid it throughrepay()usingconfig.pool.repayDirect(), calledrepay()beforeforward(), exposedgetTotalBorrowed(), and blocked_setPool()whiletotalBorrowed != 0.After the remediation,
TokenConfig.totalBorrowedis left in storage only as deprecated layout, but no production function reads or reduces it. Therepay()andgetTotalBorrowed()functions are removed,forward()transfers the full balance toRECEIVERwithout first repaying legacy debt, and_setPool()can replace the configured pool without checking outstanding legacy debt.This creates an unsafe upgrade path for any already-deployed StashDex instance with nonzero
tokenConfig[token].totalBorrowedand matching pooldirectDebt[token]:- The old implementation borrows through
borrowDirect()and records StashDex legacy debt in both StashDextotalBorrowedand the pool'sdirectDebt. - The proxy is upgraded to the remediation implementation.
- The old
totalBorrowedvalue still exists in ERC-7201 storage, but the new implementation no longer exposesgetTotalBorrowed()or arepay()path that uses it. - If repayment tokens later reach StashDex,
forward()now sends them toRECEIVERinstead of callingrepayDirect()first. setPool()can move the token to a new pool even while the old pool still accounts direct debt to StashDex.- If StashDex is the only configured direct borrower for the old pool, the normal integration path can no longer reduce the old
directDebtwithout another upgrade, role change, or manual rescue action.
The issue is about upgrade safety, not the future borrow model. The new model avoids creating new StashDex direct debt, but it can permanently abandon direct debt that already existed before the upgrade.
Legacy direct debt can remain outstanding in the old liquidity pool while StashDex continues operating against the new implementation or even a new configured pool. That stale debt can distort pool accounting, interfere with profit withdrawal and health-factor management, and leave LP collateral backing debt that the upgraded integration no longer knows how to repay.
On live Ethereum state reviewed during this remediation check, StashDex still had nonzero legacy debt for USDC and USDG before this migration concern was raised. Those values matched the corresponding pool
directDebtvalues, which confirms this is not only a theoretical storage-layout edge case.Recovery would require an additional privileged action, such as a temporary legacy repayment function, a role grant to a replacement repayment helper, or another upgrade. The submitted remediation does not include that migration or pre-upgrade guard.
Recommendation
Make the upgrade explicitly handle legacy StashDex debt before removing the old repayment path. Acceptable approaches include:
- Before upgrading, enumerate every configured token and require
tokenConfig[token].totalBorrowed == 0and the corresponding pooldirectDebt[token] == 0. - Keep a temporary legacy repayment function after the upgrade that reads the deprecated
totalBorrowedfield and callsrepayDirect()until all legacy debts are zero. - Preserve the old
_setPool()outstanding-debt guard for tokens with nonzero deprecatedtotalBorrowed. - Make
forward()repay legacy direct debt before forwarding any remaining balance until the migration is complete. - Add a forked migration test against deployed StashDex state that proves all legacy
totalBorrowedand pooldirectDebtvalues are cleared before the new implementation is considered live.
Only after the legacy debt is proven zero should StashDex rely solely on the new
borrowWithRole()/Repayer model.
Informational8 findings
Aave repay can desync direct debt
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
LiquidityPoolAave.repay()repays Aave variable debt but never reduces the pool'sdirectDebtmapping. If debt created byborrowDirect()is later settled through the publicrepay()path instead ofrepayDirect(), Aave debt decreases whiledirectDebtremains inflated. The profit withdrawal logic then treats that stale direct debt as surplus backing and can borrow from Aave again to pay it out as profit.Using the wrong repayment entry point for direct-borrowed Aave debt can permanently decouple internal direct debt accounting from the actual Aave debt. Once decoupled, profit withdrawal can treat stale direct debt as withdrawable surplus, borrow new Aave debt to fund the withdrawal, and transfer repayment or donated balances out as protocol profit. This can leave LP collateral backing newly-created Aave debt even though the original direct borrow was already repaid through
repay().Recommendation
Prevent the ordinary
repay()path from settling direct borrow debt without updating direct-debt accounting. Options include tracking which Aave debt was opened by direct borrows and reducingdirectDebtwhenrepay()settles that debt, blockingrepay()for tokens with outstanding direct debt, or makingrepayDirect()the only repayment path that can touch Aave debt attributable to direct borrows. Profit withdrawal should not countdirectDebtas surplus backing unless the corresponding direct debt is still actually outstanding.Sprinter: This is expected behavior covered by tests. Direct debt must be repaid by direct borrowers through repayDirect(). Even if all the Aave debt is already closed (which could be done even by some arbitrary wallet talking directly to Aave) then repayDirect() will still reduce the direct debt, without sending any funds to Aave.
Cantina: Acknowledged by Sprinter team.
Ordinary Aave repay does not clear direct debt
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
LiquidityPoolAave.repay()can reduce Aave variable debt without reducing the pool'sdirectDebtmapping. That means Aave debt and direct-debt accounting can diverge if debt originally associated with a direct borrower is paid through the ordinary repayment path or directly against Aave by some other actor.This divergence is part of the direct-debt accounting model.
directDebtis treated as the direct borrower's obligation to the pool, not as a strict mirror of the current Aave variable-debt balance. Ordinaryrepay()does not clear that obligation; direct borrowers must callrepayDirect().Under this model, the important documentation point is that "Aave debt is repaid" and "direct debt is settled" are separate states. Integrations should not infer that clearing variable debt also clears direct-borrow accounting.
If an integration uses
repay()or direct Aave repayment when it meant to settle direct debt,directDebtremains outstanding until the borrower callsrepayDirect(). Profit and virtual-balance calculations will continue to include that direct debt according to the accepted model.The separate edge where
repayDirect()could fail after Aave debt was already zero was accepted as a real bug and is tracked inmedium-aave-repaydirect-reverts-when-aave-debt-is-zero.md.Recommendation
Document the distinction between ordinary Aave repayment and direct-debt settlement. Direct borrowers and integrations should be required to use
repayDirect()for direct debt, even if Aave variable debt has already been reduced by another path.Superchain StandardBridge USDC routes can temporarily retain repayments
Severity
- Severity: Informational
Submitted by
slowfi
Description
SuperchainStandardBridgeAdapter.initiateTransferSuperchainStandardBridge()accepts anoutputTokenfrom routeextraData, checks that the token is allowlisted for the destination domain, and then callsbridgeERC20To(localToken, outputToken, destinationPool, amount, ...).For Superchain StandardBridge routes, allowlisting the destination token is not enough. The chosen
localTokenandoutputTokenmust also be a valid StandardBridge token pair. In the reviewed configuration, some native USDC repayment routes could selectProvider.SUPERCHAIN_STANDARD_BRIDGEfor OP Mainnet or Base destinations even though the configured destination asset was Circle native USDC rather than the StandardBridge mintable representation.If such a route is used, funds can enter the bridge flow while the destination repayment does not complete normally. This is a temporary stranding/delay scenario rather than permanent loss when the bridge message can be made processable by a same-amount reverse bridge that provides the missing destination-side liquidity.
The intended destination pool remains unpaid until operators recover the route or process the delayed message. This can delay debt settlement and require manual bridge recovery, but the funds are not expected to be permanently lost.
Recommendation
Do not route native USDC/
ASSETrepayments throughSUPERCHAIN_STANDARD_BRIDGE; use CCTP/CCTP v2 for native USDC instead. If StandardBridge support remains enabled for other assets, route sync should validate provider-specific token pairs, not only whether the destination token is generally allowlisted.Code improvement suggestions
Description
-
In
StashDex.swap, the fee checkrequire(valueIn * (BPS - route.feeBps) >= valueOut * BPS, InsufficientOutput());reverts withInsufficientOutputwhen the input value net of fees does not cover the requested output value. The error name is misleading. When this check fails it should fail withInsufficientInputorExcessiveOutputinstead ofInsufficientOutput. If the user specifies an insufficient output the swap doesn't fail, it just accepts it and gives the user less output tokens than they deserve.Make sure there is no confusion that
amountOutspecifies the minimum amount the user wants out. It can be ANY amount, and StashDex will return exactly that much amount as long as it is less than thetokenInvalue minus fees. -
Processor.process4626takes anIERC4626 tokenInand values the shares withtokenIn.convertToAssets(sharesIn)before passing the result to_assertOutputAmount. That valuation is only meaningful if the vault's underlying asset is the target asset, but nothing enforces it. Add a sanity check to make sure the underlying asset for thetokenInvault isTARGET_ASSET, by requiring thattokenIn.asset()equalsaddress(TARGET_ASSET). -
uint256 precision = 10**12;is declared as a local variable inside the function body in two places,StashDex.sol:149andNetter.sol:51. Make it a global constant.
Recommendation
Make the changes as suggested
-
Regular Processor deployments leave process unusable with a zero oracle
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
Processor.processalways callsORACLE.getAssetValueto compare the input and minimum output values. The baseProcessorconstructor does not reject a zero oracle, and the deploy and upgrade scripts for regular token processors passZERO_ADDRESSas the oracle constructor argument.The test suite confirms the resulting behavior: a processor deployed with a zero oracle reverts when
process()is called because the external oracle call goes to address zero. That means regular processors deployed through the provided scripts can forward tokens and use helper-specific flows, but their generic conversion path is unusable.Assets that require the generic
process()path for an unwind or swap cannot be processed by regular token processors deployed from these scripts. Operators must either route around the processor, forward assets without the intended slippage check, or upgrade/redeploy with a real oracle before completing the unwind.Recommendation
Require a nonzero oracle for processors that expose
process(), and pass the intended oracle indeployTokenProcessor.tsandupgradeTokenProcessor.ts. If some deployments are intentionally forward-only, split that mode into a separate contract or disableprocess()explicitly so the deployed interface does not advertise an unusable path.Safe proposals are treated as executed by maintenance scripts
Severity
- Severity: Informational
Submitted by
slowfi
Description
When
SAFEis configured,createSenderreturns aSafeSigner. For non-deployment transactions,SafeSigner.sendTransactionproposes a Safe transaction and only executes it if the current confirmation count already meets the Safe threshold. If the threshold is not met, it returns a fakeTransactionResponsewhosewait()resolves tonull.upgradeProxyXtreats the signer as able to execute immediately wheneverProxyAdmin.owner()equals the signer address. With aSafeSigner, that address is the Safe itself, soupgradeProxyXcallsupgradeAndCall, prints that the upgrade was sent and completed, and returnstxRequired: falseeven when only a Safe proposal was created and no on-chain upgrade happened.All scripts that call
upgradeProxyXcan therefore report an upgrade as complete while it is only pending in the Safe UI. This is especially risky for scripts with post-upgrade actions. For example,upgradeTokenProcessor.tscheckstxRequiredand, if it is false, proceeds as though the new implementation is live before initializing the newSubProcessorand grantingCONFIG_ROLE. Under a multi-signature Safe, those follow-up steps can run against the old implementation or fail after the script has already reported the upgrade path as complete.The same pattern affects the new Safe-enabled Hardhat maintenance tasks. Tasks such as
grant-role, LTV updates, route updates,update-tokens-repayer, StashDex route/pool sync, Paxos oracle asset sync, and Netter processor-role grants call contracts throughcreateSender()and then print success after awaiting the returned transaction or fakewait(). With a Safe below threshold, these tasks have only submitted a proposal, but their output says the role was granted or the configuration was updated.Operators can believe critical upgrades, role grants, route changes, oracle asset changes, or post-upgrade initialization steps have executed when they are only pending Safe proposals. This can leave stale implementations, missing initialization, incomplete access-control changes, or unsynchronized routes in production.
Recommendation
Make Safe proposal state explicit.
SafeSigner.sendTransactionshould either return only after a real on-chain receipt exists or expose that the transaction was merely proposed.upgradeProxyX, Hardhat tasks, and dependent scripts should returntxRequired: trueor a separateproposalPendingstate unless execution is confirmed on-chain. Any post-upgrade initialization, role grant, or route/config update should be gated on a confirmed receipt.Aave token LTV cannot be set to zero while a default LTV is configured
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
LiquidityPoolAave.setBorrowTokenLTVs()stores the configured per-token LTV value directly. The enforcement paths then treat a stored value of0as "unset" and fall back todefaultLTV:uint256 ltv = $.borrowTokenLTV[borrowToken];if (ltv == 0) ltv = $.defaultLTV;This means an admin cannot disable borrowing for a specific token by setting its LTV to zero while the pool has a nonzero default LTV. The emitted
BorrowTokenLTVSet(token, oldLTV, 0)event suggests that the token's configured cap was set to zero, but_checkTokenLTV()andbalance()both continue to use the default cap.Risk maintenance can leave a token borrowable when operators intended to fully disable exposure to that token. Borrowing still requires the normal authorized borrowing paths and global health-factor checks, so this is not a permissionless loss path. It is an operational risk-control gap: a token-specific emergency reduction to zero does not take effect unless the global default LTV is also reduced.
Recommendation
Use a separate sentinel for "unset" instead of
0, or store an explicitisConfiguredflag per token. A configured LTV of zero should be enforced as zero and should makebalance(token)return no token-specific borrow capacity.Aave-backed Hub fix underreports withdrawable liquidity
Severity
- Severity: Informational
Submitted by
slowfi
Description
The remediation for redeemable assets report (#13) fixes one mismatch but leaves the Aave-backed Hub using the wrong availability primitive.
Original report relationship: report was correct for an over-reporting case. The Hub used
LIQUIDITY_POOL.balance(asset)inclaimableRedeemRequest,maxRedeem, andmaxWithdraw. For the base pool, that value could exceed the amount the pool would allow throughwithdraw(), becauseLiquidityPool.withdraw()is capped bytotalDeposited. The remediation at https://github.com/sprintertech/sprinter-stash-contracts/pull/275/commits/6d19ad81e505556757fad36e4a727a9ebb969cf3 introduced_availableAssets()and changed the Hub to usemin(LIQUIDITY_POOL.balance(asset), LIQUIDITY_POOL.totalDeposited()). That addresses the original over-reporting direction.This issue is the opposite direction and is specific to Aave-backed pools. In
LiquidityPoolAave,balance(token)does not mean "amount that can be withdrawn from Aave". It is a borrow-capacity view:_balance()computes the amount that could still be borrowed, constrained by reserve liquidity, Aave LTV, the configured minimum health factor, token-specific LTV, and current debt.Actual withdrawals use a different path.
LiquidityPoolAave._withdrawLogic()checks that the pool has enough aTokens, callsAAVE_POOL.withdraw(asset, amount, to), and then checks health factor. With no debt, or with enough health-factor headroom, the pool can withdraw its deposited collateral even when the Aave borrow capacity is much lower than the deposited/aToken balance.Concrete example:
- An Aave-backed Hub has
totalDeposited = 100 USDCand the pool holds100 aUSDC. - The Aave reserve LTV/min-health-factor calculation makes
LiquidityPoolAave.balance(USDC)return80 USDCof borrow capacity. - The fixed Hub computes
_availableAssets() = min(80, 100) = 80. claimableRedeemRequest,maxRedeem, andmaxWithdrawall report only80 USDCas available.- A direct
LiquidityPool.withdraw(..., 100 USDC)can still succeed because the withdrawal path is based on aToken collateral and health factor, not borrow capacity.
Thus the fix prevents the original over-reporting bug but still causes under-reporting for Aave-backed Hubs. The Hub can advertise less liquidity than is actually withdrawable and
fulfilRedeem()can skip or partially fulfill redeem requests even when the underlying pool can withdraw the assets.Users with pending redeem requests can remain unfulfilled even though enough Aave collateral is withdrawable.
maxWithdraw,maxRedeem, andclaimableRedeemRequestbecome artificially low, causing operators and integrations to treat available liquidity as unavailable. This can delay withdrawals and leave deployable liquidity idle until Aave borrow capacity increases, even though the pool's withdrawal path could satisfy the request.This is not a duplicate of #13. #13 was an over-reporting issue where Hub views could exceed the amount
withdraw()accepted. This report is an incomplete-fix/regression in the other direction: after the #13 fix, the Hub can under-report because the Aave implementation'sbalance()is borrow capacity, not withdrawable liquidity.Recommendation
Do not use
LiquidityPool.balance()as a generic withdrawability primitive. Add a separate pool view such aswithdrawableAssets(IERC20 token)oravailableForWithdrawal(IERC20 token)and have the Hub use that for ERC-7540 availability views.For the base pool, the withdrawable amount should be bounded by actual token balance and
totalDeposited. ForLiquidityPoolAave, it should be based on the aToken collateral that can be withdrawn while preserving the configured health-factor constraints, not on remaining borrow capacity. Add tests that cover both directions: the original #13 over-reporting case and an Aave case where borrow capacity is lower than the amount thatwithdraw()can redeem.