Skip to content

Commit

Permalink
feat: Add delayed redemptions
Browse files Browse the repository at this point in the history
  • Loading branch information
bingen committed May 20, 2024
1 parent a5fc3be commit be77d1f
Show file tree
Hide file tree
Showing 19 changed files with 674 additions and 1,406 deletions.
11 changes: 7 additions & 4 deletions contracts/src/BoldToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ pragma solidity 0.8.18;
import "./Dependencies/Ownable.sol";

import "./Interfaces/IBoldToken.sol";

// import "forge-std/console2.sol";

/*
*
* Based upon OpenZeppelin's ERC20 contract:
Expand Down Expand Up @@ -125,7 +128,7 @@ contract BoldToken is Ownable, IBoldToken {
}

function sendToPool(address _sender, address _poolAddress, uint256 _amount) external override {
_requireCallerIsStabilityPool();
_requireCallerIsCRorSP();
_transfer(_sender, _poolAddress, _amount);
}

Expand Down Expand Up @@ -277,12 +280,12 @@ contract BoldToken is Ownable, IBoldToken {
require(
msg.sender == collateralRegistryAddress || borrowerOperationsAddresses[msg.sender]
|| stabilityPoolAddresses[msg.sender],
"Bold: Caller is neither BorrowerOperations nor TroveManager nor StabilityPool"
"Bold: Caller is neither the Registry nor BorrowerOperations nor TroveManager nor StabilityPool"
);
}

function _requireCallerIsStabilityPool() internal view {
require(stabilityPoolAddresses[msg.sender], "Bold: Caller is not the StabilityPool");
function _requireCallerIsCRorSP() internal view {
require(msg.sender == collateralRegistryAddress || stabilityPoolAddresses[msg.sender], "Bold: Caller is neither the Registry nor the StabilityPool");
}

function _requireCallerIsTroveMorSP() internal view {
Expand Down
157 changes: 130 additions & 27 deletions contracts/src/CollateralRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
ITroveManager internal immutable troveManager9;

IBoldToken public immutable boldToken;
address public immutable interestRouterAddress;

uint256 public constant SECONDS_IN_ONE_MINUTE = 60;

Expand All @@ -58,22 +59,44 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
*/
uint256 public constant BETA = 2;

// Redemption interval for executing committed redemptions in seconds
uint256 public constant REDEMPTION_INTERVAL_MIN = 60;
uint256 public constant REDEMPTION_INTERVAL_MAX = 300;
// Penalty if the redemption is not executed during the interval
uint256 public constant REDEMPTION_FAILURE_PENALTY = DECIMAL_PRECISION / 1000 * 5; // 0.5%

uint256 public baseRate;

// The timestamp of the latest fee operation (redemption or new Bold issuance)
uint256 public lastFeeOperationTime;

// Total bold hold by Redemption commitments
uint256 public boldRedemptionCommitments;

struct RedemptionCommitment {
uint256 boldAmount;
uint64 timestamp;
uint64 maxIterationsPerCollateral;
uint64 maxFeePercentage;
}

// Account => index => commitment
mapping (address => mapping (uint256 => RedemptionCommitment)) redemptionCommitments;

event BaseRateUpdated(uint256 _baseRate);
event LastFeeOpTimeUpdated(uint256 _lastFeeOpTime);
event RedemptionCommited(uint256 _redemptionId, uint256 _boldAmount, uint256 _maxFeePercentage);
event RedemptionWithdrawn(uint256 _redemptionId, uint256 _redemptionRefund, uint256 _penalty);

