Skip to content

LendingPool

Overview

The LendingPool is the core contract of the RAAC system.
It manages deposits, withdrawals, borrowing, repayment and liquidation while outsourcing collateral-specific logic to Adapters.
Any token or NFT can be used as collateral as long as a compliant adapter is registered.

Architecture

INHERITED — STORAGE & MIXINS INTEREST MATH & FEE ACCOUNTING SCALED-BALANCE TOKENS EXECUTE IN LendingPool STORAGE EXTERNAL BOUNDARY LendingPoolStorage reserve · rateData · positions · parameters WithCompliance notBlacklisted() modifier OpenZeppelin AccessControl · Pausable · ReentrancyGuard LendingPool reserve accounting · borrow / repay collateral positions · liquidation entry RAACPrimeRateReceiver pushes the US prime rate on-chain ReserveLibrary liquidity + usage indices, rate model, deposit / withdraw accounting WadRayMath ray / wad math PercentageMath basis points LendingPoolFeeLibrary protocol fee + vault reward claims RToken ERC-20, balance = raw × liquidityIndex custodies the reserve asset + buffer DebtToken non-transferable, raw × usageIndex minted / burned per position LiquidationProxy initiate / close / finalize VaultProxy idle liquidity → vault IComplianceRegistry isBlacklisted(user) IAssetAdapter value · custody · position key IBaseVault ERC-4626 subset IBaseCollector collectFee(...) StabilityPool liquidation counterparty ComplianceRegistry blacklist registry ERC20AssetAdapter ERC721AssetAdapter RAACHousePrices IERC721PriceOracle — NAV Yearn V3 vault no strategy → no loss path FeeCollector protocol · deposit · insurance fees Treasury residual vault shares setPrimeRate() using for library call uses mint · burn · transfer getNormalizedIncome() mint · burn (scaled) getNormalizedDebt() delegatecall delegatecall notBlacklisted() custody · valuation idle liquidity fees collateral → pool finalizeLiquidation ⇄ getUserDeposit residual shares

Purpose

  • Hold the main liquidity of the protocol and mint yield-bearing RToken receipts against deposits
  • Abstract collateral management via adapters so the pool logic stays asset agnostic
  • Accrue interest using an index-based model and a prime-rate driven interest curve. Interest is pegged to \(1/2\) US Prime rate.
  • Enforce protocol and risk parameters (caps, thresholds, buffer ratio, etc.)
  • Provide optional insurance with a grace period for under-collateralised positions
  • Integrate with an external vault to harvest yield while keeping a liquidity buffer

Access Control

The LendingPool has no owner. Every privileged entry point is gated by an OpenZeppelin AccessControl role, so the admin surface is split across separate keys instead of concentrating in a single address.

Role Held by Controls
PROTOCOL_ROLE Protocol multisig Every setter and every risk parameter: registerAdapter, unregisterAdapter, setPrimeRateOracle, setStabilityPool, setLiquidationProxy, setVault, setFeeCollector, claimProtocolFees, rescueToken, and the 13 risk entries of setParameter
GOVERNANCE_ROLE Governance → TimelockController The four fee parameters, and nothing else: InsuranceFee, ProtocolFeeRate, LendingPoolDepositFee, VaultOpeningFee
EMERGENCY_ROLE Hypernative, and the protocol multisig pause() and unpause(), and nothing else
DEFAULT_ADMIN_ROLE Protocol multisig Grants and revokes the three roles above

The split follows what each control actually is. Fees are the one thing users pay that the token holders have a claim on, so fees are the one thing behind a vote and a timelock. Everything else is a risk control (caps, thresholds, the grace period, the buffer ratio, the oracle and proxy wiring) that has to move at operational speed, and is held by the protocol.

EMERGENCY_ROLE is held by Hypernative

Pause and unpause are carved out of the rest of the admin surface so that they can be handed to Hypernative, giving the pool active pausability: Hypernative monitors it continuously and can halt it the moment its detection fires against a live threat, without waiting for a multisig to assemble.

The role carries no other power. An EMERGENCY_ROLE key cannot change a parameter, re-point a contract or move funds; the worst it can do is stop the pool, and it cannot restart it into any state other than the one it stopped.

