Skip to content
Merged
45 changes: 45 additions & 0 deletions contracts/src/Interfaces/IRedemptionHelper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IRedemptionHelper {
struct SimulationContext {
address troveManager;
address sortedTroves;
bool redeemable;
uint256 price;
uint256 proportion;
uint256 attemptedBold;
uint256 redeemedBold;
uint256 iterations;
}

struct Redeemed {
uint256 bold;
uint256 coll;
}

function simulateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral)
external
returns (SimulationContext[] memory branch, uint256 totalProportions);

// Find the maximal amount of BOLD that can be redeemed proportionally within
// a given iteration limit. This helps prevent the redeemer from overpaying on
// the redemption fee.
//
// Also returns the expected fee that will be paid (as a percentage), and the
// expected collateral amounts that will be paid out in exchange for the
// redeemed BOLD. The latter may be used to calculate the _minCollRedeemed
// parameter passed to redeemCollateral().
function truncateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral)
external
returns (uint256 truncatedBold, uint256 feePct, Redeemed[] memory redeemed);

// Wrapper around CollateralRegistry's redeemCollateral() that adds slippage
// protection in the form of a minimum acceptable collateral amounts parameter.
function redeemCollateral(
uint256 _bold,
uint256 _maxIterationsPerCollateral,
uint256 _maxFeePct,
uint256[] memory _minCollRedeemed
) external;
}
174 changes: 174 additions & 0 deletions contracts/src/RedemptionHelper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.24;

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol";

import {_100pct, DECIMAL_PRECISION} from "./Dependencies/Constants.sol";
import {IAddressesRegistry} from "./Interfaces/IAddressesRegistry.sol";
import {IBoldToken} from "./Interfaces/IBoldToken.sol";
import {ICollateralRegistry} from "./Interfaces/ICollateralRegistry.sol";
import {IRedemptionHelper} from "./Interfaces/IRedemptionHelper.sol";
import {ISortedTroves} from "./Interfaces/ISortedTroves.sol";
import {ITroveManager} from "./Interfaces/ITroveManager.sol";
import {LatestTroveData} from "./Types/LatestTroveData.sol";

contract RedemptionHelper is IRedemptionHelper {
using SafeERC20 for IERC20;

struct RedemptionContext {
IERC20 collToken;
uint256 collBalanceBefore;
}

uint256 public immutable numBranches;
ICollateralRegistry public immutable collateralRegistry;
IBoldToken public immutable boldToken;
IAddressesRegistry[] public addresses; // only used off-chain, so we don't care about storage cost

constructor(ICollateralRegistry _collateralRegistry, IAddressesRegistry[] memory _addresses) {
require(_addresses.length == _collateralRegistry.totalCollaterals(), "Wrong number of registries");
numBranches = _addresses.length;
collateralRegistry = _collateralRegistry;
boldToken = _collateralRegistry.boldToken();

for (uint256 i = 0; i < _addresses.length; ++i) {
require(_collateralRegistry.getTroveManager(i) == _addresses[i].troveManager(), "TroveManager mismatch");
addresses.push(_addresses[i]);
}

boldToken.approve(address(_collateralRegistry), type(uint256).max);
}

// Meant to be called off-chain
// Not a view because price fetching has side-effects
function simulateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral)
public
returns (SimulationContext[] memory branch, uint256 totalProportions)
{
branch = new SimulationContext[](numBranches);

// First priority: proportional to unbacked debt
for (uint256 i = 0; i < numBranches; ++i) {
branch[i].troveManager = address(addresses[i].troveManager());
branch[i].sortedTroves = address(addresses[i].sortedTroves());
(branch[i].proportion, branch[i].price, branch[i].redeemable) =
ITroveManager(branch[i].troveManager).getUnbackedPortionPriceAndRedeemability();
if (branch[i].redeemable) totalProportions += branch[i].proportion;
}

// CS-BOLD-013: truncate redemption if it would exceed total unbacked debt
if (0 < totalProportions && totalProportions < _bold) _bold = totalProportions;

// Fallback: proportional to total debt
if (totalProportions == 0) {
for (uint256 i = 0; i < numBranches; ++i) {
branch[i].proportion = ITroveManager(branch[i].troveManager).getEntireBranchDebt();
if (branch[i].redeemable) totalProportions += branch[i].proportion;
}
}

if (totalProportions == 0) return (branch, totalProportions);

for (uint256 i = 0; i < numBranches; ++i) {
if (!branch[i].redeemable) continue;

branch[i].attemptedBold = _bold * branch[i].proportion / totalProportions;
if (branch[i].attemptedBold == 0) continue;

uint256 lastZombieTroveId = ITroveManager(branch[i].troveManager).lastZombieTroveId();
uint256 lastTroveId = ISortedTroves(branch[i].sortedTroves).getLast();

(uint256 troveId, uint256 nextTroveId) = lastZombieTroveId != 0
? (lastZombieTroveId, lastTroveId)
: (lastTroveId, ISortedTroves(branch[i].sortedTroves).getPrev(lastTroveId));

for (
branch[i].iterations = 0;
branch[i].iterations < _maxIterationsPerCollateral || _maxIterationsPerCollateral == 0;
++branch[i].iterations
) {
if (branch[i].redeemedBold == branch[i].attemptedBold || troveId == 0) break;

LatestTroveData memory trove = ITroveManager(branch[i].troveManager).getLatestTroveData(troveId);
if (trove.entireColl * branch[i].price / trove.entireDebt >= _100pct) {
branch[i].redeemedBold +=
Math.min(branch[i].attemptedBold - branch[i].redeemedBold, trove.entireDebt);
}

troveId = nextTroveId;
nextTroveId = ISortedTroves(branch[i].sortedTroves).getPrev(nextTroveId);
}
}
}