constructor(IBoldToken _boldToken, IERC20[] memory _tokens, ITroveManager[] memory _troveManagers) {
constructor(IBoldToken _boldToken, address _interestRouterAddress, IERC20[] memory _tokens, ITroveManager[] memory _troveManagers) {
uint256 numTokens = _tokens.length;
require(numTokens > 0, "Collateral list cannot be empty");
require(numTokens < 10, "Collateral list too long");
require(numTokens == _troveManagers.length, "List sizes mismatch");
totalCollaterals = numTokens;

boldToken = _boldToken;
interestRouterAddress = _interestRouterAddress;

token0 = _tokens[0];
troveManager0 = _troveManagers[0];
Expand Down Expand Up @@ -111,19 +134,36 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
emit BaseRateUpdated(INITIAL_REDEMPTION_RATE);
}

// _redemptionId is per user
function commitRedemption(uint256 _redemptionId, uint256 _boldAmount, uint64 _maxIterationsPerCollateral, uint64 _maxFeePercentage) external override {
_requireValidRedemptionId(msg.sender, _redemptionId);
_requireValidMaxFeePercentage(_maxFeePercentage);
_requireAmountGreaterThanZero(_boldAmount);
_requireBoldBalanceCoversRedemption(boldToken, msg.sender, _boldAmount);

redemptionCommitments[msg.sender][_redemptionId] = RedemptionCommitment(_boldAmount, uint64(block.timestamp), _maxIterationsPerCollateral, _maxFeePercentage);

// Account for the committed amount
boldRedemptionCommitments += _boldAmount;

// Get Bold from redeemer
boldToken.sendToPool(msg.sender, address(this), _boldAmount);

emit RedemptionCommited(_redemptionId, _boldAmount, _maxFeePercentage);
}

struct RedemptionTotals {
uint256 numCollaterals;
uint256 boldSupplyAtStart;
uint256 unbacked;
uint256 redeemedAmount;
}

function redeemCollateral(uint256 _boldAmount, uint256 _maxIterationsPerCollateral, uint256 _maxFeePercentage)
external
{
_requireValidMaxFeePercentage(_maxFeePercentage);
_requireAmountGreaterThanZero(_boldAmount);
_requireBoldBalanceCoversRedemption(boldToken, msg.sender, _boldAmount);
// _redemptionId is per user
function executeRedemption(uint256 _redemptionId) external override {
RedemptionCommitment memory redemptionCommitment = redemptionCommitments[msg.sender][_redemptionId];
_requireValidCommitment(redemptionCommitment);
_requireValidTime(redemptionCommitment);

RedemptionTotals memory totals;

Expand All @@ -138,8 +178,8 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
// because the final redeemed amount may be less than the requested amount
// Redeemers should take this into account in order to request the optimal amount to not overpay
uint256 redemptionRate =
_calcRedemptionRate(_getUpdatedBaseRateFromRedemption(_boldAmount, totals.boldSupplyAtStart));
require(redemptionRate <= _maxFeePercentage, "CR: Fee exceeded provided maximum");
_calcRedemptionRate(_getUpdatedBaseRateFromRedemption(redemptionCommitment.boldAmount, totals.boldSupplyAtStart));
require(redemptionRate <= redemptionCommitment.maxFeePercentage, "CR: Fee exceeded provided maximum");
// Implicit by the above and the _requireValidMaxFeePercentage checks
//require(newBaseRate < DECIMAL_PRECISION, "CR: Fee would eat up all collateral");

Expand All @@ -156,29 +196,65 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
}

// The amount redeemed has to be outside SPs, and therefore unbacked
assert(totals.unbacked >= _boldAmount);
assert(totals.unbacked >= redemptionCommitment.boldAmount);

// Compute redemption amount for each collateral and redeem against the corresponding TroveManager
for (uint256 index = 0; index < totals.numCollaterals; index++) {
//uint256 unbackedPortion = unbackedPortions[index];
if (unbackedPortions[index] > 0) {
uint256 redeemAmount = _boldAmount * unbackedPortions[index] / totals.unbacked;
uint256 redeemAmount = redemptionCommitment.boldAmount * unbackedPortions[index] / totals.unbacked;
if (redeemAmount > 0) {
ITroveManager troveManager = getTroveManager(index);
uint256 redeemedAmount = troveManager.redeemCollateral(
msg.sender, redeemAmount, prices[index], redemptionRate, _maxIterationsPerCollateral
msg.sender, redeemAmount, prices[index], redemptionRate, redemptionCommitment.maxIterationsPerCollateral
);
totals.redeemedAmount += redeemedAmount;
}
}
}

_updateBaseRateAndGetRedemptionRate(totals.redeemedAmount, totals.boldSupplyAtStart);

// Burn the total Bold that is cancelled with debt
if (totals.redeemedAmount > 0) {
boldToken.burn(msg.sender, totals.redeemedAmount);
// We are calling again _getUpdatedBaseRateFromRedemption inside, but redeemedAmount may be different
// That means that the effective rate payed may be greater than it should be from the final amount
// See comment above, for `redemptionRate` declaration
_updateBaseRateAndGetRedemptionRate(totals.redeemedAmount, totals.boldSupplyAtStart);
boldToken.burn(address(this), totals.redeemedAmount);
}

// Send leftovers back to redeemer
if (redemptionCommitment.boldAmount > totals.redeemedAmount) {
boldToken.transfer(msg.sender, redemptionCommitment.boldAmount - totals.redeemedAmount);
}

// Update accountancy of commitments
boldRedemptionCommitments -= redemptionCommitment.boldAmount;

// Wipe out commitment from mapping
delete(redemptionCommitments[msg.sender][_redemptionId]);
}

// _redemptionId is per user
function withdrawRedemption(uint256 _redemptionId) external override {
RedemptionCommitment memory redemptionCommitment = redemptionCommitments[msg.sender][_redemptionId];
_requireValidCommitment(redemptionCommitment);

uint256 penalty = redemptionCommitment.boldAmount * REDEMPTION_FAILURE_PENALTY / DECIMAL_PRECISION;
uint256 redemptionRefund = redemptionCommitment.boldAmount - penalty;

// Send refund to redeemer
boldToken.transfer(msg.sender, redemptionRefund);

// Send penalty to yield router
boldToken.transfer(interestRouterAddress, penalty);

// Update accountancy of commitments
boldRedemptionCommitments -= redemptionCommitment.boldAmount;

// Wipe out commitment from mapping
delete(redemptionCommitments[msg.sender][_redemptionId]);

emit RedemptionWithdrawn(_redemptionId, redemptionRefund, penalty);
}

// --- Internal fee functions ---
Expand All @@ -193,8 +269,8 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
}
}