The constructor grants all four roles to the deployment admin; the deployer then hands GOVERNANCE_ROLE, PROTOCOL_ROLE, EMERGENCY_ROLE and finally DEFAULT_ADMIN_ROLE to the multisig, and EMERGENCY_ROLE is granted to Hypernative alongside it.

setParameter still takes an OwnerParameter enum

The enum name is a leftover from the Ownable version of the contract and is kept for ABI compatibility. It no longer implies an owner: the caller check on every branch is hasRole.


Parameters

All mutable parameters are changed through a single entry point, setParameter(OwnerParameter param, uint256 newValue). There are 17 of them, split across two roles.

Every call, whichever parameter it targets:

  1. accrues interest first, with ReserveLibrary.updateReserveState, so the change never applies retroactively to interest already earned;
  2. checks the caller holds the role that owns that parameter, reverting AccessDenied — a parameter in neither bucket reverts InvalidParameter;
  3. applies the parameter's own validation (below);
  4. recomputes rates and liquidity with updateInterestRatesAndLiquidity(reserve, rateData, 0, 0), so the new value takes effect immediately rather than at the next user action;
  5. emits ParameterChanged(param, oldValue, newValue).

setParameter is not blocked by the pause, and on the contract itself there is no timelock — a change takes effect in the transaction that makes it. The lead time before that transaction comes from who holds the role, and the two buckets differ:

Bucket Role holder Lead time before a change can land
Governance — InsuranceFee, ProtocolFeeRate, LendingPoolDepositFee, VaultOpeningFee Governance → TimelockController A proposal must be voted and queued, then wait out the timelock delay, before the call executes
Protocol (risk) — the other 13 PROTOCOL_ROLE operator None: the call executes when submitted

The governance path. A change to a governance parameter is a proposal, and it clears four timed stages before the setParameter call can execute:

# Stage Governed by Default Range
1 Proposal is on-chain; voting has not opened votingDelay 1 day 1 hour – 2 weeks
2 Voting is open votingPeriod 7 days 1 day – 4 weeks
3 Approved call is queued in TimelockController _minDelay 2 days 2 days – 30 days
4 Execution window; after it the call expires GRACE_PERIOD 14 days fixed constant

Stages 1–3 are the lead time. At the default values a fee change is visible on-chain for 10 days before it can take effect, and stage 3 cannot be skipped: _minDelay is fixed at deployment and the constructor reverts on anything outside [2 days, 30 days].

Stage 4 is a deadline rather than a delay — a queued call that is not executed within 14 days expires and has to be proposed again.

The protocol path. Risk parameters move without that lead time, by design — see Risk-parameter changes take effect immediately.

Protocol (risk) parameters — PROTOCOL_ROLE

Early Beta

These parameters can be adjusted by the protocol to optimize system performance and risk management.

Enum Unit Validation on change Description
LendingPoolBorrowCap reserve asset > 0 and ≤ supplyCap Maximum total debt the pool can create
LendingPoolSupplyCap reserve asset > 0 and ≥ borrowCap Maximum total deposits the pool can accept
BorrowThreshold bp < liquidationThreshold % of collateral value that can be borrowed
LiquidationThreshold bp > borrowThreshold % at which a position becomes liquidatable
LiquidationGracePeriod seconds ≥ 1 day and ≤ 7 days Time given to insured users to repay
LiquidityBufferRatio bp none Share of total liquidity kept on-chain rather than in the vault
WithdrawalStatus 0 / 1 ≤ 1 1 pauses user withdrawals, 0 un-pauses
CanPaybackDebt 0 / 1 ≤ 1 Allows third-party repayments
MaxRate RAY none Borrow rate at 100% utilisation
MinDepositAmount reserve asset > 0 Smallest accepted deposit()
OptimalUtilizationRate RAY none Utilisation at which the rate curve changes slope
PrimeRateChangeBPS bp none Largest prime-rate move the oracle may push in one update
MinVaultRebalanceAmount reserve asset none Below this, a rebalance is skipped rather than executed

Governance parameters — GOVERNANCE_ROLE

