Skip to content

StabilityPool — Design Decisions & Accepted Trade-offs

The behaviours below are properties of the StabilityPool and are intentional. Each has been raised in a previous security review and accepted as design rather than fixed, and each is recorded here so that reviewers can tell a deliberate trade-off from a defect. References are to the Pashov Audit Group reviews: v4 (2026-02, CORE Lending), v3 (2025-12, CORE), sweep (2025-11) and v2 (2025-08).

Raised in the most recent review

Liquidation losses are not socialised pro-rata

When an unprofitable liquidation leaves the pool under-backed, the deficit is not distributed across depositors by re-pricing deToken. Withdrawals remain 1 : 1 and the shortfall lands on whoever withdraws last.

This follows from the 1 : 1 peg being a hard invariant rather than an accounting convenience. deToken is designed to be handed onward — into the Curve liquidity pool, and from there into the RAAC Gauge — and every one of those venues prices it against rToken at par. Introducing a floating internal exchange rate would break that composability for a loss the protocol intends to repair rather than distribute.

The compensating controls are the ones described above: the cooldown prevents a first-mover race, collectDust cannot remove rToken while a deficit is open, surplus from the next profitable liquidation repairs the deficit before anything else, and the Treasury can restore backing directly.

Withdrawal requests are free and unescrowed

requestWithdraw() charges no fee and does not lock or escrow the deToken being requested. A depositor can therefore keep a standing exit option alive — re-queueing whenever the execution window lapses — while continuing to hold and stake the deToken elsewhere, and can execute the moment a bad liquidation appears.

This is accepted. Escrowing the requested amount would make deToken unusable in the Curve pool and the gauge for the whole cooldown, which defeats the reason depositors hold it; charging a fee to queue an exit penalises ordinary users to price out a strategy that only pays off in the narrow window where a liquidation is already visible on-chain. Liquidation timing is manager-driven rather than public and permissionless, which further narrows that window.

Managers can liquidate while the pool is paused

liquidateBorrower is deliberately not whenNotPaused. Pausing stops user deposits and withdrawals; it must not stop the protocol from clearing an unhealthy position, since solvency work is exactly what is needed during the incident that caused the pause.

Liquidations are manager-driven and unincentivised

There is no public liquidation bounty and no permissionless entry point. Real-estate collateral is large, slow-moving and thinly traded, so an open race would produce badly timed swaps against shallow liquidity. Managers are expected to weigh gas, slippage and available Curve depth before executing.

Acknowledged in earlier reviews

Ref Review Behaviour
L-05 sweep · 2025-11 Liquidation proceeds while the LendingPool is paused, so borrowers cannot repay to cure
L-19 v3 · 2025-12 LiquidationSwap derives expectedDy from the vault oracle and ignores the AMM's own fee
L-02 v2 · 2025-08 The same-block guard keys on block.number
L-10 v3 · 2025-12 Rounding direction is not uniformly pessimistic across the protocol

Liquidation continues while the LendingPool is paused

liquidateBorrower is not gated on the LendingPool's pause state, but repay, repayOnBehalf and closeLiquidation are. Pausing the LendingPool therefore stops a borrower curing a position while the StabilityPool can still finalise it — the borrower loses the collateral without having been able to pay.

The asymmetry is intentional in one direction only: solvency work must not be blocked by the incident that caused the pause. The unfairness on the borrower's side is handled operationally rather than in code — as documented under Repayment, a PROTOCOL_ROLE call to setParameter(LiquidationGracePeriod, …) extends the grace period to cover a window in which repayment was disabled, so no borrower is finalised for a grace period they could not use. The extension is read live by finalizeLiquidation, so it reaches positions already flagged — but the parameter is capped at 7 days, so a pause outlasting that window is not fully covered by it.

The swap's expected output ignores the AMM's fee

LiquidationSwap._swap computes expectedDy from IBaseHybridVault.pricePerShare() and the pmUSD/USD oracle, then derives minDy from it with slippageProtectionBps. That is a theoretical mid-price: it does not subtract the pool's own swap fee, which the actual output does pay. The tolerance therefore has to absorb both real slippage and the pool fee.

With slippage protection enabled, a slippageProtectionBps set below the pool's fee makes every swap revert with SlippageExceeded and blocks liquidations. This is accepted because slippageProtectionBps is an operator parameter set with the live pool fee in hand, and liquidations are manager-driven rather than permissionless — the manager checks the pool's depth and fee before executing anyway. Operational requirement: slippageProtectionBps must exceed the AMM's fee, and must be revisited whenever the pool or its fee changes.

The same-block guard keys on block.number

depositBlock[user] = block.number blocks a deposit and a withdrawal landing in the same block. See the LendingPool note — the guard assumes mainnet block atomicity, which holds for the Ethereum-mainnet-only deployment.

Rounding in the pool's own reads is directional

StabilityPool performs no interest math of its own, but it does scale two values, and both use rayMulDown: getScaledDeposit() and the withdrawal cap in _withdrawTimelockCheck. Truncating is the conservative direction here — a depositor can never be quoted more than the pool can pay. The protocol-wide rounding position is documented under LendingPool.