diff --git a/README.md b/README.md index 00891006c..90157ca2d 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,16 @@ A unified liquidity layer and modular architecture that enhances capital efficie ## Documentation -- [Aave V4 Overview](./docs/overview.md) - [Aave V4 Docs](https://aave.com/docs/aave-v4) +- [Aave V4 Overview](./docs/overview.md) +- [Liquidation Engine](./docs/features/LiquidationEngine.md) +- [Reinvestment Model](./docs/features/ReinvestmentModule.md) +- [Siloed Borrowing](./docs/features/SiloedBorrowing.md) +- [Isolation Mode](./docs/features/IsolationMode.md) +- [Efficiency Mode](./docs/features/EfficiencyMode.md) +- [Dynamic Configuration](./docs/features/DynamicConfiguration.md) +- [Position Manager](./docs/features/PositionManager.md) +- [Tokenization Spoke](./docs/features/TokenizationSpoke.md) ## Architecture diff --git a/docs/Glossary.md b/docs/Glossary.md new file mode 100644 index 000000000..91427083d --- /dev/null +++ b/docs/Glossary.md @@ -0,0 +1,59 @@ +# Glossary + +Canonical terminology for Aave V4. Terms appear in alphabetical order. + +--- + +**Base Drawn Rate:** Per-asset interest rate set at the Hub level, applied to all drawn debt for that asset. Determined by the Hub's interest rate strategy, based on current utilization. The symbol $R_{sbase,i}$ refers to the base drawn rate for asset $i$. + +**Collateral Factor:** Maximum percentage of collateral value that can be borrowed against a collateral asset. + +**Collateral Risk:** Additional risk parameter per asset, managed by the Governor. Introduces a charge on borrowers above the base drawn rate. + +**Deficit:** Protocol accounting state in which a borrower has outstanding debt but no remaining collateral after a liquidation. Deficits are tracked per asset at the Hub level and reported per Spoke (the Spoke where the shortfall arose). Authorized active Spokes can eliminate a deficit on behalf of a specific other Spoke's reported deficit for that asset by deploying their supplied shares. + +**Draw Cap:** Maximum amount a Spoke can draw from a Hub. + +**Drawn Debt:** Base debt of a position, accruing interest at the base drawn rate. + +**Dynamic Risk Configuration:** Combination of Risk Configuration parameters that allows different users to be subscribed to different configurations. + +**Governor:** Entity authorized to manage Spokes and Hubs, set risk parameters, configure caps, and execute emergency controls. In the Aave V4 deployment, this is the Aave DAO. + +**Health Factor (HF):** Ratio of a user's weighted collateral value (adjusted by Collateral Factor) to total outstanding debt value. When HF drops below `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`, the position becomes eligible for liquidation. + +**Hub:** Central store where all liquidity is held, accessed by attached Spokes. No collateralization is enforced on the Hub. + +**Isolation Mode:** Configuration pattern that deploys a dedicated Spoke and Hub pair for a new or experimental asset with contained risk. This asset is the only allowed collateral asset. Borrowable assets are restricted by the `borrowable` flag on each Reserve; maximum borrow exposure is controlled per asset through `SpokeData.drawCap` at the Hub. Users can retain positions in other Spokes simultaneously using the same wallet. + +**Liquidation Bonus:** Collateral surplus awarded to a liquidator above the base currency value of the debt repaid. In Aave V4, the bonus follows a Dutch-auction formula that interpolates linearly between a governance-set minimum at `HEALTH_FACTOR_LIQUIDATION_THRESHOLD` and the per-collateral `maxLiquidationBonus` at `healthFactorForMaxBonus`. The net bonus after the protocol fee equals the effective collateral surplus received by the liquidator. + +**Liquidation Fee:** Protocol fee applied to the effective bonus portion of collateral seized during a liquidation. A fraction of the bonus collateral is transferred to the Hub fee receiver as shares via `Hub.payFeeShares`. Expressed in BPS as `liquidationFee` in `DynamicReserveConfig`. + +**Position Manager:** Authorized entity allowed to manage a user position after the user has granted explicit permissions to do so. + +**Premium Debt:** Extra debt of a position, corresponding to the user's collateral risk, determined by `premiumShares`, `premiumOffset`, and `realizedPremium`. + +**Premium Offset:** In asset terms, the effective principal value at the time debt was taken, such that premium debt accrues only on interest earned on `premiumShares`. + +**Premium Shares:** Effective shares on which the base drawn rate is applied to accrue interest. This helps account for the extra interest owed due to a user having riskier collateral. + +**Realized Premium:** In asset terms, the completely non-interest-bearing portion of premium debt accumulated to date. + +**Reinvestment Controller:** Authorized entity allowed to pull idle liquidity from the Hub to generate yield. Responsible for maintaining investment strategies, caps, and accounting for yield distribution to suppliers. + +**Reserve:** Per-asset accounting unit within a Spoke. Each Reserve tracks supply and borrow state for a single asset, references its Hub via `Reserve.hub`, and carries configuration for collateral eligibility, borrowability, interest parameters, and liquidation settings. + +**Risk Premium:** Weighted average of the Collateral Risk values of a user's collateral assets. + +**Siloed Borrowing:** Configuration pattern in which a single asset is designated as the sole borrowable Reserve in a Spoke by setting `borrowable = false` on all other Reserves. Borrow exposure and any resulting risk are confined to the siloed asset's Hub; other Spokes and markets are not affected. + +**Spoke:** Entity that can supply to and draw liquidity from the Hub. + +**Supply Cap:** Maximum amount a Spoke can supply to the Hub. + +**Target Health Factor:** The HF to which a borrower's position is restored after liquidation, set by the Governor at the Spoke level. Liquidators repay only the debt necessary to reach this value, replacing the fixed close-factor used in Aave V3. + +**Tokenization Spoke:** Spoke variant that exposes only the supply side of the protocol via an ERC-4626 interface. Has no borrow surface and no risk premium calculation; positions through the TokenizationSpoke are supply-only. + +**User Borrow Rate:** Effective borrow rate charged to an individual user, composed of the base rate from the asset's `drawnRate` and an additional Risk Premium. diff --git a/docs/features/DynamicConfiguration.md b/docs/features/DynamicConfiguration.md new file mode 100644 index 000000000..47043b681 --- /dev/null +++ b/docs/features/DynamicConfiguration.md @@ -0,0 +1,160 @@ +# Dynamic Risk Configuration + +## Summary + +Dynamic Risk Configuration is a spoke-level versioning mechanism (where each version is a snapshot of collateralization parameters) that isolates those parameters into per-Reserve configuration entries, each identified by a sequentially incrementing `dynamicConfigKey`. When governance updates collateralization parameters for a Reserve, the Spoke typically appends a new configuration entry rather than replacing the existing one (though governance can also update an existing key in place via `updateDynamicReserveConfig`). User positions retain a snapshot of the `dynamicConfigKey` active at the time of their last risk-bearing action. Parameter updates therefore do not immediately affect open positions; existing positions continue to evaluate under their snapshotted configuration until the user performs a risk-increasing action, at which point the Spoke rebinds the snapshot to the latest key. If the rebinding leaves the position under-collateralized, the action reverts. + +The Governor retains the ability to force-migrate individual positions to the latest configuration via `updateUserDynamicConfig`. This mechanism is intended for emergency scenarios where extreme market conditions could negatively impact the protocol, allowing governance to proactively manage risk when waiting for natural user interactions is not viable. + +## Relationship to the Hub/Spoke Architecture + +Dynamic Risk Configuration is a spoke-level concern. The three parameters it encapsulates (Collateral Factor (CF), Liquidation Bonus (LB), and Liquidation Fee (LF)) govern how a user's collateral contributes to their health factor and how liquidation economics are computed. The Hub is unaware of these parameters; it maintains interest rate accounting, liquidity caps, and deficit state only. Spokes apply dynamic configurations independently: the same underlying asset registered on two different Spokes carries independent configuration histories and independent `dynamicConfigKey` counters. + +The implementation spans three contracts in `src/spoke/`: + +- `Spoke.sol`: exposes `addDynamicReserveConfig`, `updateDynamicReserveConfig`, and `updateUserDynamicConfig`, and implements all internal snapshot refresh logic. +- `SpokeStorage.sol`: declares the `_dynamicConfig` mapping and stores `_reserves` / `_userPositions` (whose `dynamicConfigKey` fields are defined in `ISpoke`). +- `SpokeConfigurator.sol`: provides access-controlled convenience functions for common parameter updates that delegate to the above entry points on the target Spoke. + +## Configuration Data Model + +The `DynamicReserveConfig` struct contains three fields: + +| **Field** | **Type** | **Description** | +| --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | +| `collateralFactor` | `uint16` | Proportion of a Reserve's supplied value that counts toward a user's health factor, expressed in BPS. | +| `maxLiquidationBonus` | `uint32` | Maximum extra collateral the liquidator receives per unit of debt repaid, expressed in BPS. `100_00` represents a 0% bonus. | +| `liquidationFee` | `uint16` | Protocol fee charged on liquidations, deducted from the collateral bonus before paying the liquidator, expressed in BPS. | + +The configuration history for each Reserve is stored in a nested mapping keyed first by `reserveId` and then by `dynamicConfigKey`: + +``` +mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) + internal _dynamicConfig; +``` + +Each `Reserve` struct holds a `dynamicConfigKey` field (type `uint32`) pointing to the most recently created configuration for that Reserve. Each `UserPosition` struct (one per user per reserve) holds a `dynamicConfigKey` field (type `uint32`) pointing to the configuration in use for that reserve when it is used as collateral. Health factor calculations for a user always use the configuration at the user position's snapshot key, not the Reserve's current key. + +The maximum permitted `dynamicConfigKey` is `type(uint32).max` (approximately 4.29 billion entries per Reserve). + +## Configuration Lifecycle + +Configuration entries are created by appending a new key or updated by modifying an existing one. The two operations share most structural validation, but differ in one respect: `updateDynamicReserveConfig` disallows `collateralFactor = 0`, whereas `addDynamicReserveConfig` permits it (allowing a Reserve to be added as non-collateral from the start). + +**Adding new configurations** + +`addDynamicReserveConfig` creates a new configuration entry for a Reserve. The call increments the Reserve's `dynamicConfigKey` by one, stores the provided `DynamicReserveConfig` at that new key, advances the Reserve's `dynamicConfigKey` field to reference the new entry, and emits `AddDynamicReserveConfig`. New position snapshots created after this call bind to the new key. + +Before storing, the Spoke validates three combined constraints under `InvalidCollateralFactorAndMaxLiquidationBonus`: + +- `collateralFactor` must be strictly less than `100_00` BPS. +- `maxLiquidationBonus` must be greater than or equal to `100_00` BPS. +- `percentMulUp(maxLiquidationBonus, collateralFactor)` must be strictly less than `100_00` BPS. + +The third constraint enforces that the liquidation penalty term derived from `maxLiquidationBonus` and `collateralFactor` remains strictly below 100%, which keeps downstream liquidation math well-defined. Additionally, `liquidationFee` must be at most `100_00` BPS; violations revert with `InvalidLiquidationFee`. + +A new configuration with `collateralFactor = 0` is valid under these constraints. It is used to represent a Reserve being offboarded as collateral: users who enable the Reserve as collateral after the new configuration is created receive a zero collateral factor and therefore no collateral credit. Positions that already hold a snapshot at a prior key with a non-zero collateral factor are unaffected. + +**Updating existing configurations** + +`updateDynamicReserveConfig` modifies an existing configuration entry in place at a specified `dynamicConfigKey`. It applies the same structural validation as `addDynamicReserveConfig`, with two additional checks: the target key must reference a previously initialized entry, and the `collateralFactor` in the updated configuration must be strictly greater than zero. + +The guard against setting `collateralFactor = 0` on a historical key is enforced in validation with `InvalidCollateralFactor`. If a historical key were altered to carry a zero collateral factor, all positions currently snapshotted at that key would lose collateral credit for that Reserve in health factor calculations. Additionally, liquidations read the borrower’s snapshotted dynamic configuration for the collateral reserve and require `collateralFactor > 0`; therefore that reserve could not be seized as liquidation collateral while bound to a zero-CF key. If it is the only collateral supporting outstanding debt, liquidation could be blocked until the key is updated back to `>0` or the position is migrated to a key with `collateralFactor > 0`. + +Updating an uninitialized key reverts with `DynamicConfigKeyUninitialized`, detected by checking that the stored `maxLiquidationBonus` is nonzero (all valid stored configurations carry a `maxLiquidationBonus` of at least `100_00`). + +> **Warning:** `updateDynamicReserveConfig` affects all open user positions currently snapshotted at the target key. Reducing `collateralFactor` on a historical key reduces effective collateral value for those positions and may bring them closer to the liquidation threshold. The Governor should model the impact on bound positions before executing such an update. + +The `SpokeConfigurator` exposes convenience wrappers that read the current latest configuration, replace the specified field, and call the underlying add or update function: + +- `addCollateralFactor` / `updateCollateralFactor` +- `addMaxLiquidationBonus` / `updateMaxLiquidationBonus` +- `addLiquidationFee` / `updateLiquidationFee` +- `addDynamicReserveConfig` / `updateDynamicReserveConfig` — full configuration passthrough + +## User Position Snapshots + +Each `UserPosition` stores a `dynamicConfigKey` field recording the configuration key currently bound to that user/reserve position for collateral evaluation. During health factor computation, the Spoke resolves the collateral factor for each Reserve from `_dynamicConfig[reserveId][userPosition.dynamicConfigKey].collateralFactor`. A collateral factor of zero causes the Reserve to contribute no value to the health factor regardless of the supplied amount. + +Liquidations also use the user's snapshot key when determining the `maxLiquidationBonus` applicable to the collateral being seized. The liquidation bonus is therefore governed by the configuration active at the time of the user's last risk-bearing action, not the Reserve's current active configuration. + +When a Reserve is first enabled as collateral via `setUsingAsCollateral`, the Spoke sets `userPosition.dynamicConfigKey = reserve.dynamicConfigKey` for that Reserve, binding the position to the then-current active configuration. Subsequent `addDynamicReserveConfig` calls advance the Reserve's key but leave the user position's snapshot unchanged until a refresh-triggering action occurs. + +## Snapshot Refresh Rules + +Whether a user action refreshes position snapshots depends on whether the action increases or decreases the risk the position poses to the protocol. + +**Actions that refresh all collateral Reserve snapshots** + +The following actions call `_refreshAndValidateUserAccountData`, which updates `userPosition.dynamicConfigKey` to `reserve.dynamicConfigKey` for every Reserve where the user has `usingAsCollateral` enabled, then recalculates and validates the health factor: + +- `borrow` +- `withdraw` (only when the withdrawn Reserve has `usingAsCollateral` enabled) +- `setUsingAsCollateral` when disabling a Reserve as collateral +- `updateUserDynamicConfig` + +On success, a `RefreshAllUserDynamicConfig` event is emitted. If the rebinding leaves the position under-collateralized, the call reverts with `HealthFactorBelowThreshold`, unwinding all state changes from the transaction. + +**Actions that refresh a single Reserve's snapshot** + +`setUsingAsCollateral` when enabling a Reserve as collateral calls `_refreshDynamicConfig`, which sets `userPosition.dynamicConfigKey = reserve.dynamicConfigKey` for the single Reserve being enabled and emits a `RefreshSingleUserDynamicConfig` event. + +**Actions that do not refresh snapshots** + +- `supply` +- `repay` +- `withdraw` (only when the withdrawn Reserve has `usingAsCollateral` disabled) +- `updateUserRiskPremium` +- `liquidationCall` + +These actions evaluate health and risk using the user's existing snapshot keys. For `supply` and `repay`, the user's risk exposure decreases or stays neutral, so rebinding to latest configurations is not required. For `liquidationCall`, the liquidator acts on the position as-is; the health factor check uses the borrower's current snapshots. + +## Health Factor Guard + +When any action triggers a full snapshot refresh, the Spoke applies the following sequence atomically within the same transaction, before committing final state: + +1. Iterate over all collateral Reserves in the position and set `userPosition.dynamicConfigKey = reserve.dynamicConfigKey` for each. +2. Recompute the health factor using the newly bound configurations. +3. If `healthFactor < HEALTH_FACTOR_LIQUIDATION_THRESHOLD`, revert with `HealthFactorBelowThreshold`. + +This guard prevents a user from taking a risk-increasing action while pinned to a configuration that, after rebinding, would leave the position under-collateralized. The rebind is unconditional, but if the transaction reverts due to the health factor check, the user's position remains unchanged. + +A position that is healthy under its current snapshot keys, but under-collateralized under the latest Reserve configuration is effectively blocked from further risk-increasing actions until the user either repays debt, adds collateral via `supply`, or the Governor adjusts the latest configuration to one under which the position remains solvent. + +## Governance Intervention + +`updateUserDynamicConfig` force-migrates a user position to the latest configuration for all collateral Reserves without requiring the user to perform a standard action. The function is callable by: + +- The user themselves (`onBehalfOf == msg.sender`). +- An approved Position Manager for the user. +- An authorized admin role via the Spoke's access manager. + +`updateUserDynamicConfig` runs the same full refresh and validation sequence as other risk-increasing actions: it rebinds all collateral snapshots to the current latest keys, validates health factor, updates the risk premium, and reverts with `HealthFactorBelowThreshold` if the migrated position is under-collateralized. + +The expected governance workflow when introducing tightened parameters is: + +1. Call `addDynamicReserveConfig` to publish the new configuration as the latest key for the Reserve. Existing positions retain their prior snapshot keys and are unaffected. +2. Call `updateUserDynamicConfig` on targeted positions to migrate them to the new parameters immediately. + +> **Note:** `updateUserDynamicConfig` migrates the user to whatever key is current at call time. If `addDynamicReserveConfig` is called again after migration, the user will hold a snapshot at the intermediate key until their next risk-increasing action. + +> **Note:** `updateUserDynamicConfig` updates all collateral Reserves in the position simultaneously. There is no function to migrate the snapshot for a single Reserve independently. + +## Out of Scope + +The following are explicitly excluded from the Dynamic Risk Configuration system: + +- **Hub-level accounting**: CF, LB, and LF are not visible to the Hub. The Hub operates on share math, drawn indices, and liquidity caps only. +- **Per-Reserve snapshot migration**: `updateUserDynamicConfig` updates all collateral Reserves simultaneously. Individual Reserve snapshot updates are not exposed as a separate call. +- **Collateral Risk**: The risk premium parameter (`collateralRisk`) is stored in `ReserveConfig`, not in `DynamicReserveConfig`. It governs the risk premium interest component and is updated independently via `updateReserveConfig`. Dynamic configuration updates do not alter `collateralRisk`. +- **Liquidation execution logic**: Dynamic configurations supply the parameters used in the liquidation flow. The liquidation execution itself is documented separately. +- **Interest rate strategy**: Utilization-based borrow rates are a hub-level concern and are unaffected by `DynamicReserveConfig`. +- **Cross-Spoke configuration sharing**: Each Spoke maintains its own `_dynamicConfig` mapping and independent key counters. Two Spokes connected to the same Hub asset do not share configuration history. + +## Key Differences from Aave V3 + +**Single global configuration per asset**: In Aave V3, each asset carries exactly one risk configuration record. A governance update to Loan to Value, Liquidation Threshold, or Liquidation Bonus takes effect immediately for every open position borrowing against that asset. There is no mechanism to stage or scope the change to new positions only. + +**Immediate liquidation exposure from parameter changes**: Because Aave V3 applies updates globally, reducing the Liquidation Threshold for a widely used asset can bring a large number of positions below the liquidation threshold simultaneously. Governance must either accept this risk or execute parameter changes in small increments across multiple proposals, increasing operational overhead. + +**Aave V4 versioned configurations**: Aave V4 maintains a per-Reserve configuration history keyed by a monotonically incrementing `uint32`. Parameter updates create new entries; existing positions retain their snapshot keys until the user performs a risk-increasing action. This gives users the opportunity to adjust their positions before being subject to new parameters, and limits the immediate liquidation surface to positions that voluntarily take on new risk after the update is published. diff --git a/docs/features/EfficiencyMode.md b/docs/features/EfficiencyMode.md new file mode 100644 index 000000000..bc49d89de --- /dev/null +++ b/docs/features/EfficiencyMode.md @@ -0,0 +1,48 @@ +# Efficiency Mode + +## Summary + +Efficiency Mode (E-Mode) lets users opt a position into a category of correlated assets (e.g., stablecoins, or ETH-correlated liquid staking tokens) that carries its own Loan-to-Value (LTV), Liquidation Threshold (LT), Liquidation Bonus, and restricted borrowable set, tuned tighter than the general market because the assets in the category have correlated price movements. Liquid E-Mode extends this further by allowing a single asset to belong to multiple categories simultaneously, so the best available category can be selected per position rather than per asset. + +Aave V4 has no dedicated E-Mode feature, account-level category flag, or category registry. The same functional outcome is achieved entirely through the existing Hub/Spoke architecture and the Dynamic Risk Configuration primitives described in [Isolation Mode](./IsolationMode.md) and [Dynamic Risk Configuration](./DynamicConfiguration.md): a category is simply a Spoke whose Reserves are configured with category-tailored `DynamicReserveConfig` parameters and a `borrowable` set restricted to the category's assets. Users select a category by choosing which Spoke they supply and borrow through, rather than by toggling a category identifier on their account. + +## Relationship to the Hub/Spoke Architecture + +Efficiency Mode, like Isolation Mode and Siloed Borrowing, is not a contract-level primitive but a configuration pattern layered on top of ordinary Spokes. A "category" is a Spoke listing Reserves for the correlated assets that belong to it (e.g., a Stablecoins Spoke listing USDC, USDT, and GHO), each configured with a `collateralFactor`, `maxLiquidationBonus`, and `liquidationFee` in `DynamicReserveConfig` that reflect the tighter risk profile justified by the assets' correlation, and each Reserve's `borrowable` flag restricted to the assets the Governor wants borrowable within that category. + +Because `Reserve.hub` is set independently per Reserve, the same underlying Hub asset can be listed as a Reserve in the Canonical Spoke under standard parameters and, at the same time, as a Reserve in one or more Spokes configured for a correlated-asset set under tighter, category-specific parameters. Each Spoke tracks its own draw and add caps against the Hub via `SpokeData.drawCap`, so exposure through such a Spoke is accounted independently of exposure through the Canonical Spoke or any other Spoke, even when they reference the same Hub asset. + +## Category Configuration + +Configuring a Spoke to serve as an E-Mode category uses the same primitives documented elsewhere, applied together: + +- **Collateral and liquidation parameters**: Each Reserve's `collateralFactor`, `maxLiquidationBonus`, and `liquidationFee` are set via `addDynamicReserveConfig` / `updateDynamicReserveConfig`, the same Dynamic Risk Configuration mechanism used protocol-wide. Such a Spoke simply uses values calibrated for the correlated asset set (e.g., a higher `collateralFactor` for stablecoin-against-stablecoin borrowing than the Canonical Spoke would allow) rather than a value specific to some separate "E-Mode" config type. +- **Borrowable restriction**: The Governor sets `borrowable = true` only on the Reserves meant to be borrowable within the category, using the same `borrowable` flag described in [Siloed Borrowing](./SiloedBorrowing.md) and [Isolation Mode](./IsolationMode.md). Attempting to borrow a Reserve with `borrowable = false` reverts with `ReserveNotBorrowable`. +- **Exposure ceilings**: Per-asset draw caps on the Spoke, enforced at the Hub via `SpokeData.drawCap` and configured through `HubConfigurator.updateSpokeDrawCap`, bound how much of each borrowable asset the category can draw, independent of any cap the same asset carries in the Canonical Spoke or other Spokes. + +Aave V4 does not distinguish between a single Loan-to-Value and a separate Liquidation Threshold the way Aave V3 does. A single `collateralFactor` per Reserve per `dynamicConfigKey` serves both roles; there is no separate LT field to configure for a category. + +## Choosing a Category + +Because the category boundary is a Spoke rather than an account flag, there is no explicit "enable E-Mode" call in Aave V4. A user's category is implicit in which Spoke their `supply` and `borrow` calls target: routing a transaction to a Stablecoins Spoke is functionally equivalent to activating the Stablecoins E-Mode category in Aave V3, and no separate opt-in transaction is required beyond the ordinary `supply`/`borrow` calls to that Spoke. + +Because positions in different Spokes are independent (the same relationship documented in Isolation Mode and Siloed Borrowing), a single wallet can hold a position in a Spoke configured for one category and, at the same time, an entirely separate position in the Canonical Spoke or a Spoke configured for another category. This reproduces the effect of Liquid E-Mode, where an asset (and by extension a user's exposure to it) is not confined to a single category: rather than one asset carrying membership in several categories simultaneously, Aave V4 lists that asset as a Reserve in each Spoke whose parameters should apply to it, and the user picks the applicable parameter set by picking the Spoke. Before supplying or borrowing through such a Spoke, users should review that Spoke's Reserve list, collateral factors, and borrowable set, since these are configured independently per category and can differ meaningfully from the Canonical Spoke. + +## Out of Scope + +The following are explicitly excluded from Efficiency Mode as a configuration pattern: + +- **Account-level category state**: There is no per-user category identifier, no enable/disable call, and no on-chain concept of a user being "in" a category. The pattern is realized entirely through which Spoke a position lives in. +- **Automatic single-category enforcement**: Nothing in the contracts prevents a user from holding positions in multiple Spokes each configured for a different category, or in one such Spoke and the Canonical Spoke, at the same time. +- **Cross-Spoke health factor aggregation**: Collateral and debt held through a Spoke configured for a category are evaluated independently of collateral and debt held in any other Spoke. There is no combined health factor across categories. +- **Dedicated E-Mode liquidation bonus mechanism**: Liquidations against category positions use the same Liquidation Engine as any other Spoke; only the `DynamicReserveConfig` parameters differ. + +## Key Differences from Aave V3 + +**No dedicated category primitive**: Aave V3 tracks an explicit E-Mode category id per user and per asset, with dedicated storage and `setUserEMode` / category-admin entry points. Aave V4 has no equivalent state or function; the same outcome follows from ordinary Spoke and Dynamic Risk Configuration primitives that already exist for other purposes. + +**Spoke-scoped vs. account-scoped selection**: In Aave V3, E-Mode is a flag on the user's account: activating a category restricts borrowing across the entire account to that category's assets, and only one category can be active at a time. In Aave V4, the "category" is a property of the Spoke, not the account. A wallet can simultaneously hold a position in a Spoke configured for one category and a different position in a Spoke configured for another category (or the Canonical Spoke), each independently accounted, with no single active category and no account-wide restriction. + +**Single collateral factor vs. separate LTV/LT**: Aave V3 E-Mode categories configure a separate LTV and Liquidation Threshold. Aave V4's Dynamic Risk Configuration uses a single `collateralFactor` per Reserve per `dynamicConfigKey` that serves both roles. + +**Structural multi-category assets vs. liquid category membership**: Aave V3's Liquid E-Mode allows one asset to be declared a member of several categories, with the user's active category determining which parameter set applies. Aave V4 achieves the same flexibility structurally: the same underlying asset can be listed as a Reserve in several Spokes at once, each with independently configured parameters, and the user selects the applicable parameters by choosing which Spoke to use rather than by the asset declaring category membership. diff --git a/docs/features/IsolationMode.md b/docs/features/IsolationMode.md new file mode 100644 index 000000000..eb3ba3bd6 --- /dev/null +++ b/docs/features/IsolationMode.md @@ -0,0 +1,50 @@ +# Isolation Mode + +## Summary + +Isolation Mode enables new collateral assets to be added to the protocol with contained risk by registering them in a dedicated Spoke and Hub pair separate from the canonical market. Users who supply an isolated collateral asset in this Spoke can only borrow assets configured as borrowable for that market, up to per-asset draw caps set by the Governor. Because the isolated market lives in its own Spoke, users can continue to hold unrestricted positions in the Canonical Spoke and any other Spoke simultaneously. + +Unlike Aave V3, which enforced a single aggregated borrow ceiling per isolated market and locked the borrower to one isolated collateral asset per account, Aave V4 assigns draw caps independently per asset and permits any number of assets to be configured as collateral within the isolated Spoke. The risk boundary is structural: it is scoped to the Spoke, not to the user's account. + +## Relationship to the Hub/Spoke Architecture + +Isolation Mode is not a contract-level primitive but a configuration pattern applied to a Spoke and its associated Hubs. Any Spoke becomes an isolated market when its Reserves are configured with a restricted borrowable set and the Hub enforces per-asset draw caps on that Spoke. + +The enforcement point for the borrow ceiling is `SpokeData.drawCap` stored on the Hub, keyed by `(assetId, spoke)` so each asset has its own draw cap per Spoke. When a Spoke calls `Hub.draw`, the Hub checks that the Spoke's total owed (drawn plus premium), any reported deficit, and the requested draw amount would not exceed `drawCap`. A draw that would exceed the cap reverts with `DrawCapExceeded`. The cap is expressed in whole assets, not scaled by decimals, stored as `uint40` within `SpokeData`. A value of `MAX_ALLOWED_SPOKE_CAP` disables the ceiling. + +Each Reserve on a Spoke references a specific Hub through `Reserve.hub`. This means a single Spoke can draw liquidity from multiple Hubs, each subject to its own draw cap per asset. Draw caps are configured via `HubConfigurator.updateSpokeDrawCap` by governance-authorized roles (AccessManaged `restricted` access). + +## Collateral and Borrow Configuration + +The collateral set in an isolated market is defined by which Reserves on the Spoke carry a non-zero `collateralFactor` in their `DynamicReserveConfig`. A Reserve with `collateralFactor > 0` contributes to a user's health factor when the user has activated it as collateral via `setUsingAsCollateral`. Any number of Reserves can be configured this way within the isolated Spoke. + +The borrowable set is controlled by the `borrowable` flag in `ReserveConfig`. Reserves with `borrowable = false` reject borrow requests; the Spoke reverts with `ReserveNotBorrowable`. The Governor designates borrowable assets by setting `borrowable = true` on selected Reserves. + +A draw cap on the Hub supplements the `borrowable` flag. The `borrowable` flag gates access at the Spoke level; the draw cap enforces an absolute ceiling on how much the Spoke may draw from a given Hub asset. Both constraints must be satisfied for a borrow to succeed. + +## Bootstrapping Liquidity + +A new isolated market initially has no native liquidity. Without liquidity there are no borrowers, and without borrowers, suppliers earn no competitive rates. Aave V4 addresses this by allowing a Reserve in the isolated Spoke to reference the Canonical Hub rather than the new isolated Hub. + +For instance, to bootstrap USDC liquidity, USDC from the Canonical Hub is listed as an additional Reserve (USDC') on the isolated Spoke with `Reserve.hub` pointing to the Canonical Hub. The Governor sets a draw cap on USDC' in the Canonical Hub, limiting total exposure of canonical USDC suppliers to the isolated market. Borrowers in the isolated Spoke draw USDC' from the Canonical Hub up to this cap. + +Once the isolated Hub has accumulated sufficient native USDC liquidity, the Governor sets the draw cap for USDC' to zero. Existing USDC' borrow positions can still be repaid, but no new borrows are possible. Users then borrow USDC directly from the isolated Hub. At this point the credit line is wound down and the isolated market is self-sustaining. + +## Out of Scope + +The following are explicitly excluded from Isolation Mode as a configuration pattern: + +- **Account-level enforcement**: Isolation Mode is a spoke-level pattern, not an account-level lock. The contracts do not prevent a user from holding positions in the isolated Spoke, the Canonical Spoke, or any other Spoke simultaneously. +- **Cross-Spoke health factor aggregation**: Collateral and debt in the isolated Spoke are accounted for independently. Health factors are not aggregated across Spokes. + +## Key Differences from Aave V3 + +**Spoke-scoped isolation**: In Aave V3, Isolation Mode applies at the account level. A user who supplies an isolated collateral asset cannot use any other asset as collateral in that account. If non-isolated collateral is already active, the isolated asset cannot be added until it is removed. In Aave V4, Isolation Mode is spoke-scoped: the user is isolated within the isolated Spoke but can hold unrestricted positions in any other Spoke at the same time. This also means a single account can participate in multiple isolated markets simultaneously. For example, supplying collateral in one isolated Spoke while also borrowing against collateral in a separate isolated Spoke, with neither position affecting the other. + +**Multiple isolated collaterals**: In Aave V3, only one isolated asset can serve as collateral per account. In Aave V4, any number of Reserves in the isolated Spoke can be configured with a non-zero `collateralFactor`, making them eligible as collateral simultaneously. + +**Per-asset draw caps**: Aave V3 enforces a single aggregated borrow ceiling shared across all borrowable assets in the isolated market. A 10,000 USD cap shared among USDC, USDT, and GHO allows a single asset to consume the entire budget, blocking all others. Aave V4 assigns draw caps independently per asset: a 3,000 USDC draw cap, 3,000 USDT draw cap, and 4,000 GHO draw cap each enforce a distinct limit so no single asset crowds out the rest. + +**Unrestricted borrowable assets**: In Aave V3, Isolation Mode restricts borrowing to a governance-approved list of stablecoins. In Aave V4, any asset can be marked `borrowable = true` in the isolated Spoke; the Governor defines the composition without a stablecoin-only constraint. + +**Bootstrapped liquidity**: In Aave V3, an isolated market cannot draw liquidity from the canonical market. In Aave V4, a Reserve in the isolated Spoke can reference the Canonical Hub directly through `Reserve.hub` and draw from it under a draw cap, enabling the isolated market to operate while native liquidity grows. diff --git a/docs/features/LiquidationEngine.md b/docs/features/LiquidationEngine.md new file mode 100644 index 000000000..55142479c --- /dev/null +++ b/docs/features/LiquidationEngine.md @@ -0,0 +1,168 @@ +# Liquidation Engine + +## Summary + +The Liquidation Engine determines when and how under-collateralized positions are closed in Aave V4. It replaces the fixed close-factor logic of Aave V3 with a target-health-factor approach: liquidation sizing is target-health-factor-driven, but the final repaid amount is also constrained by `debtToCover`, dust-prevention adjustments, and collateral availability, preventing the over-liquidation inherent in Aave V3's 50% close factor. A Dutch-auction-style variable bonus incentivizes timely intervention by increasing linearly as the borrower's health factor falls. Dust-prevention logic, rounding rules designed to neutralize griefing, and a deficit-reporting path cover the edge cases that arise from rounding effects and partial liquidations. + +The engine operates on both drawn debt (principal borrowed from the Hub) and premium debt (additional interest from the user's risk premium). It settles collateral to the liquidator either as underlying assets or, when `receiveSharesEnabled` is active and the liquidator opts in, as hub-added shares. + +## Relationship to the Hub/Spoke Architecture + +Liquidation logic is implemented in `LiquidationLogic.sol`, an external library loaded by the Spoke. The Spoke exposes `liquidationCall` to callers; internally it reads the borrower's account data from Spoke storage, assembles a `LiquidateUserParams` struct, and delegates to `LiquidationLogic.liquidateUser`. + +Within the library, execution can involve up to three Hub interactions, depending on path and amounts. Collateral settlement executes before debt settlement: + +1. `Hub.remove` on the collateral Hub: when `receiveShares = false`, collateral is removed and transferred to the liquidator. When `receiveShares = true`, shares are credited directly to the liquidator's Spoke position without a `Hub.remove` call. +2. `Hub.payFeeShares` on the collateral Hub: if fee shares are non-zero, the protocol fee portion is transferred to the fee receiver as shares. +3. `Hub.restore` on the debt Hub: the liquidator's repayment is pulled via `safeTransferFrom` and credited, retiring drawn and premium shares. + +Each Reserve references its own Hub through `Reserve.hub`, so the collateral and debt Reserves in a single liquidation can reference different Hubs. Spoke-wide liquidation parameters (`targetHealthFactor`, `healthFactorForMaxBonus`, `liquidationBonusFactor`) are stored in `LiquidationConfig`. Per-reserve parameters (`maxLiquidationBonus`, `liquidationFee`) are stored in `DynamicReserveConfig`, allowing different collateral assets within the same Spoke to carry different bonus ceilings and fee rates. + +## Parameters and Configuration + +Liquidation behavior is controlled by spoke-wide parameters stored in `LiquidationConfig` and per-reserve parameters stored in `DynamicReserveConfig`. + +| **Parameter** | **Description** | **Constraints** | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `targetHealthFactor` | Spoke-wide HF target (WAD) used to compute debt-to-target during liquidation. Actual post-liquidation HF can differ due to `debtToCover`, collateral exhaustion, dust-handling adjustments, and rounding effects. | Must be ≥ `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. | +| `DUST_LIQUIDATION_THRESHOLD` | Hard-coded threshold below which leftover debt or collateral qualifies as dust. Liquidation amounts are adjusted to avoid leaving dust on either side unless the opposite side is fully exhausted (including debt extension and collateral-exhaustion recomputation paths). | Hard-coded constant set to `$1_000` in protocol value units. | +| `maxLiquidationBonus` | Per-collateral maximum bonus, expressed in BPS. A value of `105_00` means the liquidator receives collateral worth 5% more in base currency than the debt repaid. | Must be ≥ `100_00`, and must satisfy the reserve-level solvency invariant with `collateralFactor` (`maxLiquidationBonus.percentMulUp(collateralFactor) < 100_00`). | +| `healthFactorForMaxBonus` | Spoke-wide HF threshold below which the maximum bonus applies, expressed in WAD. | Must be < `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. | +| `liquidationBonusFactor` | Spoke-wide percentage applied to the effective bonus (the part of `maxLiquidationBonus` above 100%) to compute the minimum bonus at `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. Formula: `minLB = (maxLB - 100%) × lbFactor + 100%`. Expressed in BPS. | Must be ≤ `100_00`. | +| `liquidationFee` | Per-collateral protocol fee expressed as a percentage of the effective bonus (the collateral bonus above 100%), in BPS. Fee shares are transferred to the Hub fee receiver via `Hub.payFeeShares`. | Must be ≤ `100_00`. | +| `receiveSharesEnabled` | Per-reserve flag. When `true` and the Reserve is not frozen, liquidators can opt to receive collateral as hub-added shares instead of underlying assets by passing `receiveShares = true` to `liquidationCall`. | `true` or `false`. | +| `riskPremiumThreshold` | Per-Spoke, per-asset Hub parameter limiting the maximum ratio of premium shares to drawn shares a Spoke can hold, expressed in BPS. If configured too strictly, premium share increases that arise when safer collateral is liquidated can cause the post-liquidation `Hub.refreshPremium` call to revert with `InvalidPremiumChange`, reverting the liquidation transaction. A value of `MAX_RISK_PREMIUM_THRESHOLD` disables the constraint. | Configured via `HubConfigurator.updateSpokeRiskPremiumThreshold`. | + +## Eligibility and Validation + +Any address can call `liquidationCall` on a Spoke to initiate a liquidation. Self-liquidation reverts with `SelfLiquidation`. Before computing amounts, the engine validates: + +- `debtToCover` is non-zero; otherwise reverts with `InvalidDebtToCover`. +- Neither the collateral Reserve nor the debt Reserve is paused; violation reverts with `ReservePaused`. (Frozen Reserves, unlike paused ones, can be liquidated.) +- The borrower holds supply in the target collateral Reserve; otherwise reverts with `ReserveNotSupplied`. +- The borrower holds active drawn shares in the target debt Reserve; otherwise reverts with `ReserveNotBorrowed`. +- The borrower's health factor is strictly below `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`; otherwise reverts with `HealthFactorNotBelowThreshold`. +- The collateral Reserve has `collateralFactor > 0` and the borrower has `usingAsCollateral` enabled for it; otherwise reverts with `ReserveNotEnabledAsCollateral`. +- If the liquidator passes `receiveShares = true`, the collateral Reserve must not be frozen and must have `receiveSharesEnabled = true`; otherwise reverts with `CannotReceiveShares`. + +Frozen Reserves and collateral with `receiveSharesEnabled = false` (even when not frozen) can be liquidated, but cannot be received as shares. Liquidators can always settle frozen collateral as underlying by passing `receiveShares = false` (the default). The per-reserve `receiveSharesEnabled` flag gives the Governor finer control than `frozen`, e.g. when the Hub asset is paused. Paused Reserves in the borrower's position that are not the specific collateral or debt target being liquidated do not block the call. + +## Liquidation Process + +A liquidation executes in six sequential steps. + +1. **Compute account data**: The Spoke calculates the borrower's health factor, total debt value (RAY-scaled), total collateral value (counting Reserves with `collateralFactor > 0`, `usingAsCollateral` enabled, and `suppliedShares > 0`), and the counts of active collateral and borrow Reserves. + +2. **Compute debt to liquidate**: The engine determines how much debt must be repaid to restore the borrower to `targetHealthFactor`, using the following formula derived from the health factor definition and the liquidation bonus: + +$$l = \frac{D \cdot (THF - HF)}{THF - lb \cdot cf}$$ + +- $l$ is the debt to liquidate in base currency +- $D$ is the user's total debt value in base currency +- $THF$ is `targetHealthFactor` +- $HF$ is the borrower's health factor before liquidation +- $lb$ is the liquidation bonus from the Dutch-auction formula +- $cf$ is the collateral factor of the collateral being seized + +3. **Adjust for dust**: If the debt remaining after a standard liquidation falls below `DUST_LIQUIDATION_THRESHOLD`, the engine extends `maxDebtToLiquidate` to cover the full debt position in the target Reserve. The liquidator's `debtToCover` input must be large enough to cover the extended amount; otherwise the transaction reverts with `MustNotLeaveDust`. Debt dust may still remain if the collateral Reserve is fully exhausted before the debt is fully covered. + +4. **Compute collateral to seize**: The debt to liquidate is converted into collateral units at the current oracle price and scaled by the liquidation bonus. If the computed collateral exceeds available supply, all remaining collateral is seized and debt-to-liquidate is recomputed downward from available collateral. Separately, if remaining collateral would fall below `DUST_LIQUIDATION_THRESHOLD` and drawn shares would remain in the target debt Reserve after liquidation, all collateral is seized and debt-to-liquidate is increased to fully consume the collateral (potentially bypassing the target health factor). The liquidator's `debtToCover` must be large enough to cover this increased amount; otherwise the transaction reverts with `MustNotLeaveDust`. + +5. **Settle collateral and repay debt**: Collateral shares are deducted from the borrower. The net collateral goes to the liquidator via `Hub.remove` (underlying assets) or is credited directly as added shares in the liquidator's Spoke position (`receiveShares = true` path). Fee shares (the `liquidationFee` fraction of the effective bonus) are sent to the fee receiver via `Hub.payFeeShares`. Finally, the liquidator's repayment is pulled via `safeTransferFrom` to the debt Hub, which credits the position via `Hub.restore`. + +6. **Evaluate deficit**: The engine calls `_evaluateDeficit` after execution. If the collateral position is fully emptied, it was the borrower's only active collateral, and debt still remains in any Reserve, the Spoke invokes `notifyReportDeficit`. This iterates all remaining debt Reserves, calls `Hub.reportDeficit` for each, zeroes drawn and premium shares, and resets the borrower's risk premium to zero. A `ReportDeficit` event is emitted per Reserve and `UpdateUserRiskPremium` is emitted with value zero. + +## Dutch-Auction Style Liquidation Bonus + +The liquidation bonus varies linearly with the borrower's health factor. Once a position becomes liquidatable, the minimum bonus is: + +$$minLB = (maxLB - 100\%) \times lbFactor + 100\%$$ + +- $maxLB$ is `maxLiquidationBonus` for the collateral Reserve, as a ratio (e.g., 1.05 for `105_00`) +- $lbFactor$ is `liquidationBonusFactor` for the Spoke, as a ratio (e.g., 0.80 for `80_00`) + +For a liquidatable position, the bonus is: + +$$lb = \begin{cases} maxLB & \text{if } HF \le hfForMaxBonus \\ minLB + (maxLB - minLB) \times \dfrac{HF\_LIQ - HF}{HF\_LIQ - hfForMaxBonus} & \text{if } HF > hfForMaxBonus \end{cases}$$ + +- $HF\_LIQ$ is `HEALTH_FACTOR_LIQUIDATION_THRESHOLD` (equals 1) +- $hfForMaxBonus$ is `healthFactorForMaxBonus` + +**Example 1:** `maxLiquidationBonus = 105_00`, `liquidationBonusFactor = 80_00`, `healthFactorForMaxBonus = 0.85e18`. At `HF = 0.95` (between the liquidation threshold and the max-bonus boundary): + +$minLB = (1.05 - 1.00) \times 0.80 + 1.00 = 1.04$ + +$lb = 1.04 + (1.05 - 1.04) \times \frac{1.00 - 0.95}{1.00 - 0.85} = 1.04 + 0.01 \times \frac{1}{3} \approx 1.0433$ + +**Example 2:** At `HF = 0.80 ≤ 0.85`, the liquidator earns the full `maxLiquidationBonus = 105_00`, a 5% collateral surplus over the debt repaid in base currency. + +**Protocol fee**: `liquidationFee` is applied to the effective bonus only, not to the full collateral seized. A `liquidationFee = 10_00` with a 5% effective bonus yields a 0.5% protocol fee, giving the liquidator a net 4.5% collateral bonus. + +## Preventing Liquidation Splits + +The variable bonus creates an incentive risk: if a partial liquidation lowers the borrower's health factor, a liquidator could split a single liquidation into multiple smaller calls, obtaining a higher bonus on each subsequent call. This increases the probability of bad debt and harms the borrower. + +A partial liquidation decreases health factor when $lb_k \times cf_k > HF_{beforeLiq}$, where $lb_k$ is the bonus and $cf_k$ is the collateral factor for the collateral seized. It increases or preserves health factor when $lb_k \times cf_k \le HF_{beforeLiq}$. + +The Dutch-auction interpolation region is $HF > hfForMaxBonus$. In that region, $lb_k \le maxLB_k$ (strictly `<` unless `liquidationBonusFactor = 100_00`, where `minLB = maxLB` and the bonus is flat). If the Governor enforces: + +$$maxLB_i \times cf_i \le hfForMaxBonus, \quad \forall i$$ + +then whenever the Dutch-auction interpolation region applies ($HF > hfForMaxBonus$), it holds that $lb_k \times cf_k \le hfForMaxBonus < HF_{beforeLiq}$, guaranteeing health factor improvement after every liquidation. When $HF \le hfForMaxBonus$, the full $maxLB_k$ applies uniformly regardless of health factor, so there is no incremental incentive to split. Under this condition, liquidation splits are never profitable. + +The Governor must verify this condition for every collateral across all active `DynamicReserveConfig` keys. Because `hfForMaxBonus` is spoke-wide while `maxLiquidationBonus` and `collateralFactor` are per-reserve and per-config-key, changes to any of these values must be validated against the condition before they are applied. + +## Dust and Rounding + +Dust prevention and rounding direction are enforced independently within the liquidation flow. + +**Dust prevention**: The engine prevents leaving sub-threshold dust on either side unless the opposite side is fully exhausted. If remaining debt would be dust, liquidation is extended to full debt; if remaining collateral would be dust, liquidation is adjusted to fully consume collateral (or otherwise satisfy dust constraints). Calls that do not provide enough `debtToCover` for these dust-safe bounds revert with `MustNotLeaveDust`. Dust can still remain on one side when the other side is fully exhausted. + +**Rounding direction**: The engine rounds against the liquidator at every step to prevent value extraction via repeated small liquidations: + +- **Collateral rounds down**: collateral shares are computed from debt-to-liquidate using floor division, giving the liquidator slightly fewer collateral units. +- **Debt rounds up**: drawn shares are derived from debt-to-liquidate by dividing by the drawn index with ceiling division (`divUp`); the corresponding debt amount used in validation is computed with rounding-up multiplication (`rayMulUp`). Both round against the liquidator. + +This asymmetry means very small positions (a few cents in value) may become unprofitable to liquidate even at zero gas cost. A cleanup liquidator mechanism should be considered for such positions. + +**Zero-collateral liquidations**: Because collateral is computed from debt-to-liquidate using floor division, it is possible for a liquidation to repay a non-zero amount of debt yet yield zero collateral shares. This does not cause a revert regardless of whether `receiveShares` is `true` or `false`: the `Hub.remove` path transfers zero underlying, and the share-credit path adds zero shares to the liquidator's position. The liquidator pays for debt repayment and receives nothing in return. This can occur with very small debt amounts where the debt-to-collateral conversion rounds down to zero. + +**Two-wei premium effect and rounding asymmetry**: Premium accounting updates during `Hub.restore` can leave up to two wei of instantaneous premium debt due to premium-share/premium-offset rounding. Additionally, the systematic rounding asymmetry (collateral rounds down against liquidator, debt rounds up against liquidator) causes post-liquidation HF to typically exceed `targetHealthFactor` slightly rather than equaling it. HF remains below target when the computed debt-to-target cannot be fully achieved due to liquidation bounds (for example insufficient `debtToCover`, collateral exhaustion, or target debt-reserve exhaustion). Dust-prevention overrides can move post-liquidation HF further away from the exact target and may worsen it when `lb * cf > HF_before`. + +## Premium Debt in Liquidations + +Both drawn debt and premium debt are settled in a single atomic operation. Premium debt is liquidated first: the engine computes `premiumDebtRayToLiquidate` and subtracts it from the borrower's premium obligation before reducing drawn shares. + +If the liquidated collateral was lower-risk than the collateral remaining in the position, the borrower's effective risk premium increases after the liquidation, causing premium shares on the Hub to rise. If `riskPremiumThreshold` is set below the post-liquidation premium-to-drawn ratio, the post-liquidation `Hub.refreshPremium` call reverts with `InvalidPremiumChange`, causing the liquidation transaction to revert. The Governor must set `riskPremiumThreshold` with sufficient headroom to accommodate the premium increases that naturally arise when safer collateral is partially liquidated while riskier collateral remains. + +## Deficit Reporting and Elimination + +Deficit arises when a liquidation fully exhausts the borrower's last active collateral Reserve and debt remains. The Spoke reports it to the Hub; any authorized active Spoke can eliminate it. + +**Deficit condition**: After liquidating the target collateral Reserve and repaying the target debt, the engine evaluates deficit via `_evaluateDeficit`. Deficit is signaled when the collateral position is fully emptied, `activeCollateralCount` was 1 (it was the borrower's only active collateral), and debt remains in at least one Reserve (the target debt Reserve or any other). + +**Reporting**: `notifyReportDeficit` iterates all of the borrower's remaining debt Reserves, calls `Hub.reportDeficit` for each outstanding debt position, applies the corresponding premium delta to clear premium accounting, zeroes drawn and premium shares, and clears the borrow flag per Reserve. The borrower's risk premium is reset to zero. Deficit reporting proceeds even when the Spoke is halted, as long as it remains active. + +**Griefing via dust collateral**: Deficit is only triggered when `activeCollateralCount == 1` and the target collateral position is fully emptied. A borrower can prevent deficit reporting by maintaining a dust supply in a second collateral Reserve, keeping `activeCollateralCount > 1`. This blocks protocol recovery from bad debt. Possible mitigations include enforcing minimum collateral thresholds or extending deficit logic to treat collateral below a value threshold (e.g. `DUST_LIQUIDATION_THRESHOLD`) as inactive for deficit evaluation. + +**Elimination**: Any authorized active Spoke can call `Hub.eliminateDeficit` to use its own added shares to cover another Spoke's reported deficit for a given asset. The calling Spoke must hold sufficient added shares. Deficit elimination is permitted even when the eliminating Spoke is halted. + +## Out of Scope + +The following are explicitly excluded from the Liquidation Engine: + +- **Cross-Spoke liquidations**: Each `liquidationCall` operates entirely within a single Spoke. A borrower's positions in other Spokes are not affected and cannot be targeted in the same call. +- **Multi-reserve batch liquidation**: A single `liquidationCall` targets exactly one collateral Reserve and one debt Reserve. Liquidating multiple debt or collateral Reserves requires separate calls. +- **Flash-loan integration**: The Liquidation Engine provides no native flash-loan or callback mechanism for liquidators. External flash-loan providers operate independently. + +## Key Differences from Aave V3 + +**Target Health Factor vs. Close Factor**: In Aave V3, the default close factor is 50%, rising to 100% when the borrower's HF falls below 0.95 or the position falls below a base currency threshold. Aave V4 removes the default close factor entirely: liquidation sizing is target-health-factor-driven, but the final repaid amount is also constrained by `debtToCover`, dust-prevention adjustments, and collateral availability, preventing the systematic over-liquidation of Aave V3 while maintaining protocol safety. + +**Variable Liquidation Bonus**: Aave V3 applies a static per-reserve bonus regardless of health factor. Aave V4 introduces the Dutch-auction bonus: it varies linearly with health factor. When HF ≤ `healthFactorForMaxBonus`, the liquidator receives the maximum bonus. When HF is between `healthFactorForMaxBonus` and `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`, the bonus interpolates linearly from maximum to minimum. Lower health factors earn higher bonuses, incentivizing faster intervention as a position deteriorates. + +**Dynamic Dust Handling**: Aave V3 reverts when remaining collateral or debt falls below a dust threshold with no cleanup path. Aave V4 dynamically extends the maximum liquidatable amounts to prevent both debt and collateral dust, while dust may still remain on either Reserve when the counterpart Reserve is fully exhausted first. + +**Share-based Collateral Settlement**: Aave V3 always transfers underlying assets to liquidators. Aave V4 introduces the `receiveShares` path: when `receiveSharesEnabled` is true and the Reserve is not frozen, liquidators can receive hub-added shares directly, accruing yield without an asset transfer. This enables capital-efficient liquidation in low-liquidity environments. + +**Premium Debt Settlement**: Aave V3 liquidations operate solely on principal drawn debt. Aave V4 liquidations simultaneously settle drawn debt and the associated premium debt, repaying the premium obligation atomically alongside the principal. diff --git a/docs/features/PositionManager.md b/docs/features/PositionManager.md new file mode 100644 index 000000000..3505a0b2a --- /dev/null +++ b/docs/features/PositionManager.md @@ -0,0 +1,152 @@ +# PositionManager + +## Summary + +Position Managers are trusted periphery contracts that operate on Aave V4 Spokes on behalf of users. They enable supply, repay, withdraw, borrow, and configuration actions to be delegated to third-party entities without requiring users to cede persistent custody of funds. Spoke-level Position Manager approval is a simple on/off authorization per (Spoke, user, PositionManager). Finer scoping, where present, is implemented inside specific Position Managers via per-reserve allowances, bitmapped config permissions, or EIP-712 signed intents. The specialized managers (`GiverPositionManager`, `TakerPositionManager`, and `ConfigPositionManager`) each encode a narrow delegation scope (inflow, outflow, or configuration), while gateways provide signature-oriented and native-asset-oriented execution paths. The architecture replaces Aave V3's aToken allowance, credit delegation signature, and `WrappedTokenGatewayV3` (native-asset gateway) patterns with a more expressive, auditable delegation model designed to support lending aggregators, automated strategies, and protocol-to-protocol integrations. + +## Relationship to the Hub/Spoke Architecture + +Spokes in Aave V4 expose supply, borrow, withdraw, repay, and liquidation entry points. Actions that mutate a position on behalf of another user (e.g., `supply`, `withdraw`, `borrow`, `repay`, `setUsingAsCollateral`) enforce an `onBehalfOf` restriction: a caller can only act on its own position unless it has been explicitly approved as a Position Manager by the target user and activated by Spoke governance/admins. Position Managers sit at this boundary. They are not core protocol contracts and are not designed as custodial vaults. However, some flows may hold assets transiently within a transaction before forwarding to or from the Hub. A Spoke treats an address as a Position Manager for a user only if (i) the Position Manager-side Spoke allowlist gate (`onlyRegisteredSpoke`) is satisfied, (ii) the Spoke-side activation gate (`active=true`) is set, and (iii) the user approval gate is satisfied. The Spoke enforces this gate at the call site and otherwise does not attempt to interpret or restrict the Position Manager’s internal policy. + +The Position Manager system is implemented in `src/position-manager/` and consists of two abstract base contracts (`PositionManagerBase` and `PositionManagerIntentBase`), two gateway contracts (`NativeTokenGateway`, `SignatureGateway`), and three specialized Position Manager contracts (`GiverPositionManager`, `TakerPositionManager`, `ConfigPositionManager`). `NativeTokenGateway` and `GiverPositionManager` inherit `PositionManagerBase` directly. `SignatureGateway`, `TakerPositionManager`, and `ConfigPositionManager` inherit `PositionManagerIntentBase`, which extends `PositionManagerBase` with EIP-712 typed-intent verification. Spoke-side activation of Position Managers (gate 2) is managed externally via `SpokeConfigurator`, which exposes `updatePositionManager` to set or clear the `active` flag for a Position Manager on a given Spoke. + +## Trust Model and Authorization + +Authorization is a three-gate system. For a Position Manager contract to act on a user’s position through a given Spoke, all three conditions must hold: + +1. **Position Manager-side Spoke allowlist** (`onlyRegisteredSpoke`): the owner of the Position Manager must have registered the target Spoke via `registerSpoke`. This check is enforced by the Position Manager itself before it forwards any call. If the Spoke is not registered, the call reverts regardless of what the Spoke or the user has approved. +2. **Spoke-side activation** (`active=true`): the Spoke governance/admins must have activated the Position Manager address on that Spoke. +3. **User approval**: the user must have explicitly approved the Position Manager on that Spoke. + +None of the three gates alone is sufficient. Even if a Spoke has activated a Position Manager and a user has approved it, calls will still revert at the Position Manager if that Spoke has not been registered by the Position Manager owner. Conversely, a registered Spoke combined with a user approval is inert until the Spoke itself has activated the Position Manager. A user may approve a Position Manager while it is inactive or while the Spoke is unregistered; the approval is persisted but only becomes effective once all three conditions are met. One exception applies: a user is always their own implicit Position Manager. `_isPositionManager` short-circuits to `true` when `user == manager`, bypassing the Spoke-side `active` flag and user approval check (but not the Position Manager’s own `onlyRegisteredSpoke`, which is enforced at the Position Manager entry point before any Spoke call). A user may also revoke their prior approval to a Position Manager at any time by calling `setUserPositionManager` on the Spoke, passing `false` as the value of the `approve` parameter. + +These three conditions apply uniformly to all Position Managers described below. + +This design means Position Manager approvals are scoped to specific Spoke + Position Manager combinations. Approving a Position Manager on one Spoke grants no access on any other Spoke. There is no global Position Manager registry or cross-spoke approval propagation. + +Every Position Manager exposes `renouncePositionManagerRole` (owner-only, defined in `PositionManagerBase`), which calls `ISpoke.renouncePositionManagerRole` to revoke the Position Manager's own approval for a given user on a given Spoke. This is a cooperative cleanup mechanism: the Position Manager contract initiates the revocation rather than waiting for the user to submit a separate transaction. The Spoke-side call is a no-op if the approval was already absent (no event is emitted in that case). Revocation does not affect the Position Manager's `active` flag on the Spoke; it only clears that specific user's approval entry. + +**Governor sunsetting.** Whoever controls the Spoke configurator domain admin role (typically the Governor via `AccessManaged` on `SpokeConfigurator`) can deactivate a Position Manager on a Spoke by calling `SpokeConfigurator.updatePositionManager`, to set that address’s Spoke-side `active` flag to `false`. Delegated actions gated by `onlyPositionManager` then fail for that contract on that Spoke until it is reactivated with `updatePositionManager`. This does not iterate users or delete approval bitmap entries; it removes the Spoke-side activation gate (`active=true`, as described above) globally for that Spoke + Position Manager pair, so persisted user approvals are inert while the manager remains inactive. + +## Signature-Based Approval Flows + +Users approve Position Managers via `setUserPositionManagersWithSig` on the target Spoke. The function accepts an EIP-712 typed signature authorizing a set of approval updates, enabling gasless approval that can be bundled with the first delegated action in a single transaction (typically via multicall on multicall-enabled Position Managers; `NativeTokenGateway` is the exception, as multicall is intentionally disabled to prevent `msg.value` reuse across delegatecalls). This replaces the need for a separate onchain approval transaction before a Position Manager can operate. + +The plural form (`setUserPositionManagersWithSig`, not `setUserPositionManagerWithSig`) reflects that a single signed message can authorize multiple Position Manager updates in one operation. + +**Approval via Position Manager (`setSelfAsUserPositionManagerWithSig`)** + +`PositionManagerBase` exposes `setSelfAsUserPositionManagerWithSig`, which forwards a `setUserPositionManagersWithSig` call to the target Spoke on the user’s behalf. Because all concrete Position Managers inherit from `PositionManagerBase`, this function is available on every Position Manager, not only on gateways. For Position Managers with multicall enabled, this pattern allows a user to approve a Position Manager and execute the first action in a single multicall. (`NativeTokenGateway` is the exception: multicall is intentionally disabled.) Two constraints apply: + +1. The Position Manager enforces `onlyRegisteredSpoke`: the call is rejected if the Position Manager has not allowlisted the target Spoke (i.e., the Spoke is not registered in the Position Manager’s own registry). +2. The Position Manager’s multicall is restricted to its own methods. It cannot relay arbitrary calls to other Position Managers or contracts. This prevents a scenario where an EOA uses a multicall to chain approvals across Position Managers it has not independently chosen to authorize. + +The signature in `setSelfAsUserPositionManagerWithSig` must specify exactly one Position Manager update, and that update must name the address of the Position Manager. + +The Spoke call is executed in a `try/catch`: if sig verification fails (expired deadline, wrong nonce, address mismatch), the function returns successfully without setting any approval and without reverting. Integrators building a multicall that chains this with a subsequent delegated action must be aware that, if the approval silently failed, any subsequent Spoke call that is gated by Position Manager authorization (i.e., uses `onlyPositionManager(onBehalfOf)` / `_isPositionManager(onBehalfOf, msg.sender)`) will revert with `Unauthorized`. + +**ERC-20 Permit (`permitReserveUnderlying`)** + +`PositionManagerBase` exposes `permitReserveUnderlying`, which calls `IERC20Permit.permit` directly in a `try/catch` to attempt setting the ERC-20 allowance for the Position Manager within the same multicall. Nothing is stored. Because permit failures are intentionally ignored, downstream supply/repay calls will only succeed if the allowance is actually in place at execution time. + +## PositionManagerBase + +`PositionManagerBase.sol` is the shared base for all Position Manager contracts, including gateways. It inherits `Ownable2Step`, `Rescuable`, and `Multicall`. It defines: + +- The `onlyRegisteredSpoke` modifier, `registerSpoke` (owner-only) to allowlist or remove Spokes, and `isSpokeRegistered` for external inspection. +- `setSelfAsUserPositionManagerWithSig` to forward a `setUserPositionManagersWithSig` call to the target Spoke on the user's behalf, enabling gasless Position Manager approval that can be bundled with the first delegated action in a single multicall. +- `permitReserveUnderlying` to call `IERC20Permit.permit` in a `try/catch`, setting the ERC-20 allowance for the Position Manager within the same multicall; permit failures are silently ignored, so downstream supply or repay calls succeed only if the allowance is in place at execution time. +- `renouncePositionManagerRole` (owner-only) to call `ISpoke.renouncePositionManagerRole`, revoking the Position Manager's own approval for a given user on a given Spoke; the Spoke-side call is a no-op if the approval was already absent. +- A `Multicall` wrapper gated by `_multicallEnabled()`, which each subclass overrides to enable or disable multicall; `NativeTokenGateway` disables multicall to prevent `msg.value` reuse across delegatecalls, while all other Position Managers enable it. + +## PositionManagerIntentBase + +`PositionManagerIntentBase.sol` extends `PositionManagerBase` by additionally inheriting `IntentConsumer`, which adds EIP-712 domain separation and keyed-nonce consumption via `_verifyAndConsumeIntent`. It is used by contracts that verify user-signed intents: `SignatureGateway`, `TakerPositionManager`, and `ConfigPositionManager`. `NativeTokenGateway` and `GiverPositionManager` inherit `PositionManagerBase` directly, as they do not process user signatures. + +## NativeTokenGateway + +`NativeTokenGateway.sol` handles native coin (ETH or equivalent) wrapping and unwrapping around Spoke interactions. On the inflow side it accepts native coin, wraps it to the corresponding ERC-20, and forwards to the Spoke. On the outflow side it unwraps from the Spoke and returns native coin to the user. The implementation is structurally close to Aave V3's `WrappedTokenGatewayV3`. + +## SignatureGateway + +`SignatureGateway.sol` executes EIP-712 typed user intent signatures for Spoke actions. It supports `supplyWithSig`, `withdrawWithSig`, `borrowWithSig`, `repayWithSig`, `setUsingAsCollateralWithSig`, `updateUserRiskPremiumWithSig`, and `updateUserDynamicConfigWithSig`. Each function verifies a typed EIP-712 signature from the user and then executes the corresponding Spoke operation on their behalf. It uses keyed nonces, where each key namespace is consumed sequentially. + +## GiverPositionManager + +`GiverPositionManager` allows an integrator (the external caller of `GiverPositionManager`) to supply or repay on behalf of a user, subject to the three-gate authorization described in Trust Model and Authorization. No additional per-user allowances are required because the caller provides the funds. The inflow-only scope means the Position Manager can move assets into the protocol on a user’s behalf but cannot withdraw or borrow. + +The caller (integrator) provides the funds: `supplyOnBehalfOf` and `repayOnBehalfOf` transfer tokens from `msg.sender` to the Position Manager, which then approves the Spoke to pull them. The user whose position is being acted on does not need to grant any ERC-20 approvals. `repayOnBehalfOf` rejects `type(uint256).max` as the amount to prevent a frontrunning attack surface where the end user could borrow more before the repay lands. Thus the repay amount is capped at the user’s total debt. + +Supply and repay on behalf are permissioned by the Spoke’s Position Manager authorization gate (i.e., the Position Manager contract as `msg.sender` to the Spoke must be `active` on the Spoke and approved by `onBehalfOf`), and are additionally gated by the Position Manager’s own Spoke allowlist (`onlyRegisteredSpoke`). This prevents donation attack vectors that would otherwise exist if arbitrary callers could supply to another user’s position uninvited. + +The intended integrators are lending aggregators and automated repayment systems that need to fund or service positions on behalf of users without requiring active user involvement per transaction. + +## TakerPositionManager + +`TakerPositionManager` can execute `withdraw` and `borrow` on behalf of a user, subject to the three-gate authorization described in Trust Model and Authorization, and additionally when the spender holds a sufficient allowance in `TakerPositionManager`. Assets from `withdrawOnBehalfOf` and `borrowOnBehalfOf` are transferred to `msg.sender` (the spender), not to the position owner. Allowances are scoped to specific `(Spoke, ReserveId, owner, spender)` tuples; granting allowance for one Reserve on one Spoke confers no authority over any other Reserve, Spoke, or spender. These allowances are an additional gate and do not replace spoke-level Position Manager authorization. + +**Allowance mechanics** + +Withdraw and borrow allowances are tracked separately via `_withdrawAllowances` and `_borrowAllowances` mappings. Users grant allowances through `approveWithdraw` / `approveBorrow` (onchain) or `approveWithdrawWithSig` / `approveBorrowWithSig` (EIP-712 signed intents). When a spender withdraws or borrows up to the granted amount, the consumed amount is deducted from the outstanding allowance. When the allowance is set to `type(uint256).max`, spend operations do not decrease it. This max allowance behavior matches the convention established by ERC-20 for unlimited approvals. + +`renounceWithdrawAllowance` and `renounceBorrowAllowance` allow the spender to clear its own allowance for a given user, intended for cooperative consumers that want to release dust allowances after a position closes without requiring the user to submit an additional transaction. + +**Aave V3 equivalence** + +In Aave V3, two patterns covered outflow delegation: aToken allowances (ERC-20 `approve`/`transferFrom` on aTokens) enabled third parties to transfer a user's supply position, and credit delegation signatures (`approveDelegation` with EIP-712 sig) enabled third parties to borrow on a user's behalf. The TakerPositionManager replaces both with a unified, multi-asset scoped allowance model that covers withdrawals and borrows independently, without requiring aToken transfers or aToken-level accounting. + +## ConfigPositionManager + +`ConfigPositionManager` allows users to grant config permissions to any address (a delegatee), authorizing it to modify position settings on their behalf, subject to the three-gate authorization described in Trust Model and Authorization. The in-scope operations are: + +- `setUsingAsCollateralOnBehalfOf`: toggle whether a specific Reserve is used as collateral in a user's position +- `updateUserRiskPremiumOnBehalfOf`: update the user-level risk premium applied to a position +- `updateUserDynamicConfigOnBehalfOf`: update dynamic position configuration parameters + +These operations do not move funds. They adjust how the Spoke treats a user's position in risk and accounting calculations. Delegating them to a Position Manager allows automated position management systems to rebalance collateral configuration without requiring user interaction per adjustment. + +**Granular permissions** + +Permissions are granted per `(Spoke, delegator, delegatee)` triple using a bitmap (`ConfigPermissionsMap`). Each of the three config operations can be delegated independently: + +- `setCanSetUsingAsCollateralPermission` +- `setCanUpdateUserRiskPremiumPermission` +- `setCanUpdateUserDynamicConfigPermission` + +A convenience function `setGlobalPermission` sets or clears all three at once. Delegatees can renounce their own permissions: + +- `renounceGlobalPermission` +- `renounceCanUpdateUsingAsCollateralPermission` +- `renounceCanUpdateUserRiskPremiumPermission` +- `renounceCanUpdateUserDynamicConfigPermission` + +## Authorization Scope Summary + +| **PositionManager** | **Inflow (supply/repay)** | **Outflow (withdraw/borrow)** | **Configuration** | +| --------------------- | -------------------------------------------- | ------------------------------ | ------------------------- | +| GiverPositionManager | Yes, caller provides funds on behalf of user | No | No | +| TakerPositionManager | No | Yes, within granted allowances | No | +| ConfigPositionManager | No | No | Yes, per-operation bitmap | +| SignatureGateway | Yes, via user sig | Yes, via user sig | Yes, via user sig | +| NativeTokenGateway | Yes, native coin wrap | Yes, native coin unwrap | No | + +## Out of Scope + +The following are explicitly excluded from the Position Manager system: + +- **Persistent custody**: Position Managers are not custodial vaults, do not track per-user balances, and may hold assets transiently within a transaction before forwarding to or from the Hub. +- **Cross-spoke authority**: A Position Manager approval on one Spoke grants no authority on any other Spoke. +- **Flash loan origination**: Position Managers do not expose flash loan entry points. +- **Strategy execution or rebalancing logic**: Position Managers expose delegation primitives only; strategy logic is the responsibility of the integrating protocol. +- **Liquidation**: The standard Spoke liquidation path is not routed through the Position Manager system. +- **Factory deployment**: Position Managers are deployed and registered independently. There is no Position Manager factory. + +## Key Differences from Aave V3 + +In Aave V3, protocol-to-protocol integrations relied on three patterns that are replaced or superseded in Aave V4: + +**aToken allowances** allowed one address to transfer another user's aTokens (representing supply positions). In Aave V4, aToken allowances are not the primary delegation mechanism. The TakerPositionManager provides an explicit, scoped alternative for withdraw-on-behalf scenarios that does not require aToken transfers. + +**Credit delegation signatures** (`approveDelegation` with EIP-712 sig) allowed users to authorize third parties to borrow on their behalf. In Aave V4, the TakerPositionManager replaces this with per-reserve, per-spoke borrow allowances that support both onchain and EIP-712 signed grants without aToken-level accounting. + +**Native-asset gateway** (`WrappedTokenGatewayV3`) was a standalone periphery contract for wrapping/unwrapping native assets (ETH) around Pool interactions. In Aave V4, `NativeTokenGateway` subsumes this role as a Position Manager, inheriting `PositionManagerBase` and integrating into the unified authorization model rather than operating as an independent contract with its own trust assumptions. diff --git a/docs/features/ReinvestmentModule.md b/docs/features/ReinvestmentModule.md new file mode 100644 index 000000000..a4eb184bb --- /dev/null +++ b/docs/features/ReinvestmentModule.md @@ -0,0 +1,84 @@ +# Reinvestment Module + +## Summary + +The Reinvestment Module is a hub-level infrastructure component that enables a governance-authorized **Reinvestment Controller** to deploy idle liquidity into external yield-generating strategies. The Hub exposes two functions, `sweep` and `reclaim`, through which the controller moves liquidity out of the Hub and returns it. Swept liquidity remains counted in the Hub's utilization denominator and supply share valuation, so sweeping does not affect borrow rates or dilute existing suppliers. + +Yields generated by external strategies are not compounded into per-account supply rates. All interest accrued on swept funds is handled offchain. Strategy selection, yield distribution, and risk management are governance responsibilities; the Hub enforces only the access-control and accounting invariants described in this document. + +## Relationship to the Hub/Spoke Architecture + +The Reinvestment Module operates at the Hub layer, independently of all Spokes. The Reinvestment Controller is not a Spoke: it holds no Spoke registration, accrues no drawn interest, and reports no premium. The controller calls `sweep` and `reclaim` directly on the Hub. + +The Hub tracks two liquidity sub-fields per asset: `liquidity` (immediately available for spoke draws) and `swept` (currently held externally by the controller). Spoke draw operations source from `liquidity` only. If the controller has swept a large portion of the available balance, spoke draws that exceed the remaining `liquidity` revert with `InsufficientLiquidity`; the controller must reclaim before such draws can proceed. The controller is solely responsible for monitoring available liquidity and reclaiming proactively ahead of expected withdrawal demand. + +## Sweep and Reclaim + +`sweep` transfers `amount` of the underlying asset from the Hub to the registered `reinvestmentController`. The execution sequence is: + +1. The asset ID is validated; an unknown asset reverts with `AssetNotListed`. +2. Interest is accrued for the asset. +3. The caller is verified to be the registered `reinvestmentController`. Any other caller reverts with `OnlyReinvestmentController`. +4. If `amount` exceeds `asset.liquidity`, the call reverts with `InsufficientLiquidity`. +5. `asset.liquidity` decreases by `amount` and `asset.swept` increases by `amount`. +6. The drawn rate is updated. +7. The underlying ERC-20 is transferred to the controller. +8. A `Sweep` event is emitted. + +`reclaim` restores underlying assets to the Hub's available liquidity. The underlying must be transferred to the Hub before `reclaim` is called; the Hub does not pull tokens. The execution sequence is: + +1. The asset ID is validated; an unknown asset reverts with `AssetNotListed`. +2. Interest is accrued. +3. The caller is verified to be `reinvestmentController`. Any other caller reverts with `OnlyReinvestmentController`. +4. The Hub computes the expected post-reclaim liquidity as `asset.liquidity + amount` and requires the Hub's actual ERC-20 balance to be at least that value. If the balance is short, the call reverts with `InsufficientTransferred`, reporting the shortfall in asset units. +5. `asset.liquidity` increases by `amount` and `asset.swept` decreases by `amount`. +6. The drawn rate is updated. +7. A `Reclaim` event is emitted. + +The Hub imposes no onchain cap on the amount the controller may sweep in a single call. Cap enforcement is the controller contract's responsibility. The controller may not reclaim more than the current `asset.swept` amount; specifying a larger amount causes an arithmetic underflow and reverts. + +> **Note:** If the controller transfers more underlying to the Hub than the `amount` specified in `reclaim`, the surplus is retained in the Hub's ERC-20 balance. The surplus is not recognized through `reclaim` beyond outstanding swept principal, because each `reclaim` decreases `asset.swept` by exactly `amount` and reverts if `amount > asset.swept`. + +## Interest Rate Neutrality + +The Hub's interest rate strategy receives `asset.liquidity`, the outstanding drawn amount, and `asset.swept` as separate inputs when computing the utilization ratio: + +$$ +\text{usageRatio} = \frac{\text{drawn}}{\text{liquidity} + \text{drawn} + \text{swept}} +$$ + +- $\text{drawn}$ is the total outstanding borrowed amount for the asset +- $\text{liquidity}$ is the amount of the asset immediately available in the Hub +- $\text{swept}$ is the amount of the asset currently held externally by the Reinvestment Controller + +Including `swept` in the denominator preserves the utilization ratio as if swept assets remained in the Hub. Sweeping liquidity does not reduce the denominator, does not increase the utilization ratio, and does not raise borrow rates for the duration that liquidity is held externally. + +Swept liquidity is also included in `totalAddedAssets`, the quantity against which supply shares are priced. Supply shares appreciate solely through interest accrual on outstanding debt; sweep operations do not alter the share price in either direction. + +> **Warning:** Although borrow rates are unaffected while liquidity is swept, the physically available `asset.liquidity` is reduced. If `asset.liquidity` reaches zero and the controller has not reclaimed, draw requests from Spokes revert with `InsufficientLiquidity`. The controller must reclaim in advance of expected withdrawal demand. + +## Yield Accounting + +Interest accrued by external strategies on swept funds is not tracked onchain by the Hub. The Hub records no interest accrual against `asset.swept`. Per the interface specification, all interest earned on swept liquidity is distributed offchain. + +The controller may only reclaim up to the amount currently recorded in `asset.swept`. Attempting to reclaim more than `asset.swept` causes an arithmetic underflow and reverts. Yield earned by external strategies above the swept principal cannot be returned to the Hub through `reclaim`; any such surplus must be distributed directly from the controller itself. The controller is responsible for the full yield distribution lifecycle. + +## Access Control and Configuration + +The `reinvestmentController` address is configured per asset through `HubConfigurator.updateReinvestmentController`, which calls `Hub.updateAssetConfig` under a restricted access role. Exactly one controller address may be registered per asset at a time; assigning a new controller replaces the previous one. + +Setting the controller to the zero address is blocked when `asset.swept > 0`. The Hub enforces this with `InvalidReinvestmentController`. Governance must ensure the controller has reclaimed all swept liquidity before the controller is removed. Replacing the controller with a different non-zero address is permitted at any time regardless of `asset.swept`; governance is responsible for coordinating the handover between outgoing and incoming controllers. + +When `reinvestmentController` is `address(0)`, both `sweep` and `reclaim` revert with `OnlyReinvestmentController`, as the zero address can never be a valid caller. This makes the absence of a controller a sufficient guard against unauthorized invocations. + +The `getAssetSwept` view function returns the current outstanding swept amount for a given asset. + +## Out of Scope + +The following are explicitly excluded from the Reinvestment Module: + +- **Per-account yield compounding**: Yields earned externally are not attributed to individual supply positions and do not flow back into hub share pricing or interest rate accounting. +- **Onchain sweep caps**: The Hub enforces no maximum sweep amount per call or in aggregate. Cap logic is delegated to the controller contract. +- **Multi-controller per asset**: One `reinvestmentController` address is registered per asset. Routing across multiple external strategies requires a single controller contract that aggregates them internally. +- **Deficit reporting for external losses**: If the controller suffers a loss and cannot reclaim the full swept amount, the Hub provides no mechanism to record the shortfall as a protocol deficit. This risk is not absorbed by the Hub's bad debt accounting. +- **Strategy implementation**: The Hub exposes only `sweep` and `reclaim`. Strategy selection, yield distribution, offchain legal structure, investment caps, and risk parameters are governance decisions external to the Hub contract. diff --git a/docs/features/SiloedBorrowing.md b/docs/features/SiloedBorrowing.md new file mode 100644 index 000000000..9c9eb22f5 --- /dev/null +++ b/docs/features/SiloedBorrowing.md @@ -0,0 +1,45 @@ +# Siloed Borrowing + +## Summary + +Siloed Borrowing constrains a Spoke so that only one designated asset is effectively borrowable, confining volatility or liquidity risk of that asset to the siloed market alone. A common configuration is exactly one Reserve with `borrowable = true` and all others with `borrowable = false`; alternatively, non-target Reserves can be made effectively non-borrowable via a zero `drawCap` on their `(assetId, spoke)` Hub config. Multiple collateral assets can be supplied through the Spoke, but a user's active collateral set is still bounded by `MAX_USER_RESERVES_LIMIT` when enabling Reserves via `setUsingAsCollateral`. Borrow exposure is limited to the siloed asset subject to its draw cap on the Hub. + +Because the constraint is spoke-level rather than account-level, a single wallet can hold siloed borrow positions in multiple siloed Spokes simultaneously, each confined to its own asset, while also maintaining non-siloed positions in other Spokes. Collateral and debt in each Spoke remain fully segregated. + +## Relationship to the Hub/Spoke Architecture + +Siloed Borrowing is a reserve-level configuration. Each Reserve carries a `borrowable` boolean in `ReserveConfig`. When `borrowable = false`, any borrow attempt against that Reserve reverts with `ReserveNotBorrowable`. Configuring all Reserves except one to `borrowable = false` makes that one Reserve the sole drawable asset in the Spoke without requiring any additional contract logic. + +Each Reserve in `ISpoke.Reserve` carries a `hub` field of type `IHubBase`, specifying which Hub the Spoke interacts with for that Reserve. Supply and borrow flows use the same `hub` field: `reserve.hub.add` is called on supply and `reserve.hub.draw` on borrow, so each Reserve references exactly one Hub for both operations. + +The draw ceiling is enforced at the Hub level by `SpokeData.drawCap`. When the siloed Spoke calls `Hub.draw`, the Hub verifies that the sum of the Spoke's drawn amount, accrued premium, reported deficit, and the new draw amount does not exceed `drawCap`. Violation reverts with `DrawCapExceeded`. The cap is expressed in whole assets (not scaled by decimals), stored as `uint40`, and managed via `HubConfigurator.updateSpokeDrawCap`. Setting `drawCap` to `MAX_ALLOWED_SPOKE_CAP` removes the ceiling. + +## Collateral Configuration + +Multiple collateral assets can be supplied through the siloed Spoke. Each collateral Reserve references its own Hub via `Reserve.hub`: Reserve A may route supply to the Canonical Hub, Reserve B to a second Hub, and the siloed Reserve C also to the second Hub. The siloed constraint applies exclusively to borrowing; supply routing is unrestricted across Reserves, while collateral activation per user remains subject to `MAX_USER_RESERVES_LIMIT`. + +A user can call `setUsingAsCollateral` on any Reserve, but the asset only contributes to the health factor when its `collateralFactor` in `DynamicReserveConfig` is greater than zero. If `collateralFactor` is zero, the flag is set without effect: the position is not counted as collateral. Health factor calculations on the siloed Spoke use only the collateral held in that Spoke. Collateral in the Canonical Spoke or any other Spoke does not contribute to health factor in the siloed Spoke. + +## Risk Confinement + +The primary purpose of Siloed Borrowing is to prevent a newly listed asset from creating bad debt that harms suppliers of unrelated assets. Without siloing, a user could borrow both USDC and a new stablecoin XYZ in the same account. If XYZ's oracle price is manipulated upward, the resulting under-collateralization triggers liquidation. The liquidator seizes collateral to repay XYZ debt, but the remaining USDC debt may lack sufficient collateral, creating bad debt borne by USDC suppliers. + +Siloed Borrowing eliminates this contagion path. Because XYZ is the only borrowable asset in its Spoke, any shortfall from a manipulated XYZ price is confined to the XYZ Spoke's Hub. USDC suppliers in the Canonical Spoke carry no exposure to the XYZ market. The Governor controls maximum exposure through the draw cap on the siloed asset, and can safely list new stablecoins or experimental assets in dedicated siloed Spokes without affecting established markets. + +## Out of Scope + +The following are explicitly excluded from Siloed Borrowing as a configuration pattern: + +- **Cross-Spoke health factor aggregation**: Collateral and debt in one Spoke do not contribute to health factor calculations in another Spoke. Each Spoke maintains independent accounting. +- **Automatic account-level siloing**: The contracts do not prevent a user from opening borrow positions in both a siloed Spoke and the Canonical Spoke. Isolation is structural (per Spoke), not enforced at the wallet level. +- **Liquidation cross-contamination**: A liquidation event in one siloed Spoke does not affect positions in other Spokes or in the Canonical Spoke. + +## Key Differences from Aave V3 + +**Account-level vs. Spoke-level siloing**: In Aave V3, `siloedBorrowing` is a reserve configuration, and enforcement is account-level at borrow time: if an account has debt in a siloed asset, it cannot borrow a different asset; if an account already has non-siloed debt, it cannot start borrowing a siloed asset. In Aave V4, the constraint is spoke-level: the siloed Spoke enforces single-asset borrowing, but the same wallet maintains full flexibility in the Canonical Spoke and other Spokes. + +**Multiple simultaneous siloed positions**: In Aave V3, a single account can hold at most one siloed borrow position at any time. In Aave V4, a single wallet can hold siloed borrow positions in multiple siloed Spokes simultaneously, each confined to its own asset and Hub. + +**Per-asset draw caps**: In Aave V3, Siloed Borrowing does not introduce its own dedicated siloed exposure ceiling; however, borrowing can still be constrained by the reserve-level `borrowCap`. (A debt ceiling also exists in Aave V3, but it belongs to Isolation Mode, a separate feature.) In Aave V4, each `(assetId, spoke)` path applies a hub-level `SpokeData.drawCap`. For example, an exposure cap of 5,000 on a new stablecoin XYZ is implemented by creating a Spoke where XYZ is the sole borrowable Reserve and setting its draw cap to 5,000. Other Spokes and their caps remain unaffected. + +**Scalable market-level listings**: Because each siloed Spoke isolates risk to a single asset and Hub, governance can list new stablecoins and experimental assets in dedicated siloed Spokes without exposing existing markets to their volatility or liquidity risk. Aave V3 also configures siloed borrowing at market/reserve level; it does not require governance intervention per user account. diff --git a/docs/features/TokenizationSpoke.md b/docs/features/TokenizationSpoke.md new file mode 100644 index 000000000..5fd9548a1 --- /dev/null +++ b/docs/features/TokenizationSpoke.md @@ -0,0 +1,85 @@ +# TokenizationSpoke + +## Summary + +The TokenizationSpoke is a minimal ERC-4626–compliant contract that registers as a Spoke on an Aave V4 Hub, wrapping supply-only Hub positions into transferable ERC-20 shares. Its primary purpose is DeFi composability: the standard vault interface allows external protocols to interact with Aave V4 liquidity without custom adapters. The TokenizationSpoke does not implement borrowing or the full `ISpoke` interface. It is a pure supply tokenization primitive. + +## Relationship to the Hub/Spoke Architecture + +Standard Spokes in Aave V4 manage both supply and borrow flows, enforce collateralization constraints, manage reserve-level risk configuration, and support the full `ISpoke` interface including Position Manager delegation. The TokenizationSpoke operates at a narrower scope. It connects exclusively to the supply side of the Hub, calling `Hub.add` on supply and `Hub.remove` on withdrawal, and exposes no debt surface. + +A TokenizationSpoke instance wraps exactly one underlying ERC-20 asset. Where a standard Spoke manages multiple Reserves, each TokenizationSpoke deployment is scoped to a single token. The Hub registers the TokenizationSpoke as a distinct Spoke address for that underlying, alongside any other Spokes for the same asset. Hub exposure is partitioned **per Spoke** via each Spoke’s own `addCap`, the same model used when several standard Spokes share an asset. + +Because the TokenizationSpoke sits on the Hub's supply side without drawing debt, it does not participate in the risk premium system. Positions held through the TokenizationSpoke cannot be used as collateral and do not contribute to a user's health factor. + +## ERC-4626 Interface + +The TokenizationSpoke implements the ERC-4626 standard. User entry points include `deposit`, `mint`, `withdraw`, and `redeem` alongside the standard view functions: `totalAssets`, `convertToShares`, `convertToAssets`, `maxDeposit`, `maxMint`, `maxWithdraw`, and `maxRedeem`. + +Unlike `Spoke.supply`, which restricts `onBehalfOf` to approved Position Managers, the ERC-4626 interface permits callers to specify any `receiver` on deposit and any `owner` on withdrawal, following standard allowance semantics. `withSig` and EIP-2612 permit operations are natively supported within the TokenizationSpoke, covering the majority of meta-transaction use cases without requiring external Position Manager approval flows. + +**Deposit flow** + +1. The caller approves the underlying ERC-20 asset to the TokenizationSpoke. +2. `deposit` transfers the input amount of underlying assets directly from the caller to the Hub via `safeTransferFrom`. The TokenizationSpoke calls `Hub.add` to account for the deposited amount against its position. +3. Shares are minted to `receiver`; ERC-4626 events are emitted. + +**Mint flow** + +1. The caller approves the underlying ERC-20 to the TokenizationSpoke. +2. `mint` computes the assets required for the requested shares, transfers underlying from the caller to the Hub via `safeTransferFrom`, and calls `Hub.add`. +3. Shares are minted to `receiver`; ERC-4626 events are emitted. + +**Withdrawal flow** + +1. `withdraw` takes the requested asset amount as input, computes the shares to burn, and burns those shares from `owner`. +2. The TokenizationSpoke calls `Hub.remove`. +3. The Hub transfers underlying directly to `receiver`. + +**Redeem flow** + +1. `redeem` takes the share amount to burn as input and burns those shares from `owner`. +2. The TokenizationSpoke calls `Hub.remove`. +3. The Hub transfers underlying directly to `receiver`. + +All four entry points round in favor of the vault per ERC-4626 conventions: `deposit` rounds down shares minted, `mint` rounds up assets required, `withdraw` rounds up shares burned, and `redeem` rounds down assets returned. This asymmetry protects the vault from rounding-based value extraction. + +## Share Price and Accounting + +The TokenizationSpoke carries no fee logic at the vault layer. There are no performance fees, management fees, or protocol spreads applied by the contract itself. The share price (the ratio of `totalAssets` to total share supply) grows solely as a function of hub-level interest accrual on the underlying asset. As the Hub accrues yield for the TokenizationSpoke's position, `totalAssets` increases accordingly, and outstanding shares appreciate in underlying terms over time. + +`totalAssets` converts the vault's total share supply to underlying via the Hub's exchange rate (`previewRemoveByShares`), denominated in the underlying asset's smallest unit. Caps (`addCap`, type `uint40`) are stored in whole asset units and scaled by `10^decimals` during validation. + +## Cap Management and Deployment + +The TokenizationSpoke does not get deployed through the standard Spoke factory. Each instance is deployed and registered on the Hub with governor-authorized `SpokeConfig` (including `addCap`), the same pattern as adding any Spoke via `HubConfigurator.addSpoke`. + +The TVL ceiling for a TokenizationSpoke instance is controlled by the `addCap` field in `SpokeConfig` (type `uint40`). In practice it is commonly managed via governance-authorized calls to `HubConfigurator.updateSpokeAddCap`, but the source of truth is the Hub’s per-asset and per-spoke configuration (`Hub.updateSpokeConfig`). The cap is enforced by the Hub on every supply-add path (`deposit` and `mint`) when `Hub.add` is invoked. The Hub scales the configured cap to the asset’s decimals, values the Spoke’s existing position in underlying by converting its Hub shares (rounding up), adds the incoming amount, and reverts if that total would exceed the cap. + +## Upgradeability + +The TokenizationSpoke is deployed behind an upgradeable proxy. The current implementation uses `TransparentUpgradeableProxy` per instance (same as standard Spokes). + +## Safety Controls + +The TokenizationSpoke is governed by the same hub-level emergency control states as standard Spokes (`halted` and `active`) which apply to deposits and withdrawals. Enforcement is layered: the TokenizationSpoke's `maxDeposit`/`maxWithdraw` view functions return zero when either flag is in its blocking state, and the Hub's `_validateAdd`/`_validateRemove` independently enforce the same checks, reverting onchain if violated. Both flags are governance-controlled and enforced per Spoke at Hub validation time. + +## Out of Scope + +The following are explicitly excluded from the TokenizationSpoke: + +- **Borrowing**: No draw, repay, or collateralization logic. Positions through the TokenizationSpoke are supply-only. +- **Position Managers**: External Position Managers cannot be plugged in. `withSig` and permit cover the key meta-transaction use cases natively. +- **Fees**: No performance or management fees at the vault layer. +- **Multi-asset**: Each deployment handles exactly one underlying ERC-20. +- **Factory deployment**: Unlike standard Spokes, the TokenizationSpoke is not deployed through a Spoke factory. Each instance must be deployed and registered manually with `addCap` governance setup. +- **Rebalancing, strategies, or flashloans**: The contract has no strategy/rebalancing/flashloan logic. Its core state-changing interactions are with `Hub.add`/`Hub.remove`; aside from that, it only performs underlying-token transfer/permit calls required for vault flows. +- **Collateral use**: Shares held in a TokenizationSpoke cannot serve as collateral in any spoke configuration. + +## Key Differences from Standard Spokes + +**No debt surface**: Standard Spokes expose both `supply` and `borrow` paths. The TokenizationSpoke exposes only the supply side via ERC-4626. There is no `draw`, no `repay`, and no risk premium calculation. + +**No Position Manager integration**: Standard Spokes restrict `onBehalfOf` operations to approved Position Managers. The TokenizationSpoke instead uses ERC-4626 `receiver`/`owner` semantics and natively supports `withSig` flows, reducing friction for integrators who do not need Position Manager delegation. + +**Single asset per deployment**: A standard Spoke manages multiple Reserves across multiple assets. Each TokenizationSpoke instance corresponds to exactly one underlying ERC-20, making it a per-asset tokenization contract rather than a market-level entry point. diff --git a/docs/overview.md b/docs/overview.md index 772c81926..6e76ccf0b 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -2,7 +2,7 @@ Aave V4 introduces an architectural redesign centered around the Hub, enabling protocol flexibility and capital efficiency. This innovative architecture allows the Governor (e.g., the Aave DAO) to dynamically manage Spokes, adding new borrowing capabilities and removing outdated ones without requiring costly liquidity migrations. -The protocol implements sophisticated risk management through its Risk Premiums system, where each asset receives a specific risk factor (called Collateral Risk) ranging from 0 to 1000_00 (BPS) based on the asset's implied volatility, market conditions, liquidity, risk, etc. Base drawn rates are determined purely by utilisation, while risk premium rates are adjusted according to collateral risk profiles, from low‑risk pristine‑quality assets (such as ETH) to higher‑risk collateral, scaling borrowing costs proportionally. +The protocol implements sophisticated risk management through its Risk Premium system, where each asset receives a specific risk factor (called Collateral Risk) ranging from `0` to `1000_00` (BPS) based on the asset's implied volatility, market conditions, liquidity, risk, etc. Base drawn rates are determined purely by utilization, while risk premium rates are adjusted according to collateral risk profiles, from low‑risk pristine‑quality assets (such as ETH) to higher‑risk collateral, scaling borrowing costs proportionally. By providing preferential rates for stronger collateral and optimizing capital efficiency, Aave V4 creates a more robust lending environment that accurately prices risk and rewards. Consequently, the protocol attracts higher-quality collateral, while offering improved yields for suppliers and lower fees for borrowers utilizing safer collateral assets. @@ -38,16 +38,17 @@ flowchart TD class LP,BR,S1,S2,S3,HUB box; ``` -Spokes are individual modules that can connect to one or more Hubs. They route user actions (`supply`,`withdraw`, `borrow` and `repay`) to the appropriate Hub based on reserve configuration and available caps. Whenever liquidity is restored on the Hub, the Spoke accrues a base interest (determined by an interest rate strategy at the Hub level) and a premium debt based on the Risk Premium (determined by the collateral composition of the user that triggered the action). +Spokes are individual modules that can connect to one or more Hubs. They route user actions (`supply`, `withdraw`, `borrow` and `repay`) to the appropriate Hub based on reserve configuration and available caps. Whenever liquidity is drawn from the Hub, the Spoke accrues a base interest (determined by an interest rate strategy at the Hub level) and a premium debt based on the Risk Premium (determined by the collateral composition of the user that triggered the action). A Hub can have an unspecified number of Spokes, each one contributing to the total outstanding debt and to the interest generated. The Hub manages the basic accounting (total liquidity vs available), the interest rates, and the spoke-specific add and draw caps, among other parameters. ## Hub -The Hub is immutable and serves as the central coordinator for liquidity management in Aave V4. The design allows for multiple Hubs to exist, with each Hub maintaining oversight of its own set of Spokes. Each Hub sets the add/draw caps for its Spokes and enforces crucial accounting invariants. The design objective was to make the Hub as simple as possible. +The Hub is upgradeable and serves as the central coordinator for liquidity management in Aave V4, architected to be as simple as possible. The design allows for multiple Hubs to exist, with each Hub maintaining oversight of its own set of Spokes. Each Hub sets the add/draw caps for its Spokes and enforces crucial accounting invariants. The key aspects of the Hub include: +- Registering and listing assets supported by the protocol. - Maintaining a registry of authorized Spokes for each supported asset. - Spoke-specific configurations including: - Liquidity caps to limit Spoke drawing and adding @@ -58,11 +59,10 @@ The key aspects of the Hub include: - Managing interest rate strategy per asset, defining the optimal usage ratio, base drawn rate, and rate slopes, among other parameters. - Managing the access controls for the Governor entity authorized to execute emergency controls, the authorized entity which can call `mintFeeShares`, and general access controls via the `AccessManager` authority. - Setting the liquidity fee per asset, determining the share of interest revenue retained by the protocol. -- Enforcing accounting invariants: - 1. Total borrowed shares == sum of Spoke debt shares - 2. Hub added assets amount >= sum of Spoke added assets amount (converted from shares) - 3. Hub added shares == sum of Spoke added shares - 4. Supply share price and drawn index cannot decrease (remains constant or increases) +- Enforcing accounting and solvency invariants: + 1. Hub level aggregate fields == sum across all per-Spoke fields (`addedShares`, `drawnShares`, `premiumShares`, `premiumOffsetRay`, `deficitRay`) + 2. Supply share price and drawn index cannot decrease (remains constant or increases) + 3. Hub's actual token balance for a given asset is always >= internally tracked available liquidity (`asset.liquidity`) ## Spokes @@ -79,6 +79,9 @@ Users interact with the Spokes, which then interact directly with the Hubs. The - Employing reentrancy guards for extra protection against reentrancy attacks, even though the Hub, Interest Rate Strategy, and Price Feeds are trusted and Aave V4 does not support tokens with callbacks. - Enforcing position constraints through a configurable `MAX_USER_RESERVES_LIMIT` which limits the number of collateral Reserves and the number of borrow Reserves a user can have (each counted separately). - Configuring and enforcing per-reserve liquidation parameters at the Spoke level. +- Enforcing position safety invariants: + 1. Users without collateral cannot assume debt + 2. No user action can worsen their position's health factor below the liquidation threshold # Risk Premium @@ -86,7 +89,7 @@ The debt interest of each user is directly impacted by the quality of the assets ## Collateral Risk -The Collateral Risk $CR_i$ is specified by the quality of the asset $i$, which is a BPS value, ranging from 0 to 1000_00. A value of 0 means highest quality and risk-free, while a value of 1000_00 signifies the lowest quality and maximum risk possible for a collateral. +The Collateral Risk $CR_i$ is specified by the quality of the asset $i$, which is a BPS value, ranging from `0` to `1000_00`. A value of `0` means highest quality and risk-free, while a value of `1000_00` signifies the lowest quality and maximum risk possible for a collateral. This parameter is configurable and part of the Spoke's risk parameters. This means the same asset can have a different Collateral Risk value across Spokes. @@ -138,9 +141,11 @@ $RP_u = f(CR_0, C_{u,0}, P_0) = CR_0$ $RP_u = f(CR_i, C_{u, i}, P_i) = \frac{CR_0C_{u,0}P_0 + CR_1C_{u,1}P_1}{C_{u,0}P_0+C_{u,1}P_1}$ +> **Note:** If the user's total collateral value is insufficient to cover all outstanding debt (e.g., in a deficit scenario), the algorithm terminates early once all collateral has been exhausted. The Risk Premium is computed using only the collateral value available, and the remaining uncovered debt is ignored in the calculation. + ## Premium Offset -Operationally, the premium is implemented via additional virtual debt shares (“premium shares”) that increase interest accrual but are never repayable principal. We separate this component from principal interest by tracking a premium offset in asset units. At borrow time, the offset is set so that, in asset terms, it exactly equals the value of the premium shares. As time elapses, interest accrues on the premium shares causing their asset value to exceed the offset; the excess is the premium. Premium shares are recorded in share units. The premium offset is recorded in asset units. +Operationally, the premium is implemented via additional virtual debt shares (“premium shares”) that increase interest accrual but are never repayable principal. The protocol separates this component from principal interest by tracking a premium offset in asset units. At borrow time, the offset is set so that, in asset terms, it exactly equals the value of the premium shares. As time elapses, interest accrues on the premium shares causing their asset value to exceed the offset; the excess is the premium. Premium shares are recorded in share units. The premium offset is recorded in asset units. A user’s accrued premium debt at any time equals the assets value of their premium drawn shares minus the premium offset. When a user’s Risk Premium changes, the system refreshes premium accounting (via the Hub’s `refreshPremium` mechanism) to recalibrate premium shares and the offset without changing the user’s total accrued premium amount. @@ -153,7 +158,7 @@ The refresh mechanism preserves the total premium debt while updating the premiu - The user's previously accrued premium debt remains unchanged - Future premium accrual reflects the updated Risk Premium -Actions that trigger a premium refresh include events that can change $RP_u$, such as `setUsingAsCollateral` when disabling collateral, `withdraw` when withdrawing collateral, `borrow` when increasing debt, `liquidationCall` (non-deficit path), and explicit `updateUserRiskPremium` updates (user-initiated or permissioned by the Governor). In these flows, `refreshPremium` updates premium shares and the offset so the total premium debt stays constant, while future accrual reflects the new Risk Premium. +Actions that trigger a premium refresh include events that can change $RP_u$, such as `setUsingAsCollateral` when disabling collateral, `withdraw` when withdrawing collateral, `borrow` operations, `liquidationCall` (non-deficit path), and explicit `updateUserRiskPremium` updates (user-initiated or permissioned by the Governor). In these flows, `refreshPremium` updates premium shares and the offset so the total premium debt stays constant, while future accrual reflects the new Risk Premium. # Interest Accrual @@ -184,7 +189,7 @@ $ΔD_{u,ibase} = R_{sbase,i}D_{u,ibase}$ ## Premium Debt -Premium Debt is the portion of a user’s debt that represents the additional interest accumulated due to the quality of user’s collateral assets (i.e., their Risk Premium on top of the base drawn rate). +Premium Debt is the portion of a user’s debt that represents the additional interest accumulated due to the quality of the user’s collateral assets (i.e., their Risk Premium on top of the base drawn rate). $D_{u,premium}$ is a running total of the extra interest accrued on user u @@ -202,8 +207,8 @@ The Reinvestment Module offers an optional tool to support capital efficiency wh - **Governance‑Controlled**: The Governor manages all funds allocated to reinvestment strategies. Decisions on strategy selection, risk parameters, and operational guidelines are exclusively governance responsibilities. - **Interest Rate Neutral**: Swept liquidity remains part of the usage ratio denominator, so sweeping funds to external strategies does not affect the drawn rate experienced by users. -- **Opt‑In for Users**: Participation in reinvestment is not compulsory. Reinvestment is enabled at the Hub level on a per-asset basis; users choose whether to interact with a reinvestment-enabled asset by supplying to a spoke Reserve that is connected to that Hub asset. Users who prefer not to participate can supply to spoke Reserves linked to Hub assets without an active reinvestment controller. -- **Enhanced Yields**: By deploying otherwise idle liquidity into external strategies, opt‑in liquidity providers can earn incremental returns on top of borrower interest. +- **Opt‑In for Users**: Participation in reinvestment is not compulsory. Reinvestment is enabled at the Hub level on a per-asset basis; users choose whether to interact with a reinvestment-enabled asset by supplying to a spoke Reserve that is connected to that Hub asset. Since the reinvestment controller is a governance-controlled parameter that can be activated at any time, it is the Governor's responsibility to properly communicate any planned activation and its timeframe in advance. This gives existing suppliers the opportunity to withdraw their funds if they wish to opt out. +- **New Yield Opportunities**: By deploying otherwise idle liquidity into external strategies, liquidity providers opting in can potentially earn incremental returns on top of borrower interest. - **Risk Allocation**: Losses arising from reinvestment strategies are absorbed by the Governor, who defines the coverage method, shielding liquidity providers from direct strategy‑specific risk exposure. - **Optional by Design**: The module can remain disabled without affecting core supply and borrow functionality. When inactive, all liquidity stays in the Hub. @@ -211,11 +216,11 @@ The reinvestment infrastructure enables the protocol to optimize capital efficie # Dynamic Risk Configuration -One of the major risk‑side limitations of V3 lies in its single, global risk configuration per asset. This design creates significant governance overhead and potential user harm through unexpected liquidations, as any parameter change, in particular lowering the liquidation threshold, immediately affects every open position. +One of the major risk‑side limitations of Aave V3 lies in its single, global risk configuration per asset. This design creates significant governance overhead and potential user friction through unexpected liquidations, as any parameter change, in particular lowering the liquidation threshold, immediately affects every open position. Aave V4 makes it possible for multiple risk configurations to exist side‑by‑side. Whenever the Governor adjusts collateralization parameters (currently the Collateral Factor (CF), Max Liquidation Bonus (LB) or Liquidation Fee (LF)), the protocol adds a new configuration instead of replacing the old one. Earlier configurations continue to govern positions opened under them while updated parameters apply to new positions. In particular cases where there could be a negative impact to the protocol, the Governor may decide to trigger an authorized update of existing positions to the latest parameters. -Every time the Governor adjusts the collateralization parameters, it corresponds to a new configuration. These configurations are stored in a bounded dictionary of up to ~4.29B entries (2^32) identified by incremental keys, with each Reserve holding the key that points to the current active configuration. +In most cases, when the Governor adjusts the collateralization parameters, it corresponds to a new configuration. It is in rare cases that the Governor will update existing configurations. These configurations are stored in a bounded dictionary of up to ~4.29B entries (2^32) identified by incremental keys, with each Reserve holding the key that points to the current active configuration. Each user position also stores the key corresponding to the active configuration when that position became risk-bearing. This key is refreshed whenever the user performs specific actions, but may continue to reference a prior configuration even if there are changes on the dynamic risk configuration between user interactions. @@ -223,15 +228,15 @@ Each user position also stores the key corresponding to the active configuration Dynamic configuration keys allow parameter updates without affecting existing open positions. The Governor retains the ability to update parameters of old keys. However, during normal operations the system updates upon user interaction without requiring governance intervention. -## Design +## Design Choices Dynamic configuration extends the reserve model with a per‑reserve mapping that holds every historic configuration key, referenced by a `dynamicConfigKey`. Collateralization parameters now reside inside the dynamic mapping rather than the static reserve record; this set comprises the Collateral Factor (CF), the Maximum Liquidation Bonus (LB) and the Liquidation Fee (LF). -Each Reserve stores the latest `dynamicConfigKey`, which represents the current up-to-date risk configuration. In contrast, every user position retains a snapshot of the active `dynamicConfigKey` corresponding to the configuration in effect at the time of its last risk-increasing event. This snapshot is refreshed across all assets of a user position only when the user performs an action which elevates the risk posed to the system, such as disabling an asset as collateral, withdrawing, or borrowing. When a user designates a new asset as collateral, only the `dynamicConfigKey` snapshot of the asset in play is refreshed. +Each Reserve stores the latest `dynamicConfigKey`, which represents the current up-to-date risk configuration. In contrast, every user position retains a snapshot of the active `dynamicConfigKey` corresponding to the configuration in effect at the time of its last risk-increasing event. This snapshot is refreshed across all assets of a user position only when the user performs an action which elevates the risk posed to the system, such as disabling an asset as collateral, withdrawing collateral, or borrowing. When a user designates a new asset as collateral, only the `dynamicConfigKey` snapshot of the asset in play is refreshed. ### Automatic Rebinding and Hard Safety Guard -When a user attempts a health‑decreasing action, the engine checks the latest configuration for each collateral in the position. If the position remains sustainable under this configuration, the engine rebinds the snapshot to this latest key and allows the action to proceed. However, if the latest configuration would leave the position under‑collateralized, the engine blocks the action by reverting. +When a user attempts a health‑decreasing action, the engine tentatively applies the action and then evaluates the resulting position against the latest dynamic configuration for each collateral. If the position is healthy after both the action and the configuration update, the engine rebinds the snapshot to the latest key and commits the action. However, if the resulting position would be under‑collateralized with the latest configuration, the engine reverts the entire action. ### Feature Notes @@ -241,27 +246,28 @@ The architecture of dynamic configuration comes with several practical constrain 2. For a given user position, the snapshot updates to the latest key on: 1. `setUsingAsCollateral` updates a single Reserve when enabling and all Reserves when disabling 2. `borrow` - 3. `withdraw` -3. The snapshot does **not** update on actions that reduce risk exposure of the system: + 3. `withdraw` when the withdrawn Reserve had been set as collateral +3. The snapshot does **not** update on actions that do not increase the risk exposure of the system: 1. `supply` 2. `repay` 3. `liquidationCall` as liquidations will always improve the health of a user position 4. `updateUserRiskPremium` + 5. `withdraw` when the withdrawn Reserve had **not** been set as collateral 4. Dynamic Risk Configurations can be adjusted by the Governor utilizing the following methods: 1. `addDynamicReserveConfig` creates a new risk configuration and increments the latest `dynamicConfigKey`. User positions created or subsequently updated bind to this latest `dynamicConfigKey`. 2. `updateDynamicReserveConfig` updates a prior configuration, affecting existing positions bound to that `dynamicConfigKey`. -5. For a given user position, the Governor or the user can refresh the Dynamic Risk Configuration of that position: +5. For a given user position, the Governor, the user, or a Position Manager enabled by the user can refresh the Dynamic Risk Configuration of that position: 1. `updateUserDynamicConfig` updates the user's snapshots to the latest `dynamicConfigKey` for all collateral Reserves. Upon successful update, it also refreshes the user's risk premium to reflect any changes in collateral factors. # Liquidation Engine -Aave V4 introduces a redesigned liquidation mechanism that replaces the fixed close‑factor logic used in V3. Instead of always seizing a fixed percentage of a user’s debt and collateral, Aave V4 allows liquidators to repay just enough debt and seize just enough collateral to bring the borrower’s health factor (HF) back to a configurable Target Health Factor (`TargetHealthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD`). The mechanism adopts a Dutch‑auction style variable liquidation bonus. It also implements safeguards against “dust” which ensures that liquidations do not result in remaining dust collateral or debt unless the respective corresponding debt or collateral Reserves are fully liquidated. These changes aim to improve user experience and reduce the chance of protocol‑level bad debt. +Aave V4 introduces a redesigned liquidation mechanism that replaces the fixed close‑factor logic used in Aave V3. Instead of always seizing a fixed percentage of a user’s debt and collateral, Aave V4 allows liquidators to repay just enough debt and seize just enough collateral to bring the borrower’s health factor (HF) back to a configurable Target Health Factor (`TargetHealthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD`). The mechanism adopts a Dutch‑auction style variable liquidation bonus. It also implements safeguards against “dust” which ensures that liquidations do not result in remaining dust collateral or debt unless the respective corresponding debt or collateral Reserves are fully liquidated. These changes aim to improve user experience and reduce the chance of protocol‑level bad debt. ## Key Differences from Aave V3 -- **Target Health Factor vs Close Factor:** In V3, the default close factor is 50% (with a 100% close factor when HF < 0.95 or when liquidation amounts are under a given base currency threshold). Liquidators would typically repay half of a borrower’s debt and seize half of their collateral. Aave V4 removes the default close‑factor: the maximum a liquidator can repay (in situations that do not result in dust collateral or debt remaining) is the amount needed to bring the borrower back to the `TargetHealthFactor` determined by the Governor. -- **Dynamic Dust Handling during Liquidations**: Both V3 and V4 revert when the remaining amount is below a hard‑coded threshold, while dynamically adjusting the maximum debt that can be liquidated and, if the liquidator opts to fully repay, allow full repayment to prevent dust. However, Aave V4 allows more flexibility because of removing the close-factor and facilitating the liquidation steps required to bring the position back to the target HF. Dust may still remain if either the collateral or debt Reserve is fully liquidated. -- **Dutch‑Auction Style Liquidation Bonus:** V3 applies a static liquidation bonus that does not depend on the borrower’s health factor. Aave V4 introduces a variable liquidation bonus that increases linearly as the health factor decreases. Governance can specify two spoke‑wide parameters that shape the liquidation bonus: `healthFactorForMaxBonus` and `liquidationBonusFactor`. +- **Target Health Factor vs Close Factor:** In Aave V3, the default close factor is 50% (with a 100% close factor when HF < 0.95 or when liquidation amounts are under a given base currency threshold). Liquidators would typically repay half of a borrower’s debt and seize a corresponding amount of their collateral. Aave V4 removes the default close factor: the maximum a liquidator can repay (in situations that do not result in dust collateral or debt remaining) is the amount needed to bring the borrower back to the `TargetHealthFactor` determined by the Governor. +- **Dynamic Dust Handling during Liquidations**: Similar to Aave V3, Aave V4 uses a dust threshold of `$1_000` to prevent residual positions that are uneconomical to liquidate. If the remaining debt or collateral post-liquidation were to fall below this threshold, the protocol expands the liquidation to fully exhaust the smaller side, or reverts if the liquidator's intended `debtToCover` is insufficient. However, whereas Aave V3's close factor is a hard constraint, Aave V4 allows more flexibility by selectively bypassing the target HF enforcement in order to prevent dust accumulation. +- **Dutch‑Auction Style Liquidation Bonus:** Aave V3 applies a static liquidation bonus that does not depend on the borrower’s health factor. Aave V4 introduces a variable liquidation bonus that increases linearly as the health factor decreases. Governance can specify two spoke‑wide parameters that determine the liquidation bonus calculation: `healthFactorForMaxBonus` and `liquidationBonusFactor`. ## Parameters and Configuration @@ -270,7 +276,7 @@ Aave V4 exposes several configurable parameters that influence liquidation: | **Parameter** | **Description** | **Constraints** | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `TargetHealthFactor` | A spoke‑wide value set by the Governor representing the HF to which a borrower should be restored after liquidation. Liquidators repay only enough debt to reach this HF under normal circumstances that do not result in dust collateral or debt remaining. | Must be ≥ the `HEALTH_FACTOR_LIQUIDATION_THRESHOLD` constant. | -| `DUST_LIQUIDATION_THRESHOLD` | Hard‑coded threshold used to prevent extremely small leftover of debt and/or collateral. The maximum debt that can be liquidated is increased to ensure that debt or collateral dust less than this threshold does not remain unless the corresponding collateral or debt Reserve is fully liquidated. | Hard‑coded constant set to 1_000 USD in value units. | +| `DUST_LIQUIDATION_THRESHOLD` | Hard‑coded threshold used to prevent extremely small leftover of debt and/or collateral. The maximum debt that can be liquidated is increased to ensure that debt or collateral dust less than this threshold does not remain unless the corresponding collateral or debt Reserve is fully liquidated. | Hard‑coded constant set to `$1_000` in value units. | | `maxLiquidationBonus` | Per dynamic reserve config defined maximum liquidation bonus for a collateral, expressed in basis points (BPS). A value of 105_00 means there is 5% extra seized collateral over the amount of debt repaid in value units. | Must be ≥ 100_00 | | `healthFactorForMaxBonus` | Spoke‑wide value expressed in WAD units defining the HF below which the max bonus applies. It must be less than `HEALTH_FACTOR_LIQUIDATION_THRESHOLD` to avoid division‑by‑zero. | Must be < `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. | | `liquidationBonusFactor` | Spoke‑wide percentage (expressed in BPS) specifying the fraction of the max bonus earned at the threshold `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. It defines the minimum bonus; e.g., a factor of 80_00 yields a bonus equal to 80% of the max bonus when HF equals the liquidation threshold. | Must be ≤ 100_00 | @@ -281,15 +287,15 @@ Aave V4 exposes several configurable parameters that influence liquidation: The following high‑level steps outline the Aave V4 liquidation flow: 1. **Check Eligibility:** When a borrower’s HF drops below the `HEALTH_FACTOR_LIQUIDATION_THRESHOLD`, anyone can trigger a liquidation; however, users are not allowed to liquidate their own positions. The protocol retrieves the borrower’s total debt value, current HF, and total collateral value, including only Reserves with `usingAsCollateral` enabled and CF > 0. Liquidations of frozen Reserves are allowed. If other Reserves in the user position (not the target being liquidated or seized) are paused, liquidations are not blocked. -2. **Determine Debt to Repay:** Based on the Target Health Factor `TargetHealthFactor`, the protocol computes the debt that must be repaid to restore the borrower’s HF to `TargetHealthFactor`. The required repayment amount depends on the borrower’s current debt and collateral (CF, LB, HF). +2. **Determine Debt to Repay:** The protocol computes the debt that must be repaid to restore the borrower’s HF to `TargetHealthFactor`. The required repayment amount depends on the borrower’s current debt and collateral (CF, LB, HF). 3. **Handle Dust Debt:** If the borrower’s remaining debt after a standard liquidation would be below the `DUST_LIQUIDATION_THRESHOLD`, and the liquidator intends to fully repay the debt, the protocol increases the allowable debt that can be liquidated, so that the entire debt can be covered. However, dust may still remain if the liquidator targets debt equal to the full amount of the collateral Reserve $C_i$ being seized (i.e., $Δ C_i = C_i$), then a residual debt $D_{dust} > 0$ can remain when there are multiple collateral Reserves ($N_{coll} > 1$). If there is a single collateral Reserve ($N_{coll} = 1$), the residual, along with any other existing debt across all Reserves, is recorded as a protocol deficit. 4. **Calculate Collateral to Seize and Handle Collateral Dust**: Convert the debt to be repaid into the collateral asset’s value and apply the liquidation bonus for this specific liquidation. By this point the bonus is fixed (not variable during execution) based on the position’s HF at the start of liquidation and the Reserve’s `maxLiquidationBonus`. The formula in this step just computes that liquidation bonus and the resulting collateral to transfer. If the chosen collateral is not sufficient, all of that collateral is seized and the repaid debt is recomputed. Lastly, collateral dust is accounted for. -5. **Apply Debt Repayment & Transfer Collateral**: Reduce the borrower’s debt amount by the repaid amount. Transfer the corresponding collateral to the liquidator with the liquidation bonus applied, minus the protocol fee (as in V3). If `receiveSharesEnabled` is true for the collateral Reserve and the Reserve is not frozen, the liquidator can opt to receive added shares directly instead of underlying assets by setting the `receiveShares` parameter to true. Added shares accrue yield in the Hub, providing a more capital-efficient liquidation mechanism. The fee portion is sent to the protocol/fee receiver via the Hub as shares, accrues yield there, and the shares are assigned directly via Hub accounting. -6. **Emit Events and Update State:** A `LiquidationCall` event is emitted containing details of the liquidation. The borrower’s and Reserve’s interest indices are updated. If the borrower still has debt outstanding and no remaining collateral, the system will record a protocol deficit. Reporting deficit is allowed even when the reporting Spoke is halted (as long as it’s active). +5. **Apply Debt Repayment & Transfer Collateral**: Reduce the borrower’s debt amount by the repaid amount. Transfer the corresponding collateral to the liquidator with the liquidation bonus applied, minus the protocol fee (as in Aave V3). If `receiveSharesEnabled` is true for the collateral Reserve and the Reserve is not frozen, the liquidator can opt to receive added shares directly instead of underlying assets by setting the `receiveShares` parameter to true. Added shares accrue yield in the Hub, providing a more capital-efficient liquidation mechanism. The fee portion is sent to the protocol/fee receiver via the Hub as shares, accrues yield there, and the shares are assigned directly via Hub accounting. +6. **Emit Events and Update State:** A `LiquidationCall` event is emitted containing details of the liquidation. The borrower’s and Reserve’s debt amounts are updated. If the borrower still has debt outstanding and no remaining collateral, the system will record a protocol deficit. Reporting deficit is allowed even when the reporting Spoke is halted (as long as it’s active). ## Dust and Rounding Considerations -Aave V4 introduces a dynamic dust prevention mechanism. If the debt remaining after a standard liquidation is below the `DUST_LIQUIDATION_THRESHOLD` (e.g., $1_000 in value units), the protocol increases the maximum debt that can be liquidated to allow full repayment, provided the liquidator has indicated intent to fully cover the debt; otherwise, the liquidation reverts under the dust condition. Dust may still remain on either the collateral Reserve or debt Reserve if the corresponding debt or collateral Reserve, respectively, is fully exhausted. +Aave V4 introduces a dynamic dust prevention mechanism. If the debt remaining after a standard liquidation is below the `DUST_LIQUIDATION_THRESHOLD` (e.g., `$1_000` in value units), the protocol increases the maximum debt that can be liquidated to allow full repayment, provided the liquidator has indicated intent to fully cover the debt; otherwise, the liquidation reverts under the dust condition. Dust may still remain on either the collateral Reserve or debt Reserve if the corresponding debt or collateral Reserve, respectively, is fully exhausted. A deficit is only reported if, after liquidation, the borrower has no more collateral left across any of their Reserves and debt still remains. @@ -329,6 +335,6 @@ $$ where -- $HF\_LIQ\_THRESHOLD$: configured as a spoke-wide constant. Represents the health factor threshold under which the user becomes liquidatable. Equals 1. +- $HF\_LIQ\_THRESHOLD$: a spoke-wide constant. Represents the health factor threshold under which the user becomes liquidatable. Equals 1. - $hf_{beforeLiq}$: per user. Represents the user’s health factor before liquidation. - $hfForMaxBonus$: per Spoke. Represents the health factor threshold under which the protocol awards the maximum liquidation bonus.