Enum Unit Validation on change Description
InsuranceFee bp none Fee charged when a user buys insurance
ProtocolFeeRate RAY none Interest spread captured by the protocol
LendingPoolDepositFee bp none Fee on deposits
VaultOpeningFee reserve asset none Fixed fee to open a collateral vault

Fee and rate parameters are unbounded in the contract

InsuranceFee, ProtocolFeeRate, LendingPoolDepositFee, VaultOpeningFee, LiquidityBufferRatio, MaxRate and OptimalUtilizationRate are written without an upper bound. Nothing in setParameter stops a basis-point fee being set above 100_00, or LiquidityBufferRatio above 100%.

These are role-gated and the roles are held by the protocol, so the bound is a trust assumption on the role holders, not a contract-enforced invariant. Reviewers should treat the PROTOCOL_ROLE and GOVERNANCE_ROLE holders as trusted, and integrators should read the live values rather than assume a range. See Parameters — Core V1 for the values actually in force.

oldValue is reported as 0 for the two boolean parameters

WithdrawalStatus and CanPaybackDebt write straight to their boolean field and never populate the local oldValue, so ParameterChanged always carries oldValue = 0 for those two. Off-chain monitoring should read the previous state from parameters() rather than from the event.

The prime rate is not a setParameter value

primeRate is moved by setPrimeRate(uint256), callable only by primeRateOracle — the CRE receiver RAACPrimeRateReceiver (docs), and is bounded on both sides:

  • it must be > 0 and ≤ 1 RAY (100%);
  • a single update may move it by at most PrimeRateChangeBPS of one RAY in either direction, otherwise it reverts PrimeRateChangeExceedsLimit;
  • interest is accrued at the old rate before the change lands.

Applying it re-derives baseRate (25% of prime) and optimalRate (50% of prime). maxRate is not re-derived on-chain.

It is derived off-chain from the same prime rate, as the top of the curve implied by the penalty factor \(u = 4.00\) and the kink \(U_T = 0.80\):

\[ \text{maxRate} = R_b^{\text{target}} + u \times (1 - U_T) = 0.5 \times \text{primeRate} + 0.80 \]

where \(R_b^{\text{target}}\) is optimalRate. At a 7.25% prime rate that is 83.63%.

How the update reaches the chain. The prime rate is delivered by a Chainlink CRE workflow driven from RAAC's AWS infrastructure: a daily Lambda diffs the published US Prime Rate against the on-chain value and, when they differ, sends a signed HTTP trigger to the CRE gateway; the DON re-fetches the rate itself and writes it through the Forwarder, so a change lands within 24 hours of it moving. See RAAC US Prime Rate Oracle. Because maxRate is derived from the prime rate but lives in a separate PROTOCOL_ROLE parameter, the same pipeline updates it too — as a second, distinct transaction:

# Transaction Function Caller
1 Prime rate setPrimeRate(newPrimeRate) RAACPrimeRateReceiver (Chainlink Forwarder → CRE report)
2 Max rate setParameter(MaxRate, newMaxRate) PROTOCOL_ROLE

The two transactions are not atomic

Between transaction 1 and transaction 2 the curve is internally inconsistent: baseRate and optimalRate already reflect the new prime rate while maxRate still reflects the old one. The borrow rate above OptimalUtilizationRate interpolates towards maxRate, so during that window the rate at high utilisation is derived from a stale ceiling.

The exposure is bounded by how far one update can move the prime rate — at most PrimeRateChangeBPS (currently 2%) of one RAY — and by the gap between the two transactions, which is a single AWS-side sequence rather than a manual step. Reviewers should treat the ordering as an operational guarantee, not a contract-enforced one: nothing on-chain requires transaction 2 to follow transaction 1, and nothing rejects a MaxRate that is inconsistent with the current prime rate.

The Reserve Asset

The reserve asset is a deployment parameter, not a property of the design. LendingPool fixes it at construction and never assumes which token it is: RToken, DebtToken and DEToken all take their decimals() from it, and the caps and minimums (supplyCap, borrowCap, vaultOpeningFee, minDepositAmount, minVaultRebalanceAmount) are scaled by it in the constructor.

Standing up a pool against a different asset therefore changes almost nothing in the pool itself — the piece that has to change is the price oracle, since valuation is the only place the asset's identity enters. The current deployment uses pmUSD.