// Meant to be called off-chain
// Not a view because price fetching has side-effects
function truncateRedemption(uint256 _bold, uint256 _maxIterationsPerCollateral)
external
returns (uint256 truncatedBold, uint256 feePct, Redeemed[] memory redeemed)
{
(SimulationContext[] memory branch, uint256 totalProportions) =
simulateRedemption(_bold, _maxIterationsPerCollateral);

if (totalProportions == 0) return (0, 0, redeemed);

truncatedBold = _bold;
for (uint256 i = 0; i < numBranches; ++i) {
if (branch[i].redeemable && branch[i].proportion > 0) {
// Extrapolate how much the entire redeemed BOLD would
// have been if this branch was redeemed proportionally.
uint256 extrapolatedBold = branch[i].redeemedBold * totalProportions / branch[i].proportion;

// Normally this is no different from `_bold`, but can be less if the redemption on this branch
// terminated due to hitting the iteration limit. We're looking for the smallest extrapolated value,
// since that is the maximum amount of BOLD that can be redeemed proportionally within the given
// iteration limit. Any attempt to redeem more than this would result in a partial redemption, thus
// paying a higher redemption fee than necessary — since the fee is based on the attempted amount.
if (extrapolatedBold < truncatedBold) truncatedBold = extrapolatedBold;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m having hard time understanding this part. It seems that truncatedBold can be overridden at any branch iteration. Why do we know the last one is the good one? I’ll get back to it later, but maybe a comment may help.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each branch, we determine how much the total BOLD redeemed across all branches would have been if the redemption was proportional (we call this extrapolatedBold). Then we take the smallest of those amounts.

Normally, this is no different from the input _bold amount. However, if any of the branches terminated because of the iteration limit, the redeemed amount on that branch will be less than proportional. Since such redemptions end up redeeming less BOLD than intended, they pay a higher fee than necessary (since the fee is fixed at the beginning of the TX, before the actually redeemed amounts are known).

The point of truncateRedemption() is to find the maximal amount of BOLD that can be redeemed within a given iteration limit without overpaying on the redemption fee.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment that hopefully explains this.

}
}

feePct = collateralRegistry.getRedemptionRateForRedeemedAmount(truncatedBold);
redeemed = new Redeemed[](numBranches);

for (uint256 i = 0; i < numBranches; ++i) {
if (branch[i].redeemable && branch[i].proportion > 0) {
(uint256 redemptionPrice,) = addresses[i].priceFeed().fetchRedemptionPrice();
redeemed[i].bold = truncatedBold * branch[i].proportion / totalProportions;
redeemed[i].coll = redeemed[i].bold * (DECIMAL_PRECISION - feePct) / redemptionPrice;
}
}
}

function redeemCollateral(
uint256 _bold,
uint256 _maxIterationsPerCollateral,
uint256 _maxFeePct,
uint256[] memory _minCollRedeemed
) external {
require(_minCollRedeemed.length == numBranches, "Wrong _minCollRedeemed length");

RedemptionContext[] memory branch = new RedemptionContext[](numBranches);

for (uint256 i = 0; i < numBranches; ++i) {
branch[i].collToken = collateralRegistry.getToken(i);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it’s worth, but we may store the tokens, even immutably like we do it collateral registry, to save some gas.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this too. The 3 calls to collateralRegistry.getToken() total about 1.35K gas, so that's the upper limit on how much we could save by switching to immutables. It makes the code a bit nasty though.

Or we could use storage, but unless we pass an access list with the TX, it would actually end up costing more, since cold slots take 2.1K to SLOAD.

In the end, I didn't think the 1.35K potential savings warranted adding the extra immutable optimization.

branch[i].collBalanceBefore = branch[i].collToken.balanceOf(address(this));
}

uint256 boldBalanceBefore = boldToken.balanceOf(address(this));

boldToken.transferFrom(msg.sender, address(this), _bold);
collateralRegistry.redeemCollateral(_bold, _maxIterationsPerCollateral, _maxFeePct);

for (uint256 i = 0; i < numBranches; ++i) {
uint256 collRedeemed = branch[i].collToken.balanceOf(address(this)) - branch[i].collBalanceBefore;
require(collRedeemed >= _minCollRedeemed[i], "Insufficient collateral redeemed");
if (collRedeemed > 0) branch[i].collToken.safeTransfer(msg.sender, collRedeemed);
}

uint256 boldRemaining = boldToken.balanceOf(address(this)) - boldBalanceBefore;
if (boldRemaining > 0) boldToken.transfer(msg.sender, boldRemaining);
}
}
Loading