function _minutesPassedSinceLastFeeOp() internal view returns (uint256) {
return (block.timestamp - lastFeeOperationTime) / SECONDS_IN_ONE_MINUTE;
function _minutesPassedSinceLastFeeOp(uint256 _extraSeconds) internal view returns (uint256) {
return (block.timestamp - lastFeeOperationTime + _extraSeconds) / SECONDS_IN_ONE_MINUTE;
}

// Updates the `baseRate` state with math from `_getUpdatedBaseRateFromRedemption`
Expand All @@ -221,9 +297,17 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
internal
view
returns (uint256)
{
return _getFutureBaseRateFromRedemption(_redeemAmount, _totalBoldSupply, 0);
}

function _getFutureBaseRateFromRedemption(uint256 _redeemAmount, uint256 _totalBoldSupply, uint256 _extraSeconds)
internal
view
returns (uint256)
{
// decay the base rate
uint256 decayedBaseRate = _calcDecayedBaseRate();
uint256 decayedBaseRate = _calcFutureDecayedBaseRate(_extraSeconds);

// get the fraction of total supply that was redeemed
uint256 redeemedBoldFraction = _redeemAmount * DECIMAL_PRECISION / _totalBoldSupply;
Expand All @@ -234,13 +318,17 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
return newBaseRate;
}

function _calcDecayedBaseRate() internal view returns (uint256) {
uint256 minutesPassed = _minutesPassedSinceLastFeeOp();
function _calcFutureDecayedBaseRate(uint256 _extraSeconds) internal view returns (uint256) {
uint256 minutesPassed = _minutesPassedSinceLastFeeOp(_extraSeconds);
uint256 decayFactor = LiquityMath._decPow(MINUTE_DECAY_FACTOR, minutesPassed);

return baseRate * decayFactor / DECIMAL_PRECISION;
}

function _calcDecayedBaseRate() internal view returns (uint256) {
return _calcFutureDecayedBaseRate(0);
}

function _calcRedemptionRate(uint256 _baseRate) internal pure returns (uint256) {
return LiquityMath._min(
REDEMPTION_FEE_FLOOR + _baseRate,
Expand All @@ -267,19 +355,19 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
return _calcRedemptionFee(getRedemptionRateWithDecay(), _ETHDrawn);
}

function getEffectiveRedemptionFeeInBold(uint256 _redeemAmount) public view override returns (uint256) {
function getEffectiveRedemptionFeeInBold(uint256 _redeemAmount, uint256 _extraSeconds) public view override returns (uint256) {
uint256 totalBoldSupply = boldToken.totalSupply();
uint256 newBaseRate = _getUpdatedBaseRateFromRedemption(_redeemAmount, totalBoldSupply);
uint256 newBaseRate = _getFutureBaseRateFromRedemption(_redeemAmount, totalBoldSupply, _extraSeconds);
return _calcRedemptionFee(_calcRedemptionRate(newBaseRate), _redeemAmount);
}

function getEffectiveRedemptionFee(uint256 _redeemAmount, uint256 _price)
function getEffectiveRedemptionFee(uint256 _redeemAmount, uint256 _price, uint256 _extraSeconds)
external
view
override
returns (uint256)
{
return getEffectiveRedemptionFeeInBold(_redeemAmount) * DECIMAL_PRECISION / _price;
return getEffectiveRedemptionFeeInBold(_redeemAmount, _extraSeconds) * DECIMAL_PRECISION / _price;
}

// getters
Expand Down Expand Up @@ -317,12 +405,12 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
function _requireValidMaxFeePercentage(uint256 _maxFeePercentage) internal pure {
require(
_maxFeePercentage >= REDEMPTION_FEE_FLOOR && _maxFeePercentage <= DECIMAL_PRECISION,
"Max fee percentage must be between 0.5% and 100%"
"CR: Max fee percentage must be between 0.5% and 100%"
);
}

function _requireAmountGreaterThanZero(uint256 _amount) internal pure {
require(_amount > 0, "TroveManager: Amount must be greater than zero");
require(_amount > 0, "CR: Amount must be greater than zero");
}

function _requireBoldBalanceCoversRedemption(IBoldToken _boldToken, address _redeemer, uint256 _amount)
Expand All @@ -333,7 +421,22 @@ contract CollateralRegistry is LiquityBase, ICollateralRegistry {
// Confirm redeemer's balance is less than total Bold supply
assert(boldBalance <= _boldToken.totalSupply());
require(
boldBalance >= _amount, "TroveManager: Requested redemption amount must be <= user's Bold token balance"
boldBalance >= _amount, "CR: Requested redemption amount must be <= user's Bold token balance"
);
}

function _requireValidRedemptionId(address _account, uint256 _redemptionId) internal view {
require(redemptionCommitments[_account][_redemptionId].boldAmount == 0, "CR: Commitment already exists");
}

function _requireValidCommitment(RedemptionCommitment memory _redemptionCommitment) internal pure {
require(_redemptionCommitment.boldAmount > 0, "CR: Non existing commitment");
}

function _requireValidTime(RedemptionCommitment memory _redemptionCommitment) internal view {
require(
_redemptionCommitment.timestamp + REDEMPTION_INTERVAL_MIN <= block.timestamp && block.timestamp <= _redemptionCommitment.timestamp + REDEMPTION_INTERVAL_MAX,
"CR: commitment out of redemption window"
);
}

Expand Down
8 changes: 5 additions & 3 deletions contracts/src/Interfaces/ICollateralRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import "./ITroveManager.sol";
interface ICollateralRegistry {
function baseRate() external view returns (uint256);

function redeemCollateral(uint256 _boldamount, uint256 _maxIterations, uint256 _maxFeePercentage) external;
function commitRedemption(uint256 _redemptionId, uint256 _boldAmount, uint64 _maxIterationsPerCollateral, uint64 _maxFeePercentage) external;
function executeRedemption(uint256 _redemptionId) external;
function withdrawRedemption(uint256 _redemptionId) external;
// getters
function totalCollaterals() external view returns (uint256);
function getToken(uint256 _index) external view returns (IERC20);
Expand All @@ -16,6 +18,6 @@ interface ICollateralRegistry {
function getRedemptionRateWithDecay() external view returns (uint256);

function getRedemptionFeeWithDecay(uint256 _ETHDrawn) external view returns (uint256);
function getEffectiveRedemptionFee(uint256 _redeemAmount, uint256 _price) external view returns (uint256);
function getEffectiveRedemptionFeeInBold(uint256 _redeemAmount) external view returns (uint256);
function getEffectiveRedemptionFee(uint256 _redeemAmount, uint256 _price, uint256 _extraSeconds) external view returns (uint256);
function getEffectiveRedemptionFeeInBold(uint256 _redeemAmount, uint256 _extraSeconds) external view returns (uint256);
}
2 changes: 2 additions & 0 deletions contracts/src/Interfaces/ITroveManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import "./ISortedTroves.sol";

// Common interface for the Trove Manager.
interface ITroveManager is IERC721, ILiquityBase {
function STALE_TROVE_DURATION() external view returns (uint256);

function setAddresses(
address _borrowerOperationsAddress,
address _activePoolAddress,
Expand Down
9 changes: 1 addition & 8 deletions contracts/src/TroveManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -603,15 +603,8 @@ contract TroveManager is ERC721, LiquityBase, Ownable, ITroveManager {
* of the trove list. It also avoids the need to set the cap in stone in the contract, nor doing gas calculations, as both gas price and opcode
* costs can vary.
*
* All Troves that are redeemed from -- with the likely exception of the last one -- will end up with no debt left, therefore they will be closed.
* If the last Trove does have some remaining debt, it has a finite ICR, and the reinsertion could be anywhere in the list, therefore it requires a hint.
* A frontend should use getRedemptionHints() to calculate what the ICR of this Trove will be after redemption, and pass a hint for its position
* in the sortedTroves list along with the ICR value that the hint was found for.
* All Troves that are redeemed from -- with the likely exception of the last one -- will end up with no debt left, they become unredeemable and removed from the sorted list
*
* If another transaction modifies the list between calling getRedemptionHints() and passing the hints to redeemCollateral(), it
* is very likely that the last (partially) redeemed Trove would end up with a different ICR than what the hint is for. In this case the
* redemption will stop after the last completely redeemed Trove and the sender will keep the remaining Bold amount, which they can attempt
* to redeem later.
*/
function redeemCollateral(
address _sender,
Expand Down
3 changes: 2 additions & 1 deletion contracts/src/deployment.sol
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ function _deployAndConnectContracts(uint256 _numCollaterals)
contractsArray[i] = contracts;
}

collateralRegistry = new CollateralRegistry(boldToken, collaterals, troveManagers);
// TODO: interest router should be common (not per collateral branch)
collateralRegistry = new CollateralRegistry(boldToken, address(contractsArray[0].interestRouter), collaterals, troveManagers);
boldToken.setCollateralRegistry(address(collateralRegistry));
// Set registry in TroveManagers
for (uint256 i = 0; i < _numCollaterals; i++) {
Expand Down
4 changes: 3 additions & 1 deletion contracts/src/test/TestContracts/BaseTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ contract BaseTest is Test {

function redeem(address _from, uint256 _boldAmount) public {
vm.startPrank(_from);
collateralRegistry.redeemCollateral(_boldAmount, MAX_UINT256, 1e18);
collateralRegistry.commitRedemption(0, _boldAmount, 100, 1e18);
vm.warp(block.timestamp + 80 seconds);
collateralRegistry.executeRedemption(0);
vm.stopPrank();
}

Expand Down
Loading

0 comments on commit be77d1f

Please sign in to comment.