18 decimals is required, but nothing enforces it

MIN_BORROW_AMOUNT is a hardcoded 10e18 in LendingPoolStorage and is not scaled by the reserve asset's decimals, unlike every other minimum. Its comment reads "10 reserveAsset units", which is only true at 18 decimals — against a 6-decimal asset the same constant would demand 1012 tokens.

No constructor check rejects a reserve asset with other than 18 decimals. Treat 18 decimals as a deployment precondition the operator must satisfy, not an invariant the contract holds. (SToken enforces this explicitly with InvalidDecimals; the pool path does not.)

CrvUSDToUSDOracle is not crvUSD-specific

The oracle contract and its interface ICrvUSDToUSDOracle implement a generic asset/USD price feed with a multi-tier fallback — the name predates the pmUSD rename and was never updated. PmUSDToUSDOracle is the deployed instance and is a thin subclass that adds no logic. Consumers (LiquidationSwap, RWAIndexTokenOracle, RAACHousePrices) still type it as ICrvUSDToUSDOracle; that is a naming artefact, not a dependency on crvUSD.

Collateral Adapters

To ensure flexibility and support for future RWA assets as collateral, RAAC uses a system of adapters. New assets can either use existing adapters or have new ones created to suit their specific requirements (e.g., ERC1155, ERC4626).
Each collateral type is integrated via an Adapter that implements the IAssetAdapter interface.
Adapters handle the translation of generic pool calls (depositAsset, withdrawAsset, borrow, repay, etc.) into asset-specific logic.
Only adapters whitelisted by PROTOCOL_ROLE through registerAdapter or removed via unregisterAdapter can be used.

Two adapter families — don't confuse them

The codebase has two unrelated adapter interfaces, and a liquidation touches both:

Interface Purpose Deployed
Pool side IAssetAdapter Custody and valuation of borrower collateral held by the LendingPool ERC20AssetAdapter, ERC721AssetAdapter
Vault side IVaultAssetAdapter Custody of seized collateral inside the RWA Vault RAACNFTVaultAdapterV2 only

LiquidationStrategyProxy.liquidateBorrower takes one of each (poolAdapter, vaultAdapter) and checks they agree on the underlying asset. No ERC-20 vault adapter is deployed — ERC20VaultAdapter exists in the tree but is not part of the system, so the vault side is always the ERC-721 path.

Core Mechanics

Deposits & RToken minting

  1. User calls deposit(amount) sending lending asset
  2. A deposit fee (LendingPoolDepositFee) is transferred to the FeeCollector
  3. Net amount is supplied and the user receives RToken

Same-block transfers are blocked

After depositing the main asset and receiving newly minted RToken, you cannot transfer those tokens to another address within the same transaction/block. Avoid attempting an RToken transfer in the same transaction as your deposit.

Implementation Notes

  • A deposit below MinDepositAmount reverts.
  • Depositors receive RToken at a 1:1 ratio for each pmUSD supplied (18 decimals).
  • The deposit fee is determined by the LendingPoolDepositFee governance parameter.

Vault Position & Collateral

Before interacting with any adapter the user must pay the one-off VaultOpeningFee via openVaultPosition(). This is one time opening fee.

Vault Fee (Early Beta)

At the early beta, there is no vault fee. Although this may change in the future—consider checking the protocol parameters for the most up-to-date information.

Collateral is then managed with:

Function Description
depositAsset(adapter, data) Supplies collateral through the adapter
withdrawAsset(adapter, data) Withdraws collateral; reverts if health factor would fall below liquidation threshold

Borrowing

Function Description
borrow(adapter, data, amount) Standard borrow; reverts if resulting health factor < threshold
borrowWithInsurance(adapter, data, amount) Borrows and marks the whole position insured. Fee = (uninsuredDebt + amount) * InsuranceFee, charged on principal (see below)

