Functions¶
Note
The StabilityPool implements a withdrawal timelock mechanism to prevent panic withdrawals during liquidations.
Users must request withdrawal via requestWithdraw()
before they can withdraw, and a timelock duration must pass before the withdrawal becomes available.
Core Operations¶
deposit
¶
deposit(uint256 scaledAmount)
Summary
Deposits rToken into the StabilityPool and mints deToken 1:1 for the caller.
Updates the LendingPool index to ensure the most up-to-date index is used and cancels any pending withdrawal requests.
Timelock Reset on Deposit
If you have previously requested a withdrawal and your timelock is still active, depositing again will cancel your pending withdrawal request and reset the timelock.
You will need to submit a new withdrawal request and wait the full timelock period before you can withdraw.
Guarded Method
Callable by any address (EOA or contract). Subject to pause and blacklist checks.
Parameters
Name | Type | Description |
---|---|---|
scaledAmount | uint256 | Amount of rToken to deposit (scaled amount from RToken.balanceOf) |
Emits
Deposit(address indexed user, uint256 amount, uint256 deTokenMinted)
Requirements
- User must have sufficient rToken balance
- LendingPool state is updated before deposit
- Any pending withdrawal timelock is cancelled when depositing
Typescript / ethers
Source code
function deposit(uint256 scaledAmount) external nonReentrant whenNotPaused validAmount(scaledAmount) notBlacklisted(msg.sender) {
// Update the LendingPool index to ensure the most up to date index is used
ILendingPool(lendingPool).updateState();
// user has enough balance to cover the scaled amount ?
if (rToken.balanceOf(msg.sender) < scaledAmount){
revert InsufficientBalance();
}
_deposit(scaledAmount);
}
function _deposit(uint256 scaledAmount) internal {
// transfer scaled amount from user to this contract
rToken.safeTransferFrom(msg.sender, address(this), scaledAmount);
// mint deToken to the user (mint raw amount, balanceOf will return scaled amount)
deToken.mint(msg.sender, scaledAmount);
depositBlock[msg.sender] = block.number;
// Cancel any pending withdrawal timelock when depositing
// This prevents users from frontloading withdrawal requests before having balance
if (withdrawTimelock[msg.sender].readyAt != 0) {
delete withdrawTimelock[msg.sender];
emit WithdrawCancelled(msg.sender);
}
emit Deposit(msg.sender, scaledAmount, scaledAmount);
}
requestWithdraw
¶
requestWithdraw(uint256 amount)
Summary
Requests a withdrawal with timelock to prevent panic withdrawals during liquidations.
Users must wait for the timelock duration before they can execute the withdrawal.
Max withdraw
To withdraw your entire balance, use the maximum value (Uint256.max
) as the amount.
This ensures that no dust remains after withdrawal.
Guarded Method
Callable by any user with deToken balance. Subject to pause and blacklist checks.
Parameters
Name | Type | Description |
---|---|---|
amount | uint256 | Amount of deToken to withdraw (0 to cancel request) |
Emits
WithdrawQueued(address indexed user, uint256 amount, uint64 readyAt)
— when withdrawal is queuedWithdrawCancelled(address indexed user)
— when withdrawal is cancelled (amount = 0)
Requirements
- Withdrawal timelock must be enabled (
withdrawTimelockDuration > 0
) - User can cancel existing request by passing
amount = 0
- New request overwrites any existing request
Timelock Logic
readyAt
: Timestamp when withdrawal becomes available (block.timestamp + withdrawTimelockDuration
)expireAt
: Timestamp when withdrawal window expires (block.timestamp + withdrawTimelockDuration
)- User has limited time window to execute withdrawal after timelock expires
Typescript / ethers
Source code
function requestWithdraw(uint256 amount) external nonReentrant whenNotPaused notBlacklisted(msg.sender) {
if (withdrawTimelockDuration == 0) revert TimelockDisabled();
if (amount == 0) {
// cancel the withdraw
delete withdrawTimelock[msg.sender];
emit WithdrawCancelled(msg.sender);
return;
}
WithdrawTimelock storage timelock = withdrawTimelock[msg.sender];
timelock.balanceAtRequest = deToken.balanceOf(msg.sender);
timelock.amount = amount;
timelock.userIndexAtRequest = deToken.getUserIndex(msg.sender);
timelock.indexAtRequest = ILendingPool(lendingPool).getNormalizedIncome();
timelock.readyAt = uint64(block.timestamp + withdrawTimelockDuration);
timelock.expireAt = uint64(block.timestamp + withdrawTimelockDuration);
emit WithdrawQueued(msg.sender, amount, timelock.readyAt);
}
withdraw
¶
withdraw(uint256 deTokenAmount)
Summary
Burns deToken and withdraws the same amount of rToken after the withdrawal timelock has expired.
Includes comprehensive timelock validation and interest calculation logic.
Guarded Method
Callable by any user with deToken balance. Subject to pause, blacklist, and timelock checks.
Parameters
Name | Type | Description |
---|---|---|
deTokenAmount | uint256 | Amount of deToken to burn (should equal rToken amount) |
Emits
Withdraw(address indexed user, uint256 amount, uint256 deTokenBurned)
Requirements
- User must have a valid withdrawal request
- Timelock must be ready and not expired
- User cannot deposit and withdraw in the same block
- LendingPool state is updated before withdrawal
Timelock Validation Logic
The function includes sophisticated timelock validation:
function _withdrawTimelockCheck(address user, uint256 amount) internal returns (uint256 scaledAmount) {
if (withdrawTimelockDuration == 0) {
delete withdrawTimelock[user];
return amount == type(uint256).max ? deToken.balanceOf(user) : amount;
}
WithdrawTimelock storage userTimelock = withdrawTimelock[user];
if (userTimelock.readyAt == 0) revert NoPendingWithdraw();
if (userTimelock.readyAt > block.timestamp) revert WithdrawTimelockNotReady();
if (userTimelock.readyAt + withdrawTimelockDelay <= block.timestamp) revert WithdrawTimelockExpired();
if (userTimelock.amount != amount) revert WithdrawTimelockInvalid();
uint256 currentIndex = ILendingPool(lendingPool).getNormalizedIncome();
uint256 balanceWithInterestCappedAmount;
if (userTimelock.indexAtRequest == 0 || currentIndex == userTimelock.indexAtRequest) {
// max possible amount to cap is the balance by default
balanceWithInterestCappedAmount = userTimelock.balanceAtRequest;
} else {
// raw * (usageIndex / positionIndex)
uint256 indexMultiplier = currentIndex.rayDiv(userTimelock.indexAtRequest);
balanceWithInterestCappedAmount = userTimelock.balanceAtRequest.rayMul(indexMultiplier);
}
// If not, user wants his amount + interest earned since the timelock
scaledAmount = (amount == type(uint256).max) ? balanceWithInterestCappedAmount : amount;
if(scaledAmount > balanceWithInterestCappedAmount) {
// Prevent user to withdraw more than he had
revert WithdrawAmountTooHigh();
}
// Prevent user to withdraw more than he has
if(scaledAmount > deToken.balanceOf(user)) {
scaledAmount = deToken.balanceOf(user);
}
delete withdrawTimelock[user];
}
Interest Calculation
- If no index change: user can withdraw their original balance
- If index increased: user can withdraw original balance + accrued interest
- Interest is calculated as:
rawBalance * (currentIndex / positionIndex)
- User cannot withdraw more than their balance with interest
Typescript / ethers
// First request withdrawal
await stabilityPool.requestWithdraw(parseUnits("500", 18));
// Wait for timelock to expire (check timelock status)
const timelock = await stabilityPool.withdrawTimelock(userAddress);
// Then execute withdrawal
const tx = await stabilityPool.withdraw(parseUnits("500", 18));
await tx.wait();
Source code
function withdraw(uint256 deTokenAmount) external nonReentrant whenNotPaused validAmount(deTokenAmount) notBlacklisted(msg.sender) {
if (isUserDepositInSameBlock(msg.sender)) revert CannotDepositAndWithdrawSameBlock();
// Update the LendingPool index to ensure the most up to date index is used
ILendingPool(lendingPool).updateState();
uint256 scaledAmount = _withdrawTimelockCheck(msg.sender, deTokenAmount);
// Burn raw amount of deToken from user
deToken.burn(msg.sender, scaledAmount);
// Allow to withdraw amount at timelock + its interest
rToken.safeTransfer(msg.sender, scaledAmount);
emit Withdraw(msg.sender, scaledAmount, scaledAmount);
}
Admin Functions¶
collectDust
¶
collectDust(address token, address recipient, uint256 amount)
Summary
Collects dust tokens from the stability pool. Has built-in safety for rToken to prevent accidental removal of user funds.
Guarded Method
Only callable by the contract owner.
Parameters
Name | Type | Description |
---|---|---|
token | address | Address of the token to collect |
recipient | address | Address to receive the dust tokens |
amount | uint256 | Amount of dust to collect |
Emits
DustCollected(address indexed token, address indexed recipient, uint256 amount)
Safety Mechanisms
For rToken (Special Protection):
- Only the delta between deToken total supply and rToken balance can be collected
- This prevents accidental removal of user funds
- Formula: delta = rToken.balanceOf(contract) - deToken.totalSupply()
- If delta > amount
, then delta = amount
(cap at requested amount)
For Other Tokens: - Can be collected in full (use with caution) - No special protection mechanisms
Requirements
- Owner must ensure recipient is a valid address
- For rToken: only excess balance above user deposits can be collected
- For other tokens: full amount can be collected
Typescript / ethers
// Collect dust from rToken (only delta)
const tx = await stabilityPool.collectDust(rTokenAddress, treasuryAddress, parseUnits("100", 18));
await tx.wait();
// Collect dust from other tokens (full amount)
const tx2 = await stabilityPool.collectDust(otherTokenAddress, treasuryAddress, parseUnits("50", 18));
await tx2.wait();
Source code
function collectDust(address token, address recipient, uint256 amount) external onlyOwner nonReentrant {
if (recipient == address(0)) revert InvalidAddress();
if (token == address(rToken)) {
// can take out only the delta between the DEToken and RToken balance;
uint256 delta;
if (deToken.totalSupply() < rToken.balanceOf(address(this))) {
delta = rToken.balanceOf(address(this)) - deToken.totalSupply();
}
if (delta > amount) {
delta = amount;
}
rToken.safeTransfer(recipient, delta);
emit DustCollected(token, recipient, delta);
} else {
IERC20(token).safeTransfer(recipient, amount);
emit DustCollected(token, recipient, amount);
}
}
liquidateBorrower
¶
liquidateBorrower(address poolAdapter, address vaultAdapter, address user, bytes calldata data, uint256 minSharesOut)
Summary
Liquidates a borrower's position by repaying their debt with rToken and handling the collateral through the RWA Vault. This function can only be called via delegatecall from the StabilityPool contract.
Guarded Method
Only callable via delegatecall (proxy pattern). Prevents direct calls to the implementation contract.
Parameters
Name | Type | Description |
---|---|---|
poolAdapter | address | Address of the asset adapter in the lending pool for finalizing liquidation |
vaultAdapter | address | Address of the asset adapter in the vault for storing the asset |
user | address | Address of the borrower to liquidate |
data | bytes | Parameters to identify the asset (tokenId, amount, etc.) |
minSharesOut | uint256 | Minimum shares expected from vault deposit |
Emits
BorrowerLiquidated(address indexed user, address indexed asset, bytes data, uint256 amount)
Process Flow
- Validation: Checks adapter support, debt amount, and data validity
- Liquidation: Calls
finalizeLiquidation
on LendingPool to transfer collateral - Asset Handling: Deposits collateral into RWA Vault to mint iRAAC tokens
- Token Split: Distributes iRAAC according to configured percentages:
- Treasury fee (minting fee)
- Burn (permanent removal)
- Swap (convert to crvUSD): used to clear the debt
- Liquidity rewards (deposit to Curve Gauge)
- Rebalancing: Deposits swapped crvUSD back into LendingPool to restore rToken and repay the lending pool
Requirements
- Pool adapter must be supported by LendingPool
- Vault adapter must be supported by RWA Vault OR asset token must be the underlying vault token
- User must have non-zero debt
- Asset tokens must match between pool and vault adapters
- Liquidation data must be valid
- Stability Pool must have sufficient rToken balance
Error Conditions
Error | Trigger |
---|---|
InvalidAmount |
User's debt is zero |
CollateralAndParamterDataMismatch |
Supplied data is invalid |
AdaptersAssetMismatch |
Pool/vault adapter assets differ |
InsufficientBalance |
Not enough rToken/crvUSD |
ApprovalFailed |
ERC-20 approval returns false |
Direct calls not allowed | Called without proxy |
Typescript / ethers
// Liquidate a borrower position
const liquidationData = ethers.utils.defaultAbiCoder.encode(
["uint256"], // tokenId for NFT or amount for ERC20
[tokenId]
);
const tx = await stabilityPool.liquidateBorrower(
poolAdapterAddress,
vaultAdapterAddress,
borrowerAddress,
liquidationData,
minSharesOut
);
await tx.wait();
Source code
function liquidateBorrower(address poolAdapter, address vaultAdapter, address user, bytes calldata data, uint256 minSharesOut) external onlyProxy {
require(lendingPool.supportedAdapter(poolAdapter), "StabilityPool: Pool Adapter not supported");
require(rwaVault.supportedAdapter(vaultAdapter) || IAssetAdapter(poolAdapter).getAssetToken() == rwaVault.underlyingVaultToken(), "StabilityPool: Vault Adapter not supported");
// Update lending pool state before liquidation
lendingPool.updateState();
// Get the user's debt from the LendingPool.
if (lendingPool.getPositionDebt(poolAdapter, user, data) == 0) revert InvalidAmount();
// If the token that is being liquidated is not the vault token, and does not match an adapter in the system, do not liquidate
if (IAssetAdapter(poolAdapter).getAssetToken() != rwaVault.underlyingVaultToken() && IAssetAdapter(poolAdapter).getAssetToken() != IVaultAssetAdapter(vaultAdapter).getAssetToken()) revert AdaptersAssetMismatch();
// If the user data parameter is wrong, especially in ERC20 amount where amount !== collateral value
if (!IAssetAdapter(poolAdapter).validateLiquidationData(user, data)) revert CollateralAndParamterDataMismatch();
uint256 scaledPositionDebt = lendingPool.getPositionScaledDebt(poolAdapter, user, data);
lendingPool.finalizeLiquidation(poolAdapter, user, data);
_handleLiquidation(poolAdapter, vaultAdapter, data, minSharesOut);
uint256 initialCRVUSDBalance = crvUSDToken.balanceOf(address(this));
uint256 availableRTokens = rToken.balanceOf(address(this));
// We need to get the amount of rToken that is needed to cover the debt, or 0 if the debt is covered
uint256 rTokenAmountRequired = initialCRVUSDBalance >= scaledPositionDebt ? 0 : scaledPositionDebt - initialCRVUSDBalance;
if (availableRTokens < rTokenAmountRequired) revert InsufficientBalance();
// We unwind the position
if (rTokenAmountRequired > 0) {
lendingPool.withdraw(rTokenAmountRequired);
}
crvUSDToken.safeTransfer(address(rToken), scaledPositionDebt);
// Deposit crvUSD back to get rTokens (including the excess)
uint256 finalCRVUSDBalance = crvUSDToken.balanceOf(address(this));
if (finalCRVUSDBalance > 0) {
// Approve lending pool to take crvUSD for deposit
bool approveCRVUSDDeposit = crvUSDToken.approve(address(lendingPool), finalCRVUSDBalance);
if (!approveCRVUSDDeposit) revert ApprovalFailed();
lendingPool.deposit(finalCRVUSDBalance);
}
emit BorrowerLiquidated(user, IAssetAdapter(poolAdapter).getAssetToken(), data, scaledPositionDebt);
}
function _handleLiquidation(address poolAdapter, address vaultAdapter, bytes calldata data, uint256 minSharesOut) internal {
address underlyingVaultToken = rwaVault.underlyingVaultToken();
// check if the adapter asset token is the underlying Vault token. if so, skip the deposit part and proceed with liquidation right away
if (IAssetAdapter(poolAdapter).getAssetToken() != underlyingVaultToken) {
// Take the asset and transfer to the RWA Vault: we might require approval method in the adapter.
// @dev We are using delegatecall because we are abstracting the approval method in the adapter. If we call directly, the context is adapter (approving stability pool from adapter which is insuficient balance)
(bool success, bytes memory returndata) = vaultAdapter.delegatecall(
abi.encodeWithSignature("approve(address,bytes)", vaultAdapter, data)
);
if (!success) {
if (returndata.length == 0) revert();
assembly {
revert(add(32, returndata), mload(returndata))
}
}
// address(this) beacuase when we finalized liquidation, we received the asset.
rwaVault.poolDepositAsset(vaultAdapter, data, address(this), minSharesOut);
}
// calculate the profit split
uint256 indexTokenBalance = IERC20(underlyingVaultToken).balanceOf(address(this));
if (liquidationSplit.mintPercentage + liquidationSplit.burnPercentage + liquidationSplit.swapPercentage + liquidationSplit.liquidityPercentage != 100_00) revert("invalid percentages");
uint256 mintingFee = (indexTokenBalance * liquidationSplit.mintPercentage) / 100_00;
uint256 burnAmount = (indexTokenBalance * liquidationSplit.burnPercentage) / 100_00;
uint256 swapAmount = (indexTokenBalance * liquidationSplit.swapPercentage) / 100_00;
uint256 liquidityAmount = (indexTokenBalance * liquidationSplit.liquidityPercentage) / 100_00;
// handle the balances
if (mintingFee > 0) _handleTreasuryFee(mintingFee);
if (burnAmount > 0) rwaVault.burnVaultToken(address(this), burnAmount);
if (swapAmount > 0) _handleSwap(swapAmount);
if (liquidityAmount > 0) _handleLiquidityRewards(liquidityAmount);
}
function _handleSwap(uint256 amount) internal {
if (liquidationSwap == address(0)) revert("liquidation swap not set");
address underlyingVaultToken = rwaVault.underlyingVaultToken();
// Allow the liquidity pool to transfer the index token
bool approveSuccessIndexToken = IERC20(underlyingVaultToken).approve(liquidationSwap, amount);
if (!approveSuccessIndexToken) revert ApprovalFailed();
ILiquidationSwap(liquidationSwap).swap(amount);
}
function _handleLiquidityRewards(uint256 amount) internal {
if (feeCollector == address(0)) revert("fee collector not set");
address underlyingVaultToken = rwaVault.underlyingVaultToken();
bool approveSuccessIndexToken = IERC20(underlyingVaultToken).approve(feeCollector, amount);
if (!approveSuccessIndexToken) revert ApprovalFailed();
IFeeCollector(feeCollector).collectFee(underlyingVaultToken, address(this), amount, keccak256("RWA_INDEX_TOKEN_LIQUIDITY_REWARDS"));
}
function _handleTreasuryFee(uint256 amount) internal {
address underlyingVaultToken = rwaVault.underlyingVaultToken();
if (address(treasury) == address(0)) revert("treasury not set");
if (amount > 0) {
bool approval = IERC20(underlyingVaultToken).approve(address(treasury), amount);
if (!approval) revert ApprovalFailed();
treasury.deposit(underlyingVaultToken, amount);
}
}
Additional Notes¶
Withdrawal Timelock Configuration¶
The withdrawal timelock system has two configurable parameters:
withdrawTimelockDuration
: Time before withdrawal becomes available (default: 30 minutes)withdrawTimelockDelay
: Time window to execute withdrawal after timelock expires (default: 2 days)
Both parameters are configurable by the owner via:
setWithdrawTimelockDuration(uint256 _withdrawTimelockDuration)
setWithdrawTimelockDelay(uint256 _withdrawTimelockDelay)
Interest Accrual¶
The StabilityPool automatically accrues interest on deposits through the underlying rToken mechanism:
- rToken is yield-bearing and accrues interest from the lending pool
- deToken increases in value as the underlying rToken accrues yield
- Users earn the normal yield from the lending pool while maintaining their position in the StabilityPool
- Interest is calculated using the lending pool's normalized income index
Same-Block Protection¶
The contract prevents users from depositing and withdrawing in the same block:
depositBlock[user]
tracks the block number when user last depositedisUserDepositInSameBlock(user)
checks if user deposited in current block- This prevents potential manipulation or front-running attacks