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¶
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:
- accrues interest first, with
ReserveLibrary.updateReserveState, so the change never applies retroactively to interest already earned; - checks the caller holds the role that owns that parameter, reverting
AccessDenied— a parameter in neither bucket revertsInvalidParameter; - applies the parameter's own validation (below);
- recomputes rates and liquidity with
updateInterestRatesAndLiquidity(reserve, rateData, 0, 0), so the new value takes effect immediately rather than at the next user action; - 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
> 0and≤ 1 RAY(100%); - a single update may move it by at most
PrimeRateChangeBPSof one RAY in either direction, otherwise it revertsPrimeRateChangeExceedsLimit; - 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\):
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¶
- User calls
deposit(amount)sending lending asset - A deposit fee (
LendingPoolDepositFee) is transferred to theFeeCollector - 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
MinDepositAmountreverts. - Depositors receive RToken at a 1:1 ratio for each pmUSD supplied (18 decimals).
- The deposit fee is determined by the
LendingPoolDepositFeegovernance 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:
calculateInsuranceFeereturns(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
BorrowThresholdparameter.
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 flaggeddeposit,withdrawAssetandborrowall 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¶
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 currently100_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:
- Contract attempts to withdraw full amount from old vault through
withdrawAll - Calculates liquidity vs yield split
- Distributes funds accordingly to the new vault
- 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.
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
rescueTokencarries no RToken exclusion. - Vault shares — held at rest. These are the pool's position in the yield vault and are tracked by
vaultRewards.totalShares.rescueTokenexcludesaddress(vault)for exactly this reason. - Reserve asset (pmUSD) — held at rest, in one case. Most pmUSD movements are transient within a single transaction:
withdrawFromVaultforwards to the RToken,collectFeepulls and forwards to theFeeCollector. Harvested yield is the exception._harvestYield()redeems yield shares to the pool and books the proceeds withvaultRewards.unclaimedRewards += yieldHarvestedwithout forwarding them;withdrawAlldoes the same for residual yield. The balance sits in the pool untilclaimCollectorRewards()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.