Insurance Fee Application & Forfeiture

  • The insurance fee is a percentage-based fee applied to the entire borrowed capital at the time of insuring.
  • Charging on the whole outstanding balance rather than on the new borrow alone is deliberate: calculateInsuranceFee returns (uninsuredBalance + amount).percentMul(insuranceFee). Charging only the increment let a borrower insure a large position for the price of a small top-up, which was reported and fixed (ALL-V1 · 2025-05, H-20 / H-21). It is a re-quote of the whole cover, not a double charge on the part already insured.
  • If you perform a new borrow and do not insure it, any existing insurance on your position is forfeited. To regain insurance, you must pay the insurance fee on the entire outstanding debt (not just the new amount).
  • Repaid borrows lose insurance: Once a borrow is repaid, any insurance associated with that debt is voided and does not carry over to future borrows.
  • Always ensure you understand the insurance status of your position before borrowing or repaying to avoid unexpected forfeiture or additional fees.

The insurance premium is charged on principal, not on accrued interest

getUninsuredBalance is rawDebtBalance - rawInsuredBalance, and accrued interest only enters rawDebtBalance when the debt is next minted or burned. The quote is taken before _borrow capitalises that interest, and rawInsuredBalance only grows by uninsuredBalance + amount, so the interest capitalised during the call stays uninsured on the ledger.

  • First insured borrow: fee on the amount borrowed.
  • Second insured borrow: fee on the new amount only. Interest accrued since the first borrow is capitalised during this call but is not in the quote.
  • Each later insured borrow: fee on the new amount plus the interest capitalised at the previous borrow or repay.

Insurance is position-level: isInsured grants the grace period to the whole debt, including interest that was never charged. If no further insured borrow happens, the premium on that interest is waived, not deferred. This is intended; see Insured positions are protected for the full grace period.

Example (3% fee): insured borrow of 1,000 charges 30. After 100 of interest, an insured borrow of 10 charges 0.3, leaving raw debt 1,110 and raw insured 1,010. The next insured borrow of 10 charges 3% on 110.

Validation

  • Pool utilization must remain below LendingPoolBorrowCap.
  • Sufficient collateral must be provided, as determined by the BorrowThreshold parameter.

Insurance Flow

  • The default liquidation grace period is 72 hours (LiquidationGracePeriod).
  • Exiting liquidation takes both steps: repay the debt in full and call closeLiquidation. Repaying alone leaves the position flagged, and while it is flagged deposit, withdrawAsset and borrow all revert — the collateral stays locked until the position is closed.
  • Any new borrowing must also be insured; if not, any existing insurance on the position is forfeited.
  • If you opt out of insurance and later wish to re-enable it, you must pay the insurance fee on the entire outstanding debt.

Repayment

Repayment Paused During Protocol Pause

If the protocol is paused and repayments are disabled, users who are under liquidation cannot repay their debt to exit liquidation.
In such cases the protocol can extend the liquidation grace period — setParameter(LiquidationGracePeriod, …), a PROTOCOL_ROLE call, not a governance proposal, so that borrowers are not finalised over a window in which repayment was impossible. Both closeLiquidation and finalizeLiquidation compare against position.liquidationStartTime + parameters.liquidationGracePeriod read live, so an extension applies to positions that are already flagged. The parameter is capped at 7 days, so the deadline can never be pushed beyond 7 days after the position was flagged; a pause outlasting that window cannot be covered by an extension alone.

Function Description
repay(adapter, data, amount) Self repayment
repayOnBehalf(adapter, data, amount, user) Third-party repayment; amount must be ≥ 1% of the user's scaled debt

Operation Restrictions During Liquidation

While isUnderLiquidation is true, depositAsset, withdrawAsset and borrow revert unconditionally — CannotDepositWhenUnderLiquidation(), CannotWithdrawUnderLiquidation(), CannotBorrowUnderLiquidation(). The insurance-and-grace-period exception applies to repayment only: repay and repayOnBehalf revert CannotRepayUnderLiquidationWithoutInsurance() if the position is uninsured, and GracePeriodExpired() once the grace period has elapsed.

Minimum Repayment by Third Parties

When a third party uses repayOnBehalf, they must repay at least 1% of the user's current outstanding debt (including any accrued interest).
Attempts to repay less than this minimum will revert. This ensures meaningful repayments and prevents dust attacks.

Use type(uint256).max for full repayment

Only type(uint256).max resolves to the live debt. An exact amount that exceeds the debt at execution reverts, which can happen if a third-party repayOnBehalf lands first. Near the end of a grace period this can leave no time to retry, so repay in full with type(uint256).max.

Minimum Residual Debt on Repayment

When making a repayment, if the remaining debt after repayment would fall below the protocol's MIN_BORROW_AMOUNT, the transaction will revert with a ResidualCannotBeRepaid error.
In such cases, you must fully repay the outstanding debt in a single transaction to close your position.
This prevents leaving "dust" borrows below the minimum threshold and ensures protocol integrity.

Liquidation lifecycle

Stage Callable by Function Notes
Start Anyone initiateLiquidation (proxy) Marks position under liquidation when health factor < 1
Close Borrower closeLiquidation (proxy) Can be executed during grace period if the debt is fully repaid (requires insurance)
Finalize StabilityPool finalizeLiquidation (proxy) After grace period, transfers collateral & settles debt

All three functions are executed via delegate-call proxies (liquidationProxy) to keep LendingPool byte-code small.

Decision Flow

ENTRY — PERMISSIONLESS CHECK STAGE 1 — FLAG · ANY CALLER STAGE 2 — THE RACE STAGE 3 — TERMINAL health factor < 1e18 ? no Position stays active no state change · nothing to call yes initiateLiquidation() isUnderLiquidation = true liquidationStartTime = block.timestamp repay · repayOnBehalf borrower drives debt toward zero positionDebt == 0 ? yes no position.isInsured ? yes wait out the grace period LiquidationGracePeriod — 3 days default no closeLiquidation() Borrower only flag cleared · insurance reset · collateral kept finalizeLiquidation() StabilityPool only collateral seized · debt burned

What each gate actually checks

Gate Enforced in Rule Reverts with
Flag the position initiateLiquidation Health factor must be below 1e18, and the position must not already be flagged HealthFactorTooHigh() · UserAlreadyUnderLiquidation()
Borrower exit closeLiquidation positionDebt must be exactly zero DebtNotZero() · NotInsured() · GracePeriodExpired()
Seizure finalizeLiquidation Blocked only while the position is insured and still inside the grace period GracePeriodNotExpired()

The grace period only exists for insured positions

finalizeLiquidation reverts on position.isInsured && block.timestamp <= liquidationStartTime + LiquidationGracePeriod. An uninsured position fails that condition on the first clause, so the StabilityPool can finalise it in the block immediately after initiateLiquidation — there is no waiting period to race against.

closeLiquidation reduces to a single condition

The grace-period and insurance checks both carry && positionDebt > 0, and a DebtNotZero() check follows them. Any position with outstanding debt reverts on one of the three; any position with zero debt passes all three. The effective rule is therefore debt == 0, whatever the insurance flag or the elapsed time says.

Zero debt blocks seizure in the caller, not in the pool

finalizeLiquidation does not check the debt: at zero debt it would unflag, transfer the collateral and burn nothing. The guard is in the StabilityPool's liquidateBorrower, which reverts InvalidAmount() when getPositionDebt is zero. Since that is the only route into finalizeLiquidation (onlyStabilityPool), a fully repaid position is not seizable in practice — but the property is enforced by the caller, not by the LendingPool.

The health factor is never re-checked after flagging

finalizeLiquidation validates the adapter, the liquidation flag and the grace period — but not the health factor. A position whose collateral recovers after being flagged is still seizable unless the borrower zeroes the debt and calls closeLiquidation.

NFT positions can be flagged while a price update is pending

initiateLiquidation values NFT collateral with RAACHousePrices.getLatestPrice, which ignores isPendingRequest. A position can therefore be flagged on the current on-chain price even if the pending valuation would make it healthy, and since the flag is never re-evaluated, an uninsured borrower cannot repay out of it. This is intended: once the health factor is below 1 the position is liquidatable, and the pending valuation can just as well come in lower, so blocking would only delay liquidation and add risk for lenders. A failed request would also leave the pending flag set and block liquidation of that house indefinitely. The protocol's risk monitoring calls initiateLiquidation as soon as a position becomes liquidatable if no one else has.

Pausing the pool breaks the race asymmetrically

repay, repayOnBehalf and closeLiquidation are all whenNotPaused; finalizeLiquidation is not. Pausing the LendingPool therefore removes the borrower's only exit while leaving the StabilityPool's seizure path open. This is intentional — see Design Decisions — and is compensated operationally by extending LiquidationGracePeriod to cover any window in which repayment was disabled.

Liquidity Buffer & Vault

Idle reserve liquidity is parked in a Yearn V3 vault configured with no strategy, reached through VaultProxy.
Because no strategy is attached there is no loss path: the vault only accrues, and share price is expected to increase monotonically. It imposes no deposit or withdrawal limits.

Liquidity Rebalancing

  • Target allocation is set by LiquidityBufferRatio: the share of total liquidity held on-chain in the RToken, with the remainder deposited into the vault. It is currently 100_00 (100% on-chain — the vault is not in use); see Parameters — Core V1 for the live value.
  • Triggered after every deposit, withdraw, repay, borrow, or liquidation
  • Yield is harvested on deposit/withdraw, prioritising the withdraw amount
  • Full yield extraction attempted on every rebalance; any leftover is harvested later

Vault Migration Process

When PROTOCOL_ROLE sets a new vault with setVault:

  1. Contract attempts to withdraw full amount from old vault through withdrawAll
  2. Calculates liquidity vs yield split
  3. Distributes funds accordingly to the new vault
  4. Failsafe: if any vault shares remain, they are sent to Treasury

Functions

Every external function — user operations, administration, configuration and views — is documented with signatures, parameters, errors and source on the dedicated page.

→ LendingPool — Functions

What the LendingPool actually custodies

Worth stating plainly, because the exclusion list in rescueToken only makes sense against it:

  • RToken — never held. RToken is minted directly to depositors and the underlying is custodied by the RToken contract itself. The pool holds no RToken at any point, which is why rescueToken carries no RToken exclusion.
  • Vault shares — held at rest. These are the pool's position in the yield vault and are tracked by vaultRewards.totalShares. rescueToken excludes address(vault) for exactly this reason.
  • Reserve asset (pmUSD) — held at rest, in one case. Most pmUSD movements are transient within a single transaction: withdrawFromVault forwards to the RToken, collectFee pulls and forwards to the FeeCollector. Harvested yield is the exception. _harvestYield() redeems yield shares to the pool and books the proceeds with vaultRewards.unclaimedRewards += yieldHarvested without forwarding them; withdrawAll does the same for residual yield. The balance sits in the pool until claimCollectorRewards() transfers it out.

rescueToken excludes the vault share, but not the reserve asset

Because harvested yield rests in the pool, the pool's pmUSD balance between a harvest and a claim equals vaultRewards.unclaimedRewards. rescueToken can move that balance, and it does not decrement the counter — after which claimCollectorRewards() would attempt to transfer pmUSD the pool no longer holds.

This is the residual half of a finding whose vault-share half was fixed (v4 · 2026-02, L-04). Treat rescueToken as a PROTOCOL_ROLE-trusted recovery path that must not be pointed at the reserve asset while vault rewards are unclaimed.

Design Decisions & Accepted Trade-offs

Behaviours that a security review raised and the protocol accepted as design — rather than fixed — are documented separately, so that reviewers can tell a deliberate trade-off from a defect.

→ LendingPool — Design Decisions & Accepted Trade-offs

Running the deployer locally

The full core deployment can be replayed against a mainnet fork, which is the quickest way to get a LendingPool wired to real adapters, oracles and the vault. Set the environment up first (.env with HARDHAT_FORK_URL pointing at a mainnet RPC, plus the deployment wallet keys), then:

U=$(grep -m1 '^HARDHAT_FORK_URL=' .env | cut -d= -f2- | tr -d '"'\''\r')
FORK_RPC_URL="$U" FORK_CHAIN_ID=1 npx hardhat run deployment/deploy_local.js

The first line reads HARDHAT_FORK_URL out of .env and strips quotes and any trailing carriage return, so the URL can be exported as FORK_RPC_URL for the run. FORK_CHAIN_ID=1 forks Ethereum mainnet.

Notes

  • Fee Collector is always set at time of deployment. It is not expected to be removed.