Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interest distribution operator #415

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f1752ab
WIP
hieronx Sep 19, 2024
b0e8b46
Make it work
hieronx Sep 19, 2024
d5f8565
Add to router
hieronx Sep 19, 2024
2e08230
Cleanup
hieronx Sep 19, 2024
d8734c3
Work on tests
hieronx Sep 19, 2024
6332830
Comment
hieronx Sep 19, 2024
8b783f5
Improve events, use uint64 for price
hieronx Sep 19, 2024
cd4574b
Naming
hieronx Sep 19, 2024
b4bcb40
Fix peak calculations
hieronx Sep 21, 2024
32794b3
Add comments, clean up clear()
hieronx Sep 21, 2024
0fa9cb3
Cleanup
hieronx Sep 21, 2024
1f03053
Remove auth
hieronx Sep 21, 2024
a97764c
Use max method, deploy deterministically
hieronx Sep 21, 2024
0203703
Remove duplicate price storage
hieronx Sep 22, 2024
c252e0e
Fix shares count
hieronx Sep 22, 2024
71d27a6
Fix redemption logic
hieronx Sep 22, 2024
5cb8f7d
Fix paying in router for distribute call
hieronx Sep 22, 2024
2468d58
Add overload
hieronx Sep 23, 2024
c385393
Add snapshots
hieronx Sep 23, 2024
7c10b3e
Use poolManager to get price
hieronx Sep 23, 2024
df1bd32
Disable deterministic deployment
hieronx Sep 23, 2024
4c23d13
Fix pending method not accounting for reduced principal balance
hieronx Sep 23, 2024
ba20fc0
Fix comment
hieronx Sep 23, 2024
c772ab0
Check vault input
hieronx Sep 23, 2024
d23dc7f
Allow outstanding share updates without price updates
hieronx Sep 23, 2024
44dbe84
Use ternary operator
hieronx Sep 23, 2024
95998e6
Remove unused method
hieronx Sep 23, 2024
f5336ff
Add caveat
hieronx Sep 23, 2024
25bbb14
Fix clear check
hieronx Sep 23, 2024
50848c2
Fix event arg
hieronx Sep 23, 2024
7c79c27
Remove comment
hieronx Sep 24, 2024
86cca45
Optimize storage writes
hieronx Oct 1, 2024
db7fe6d
Simplify
hieronx Oct 1, 2024
eceb3bc
Public var
hieronx Oct 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .forge-snapshots/ERC7540Vault_requestDeposit.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
574684
597184
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
434944
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
71691
2 changes: 1 addition & 1 deletion lib/chimera
Submodule chimera updated 3 files
+8 −0 README.md
+24 −12 src/CryticAsserts.sol
+29 −3 src/Hevm.sol
3 changes: 3 additions & 0 deletions script/Deployer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {PoolManager} from "src/PoolManager.sol";
import {Escrow} from "src/Escrow.sol";
import {CentrifugeRouter} from "src/CentrifugeRouter.sol";
import {Guardian} from "src/admin/Guardian.sol";
import {InterestDistributor} from "src/operators/InterestDistributor.sol";
import {IAuth} from "src/interfaces/IAuth.sol";
import "forge-std/Script.sol";

Expand All @@ -31,6 +32,7 @@ contract Deployer is Script {
GasService public gasService;
CentrifugeRouter public router;
TransferProxyFactory public transferProxyFactory;
InterestDistributor public interestDistributor;
address public vaultFactory;
address public restrictionManager;
address public trancheFactory;
Expand Down Expand Up @@ -60,6 +62,7 @@ contract Deployer is Script {
gateway = new Gateway(address(root), address(poolManager), address(investmentManager), address(gasService));
router = new CentrifugeRouter(address(routerEscrow), address(gateway), address(poolManager));
guardian = new Guardian(adminSafe, address(root), address(gateway));
interestDistributor = new InterestDistributor(address(poolManager));

_endorse();
_rely();
Expand Down
12 changes: 10 additions & 2 deletions src/CentrifugeRouter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {IGateway} from "src/interfaces/gateway/IGateway.sol";
import {TransientStorage} from "src/libraries/TransientStorage.sol";
import {IRecoverable} from "src/interfaces/IRoot.sol";
import {ITransferProxy} from "src/interfaces/factories/ITransferProxy.sol";
import {IInterestDistributor} from "src/interfaces/operators/IInterestDistributor.sol";

/// @title CentrifugeRouter
/// @notice This is a helper contract, designed to be the entrypoint for EOAs.
Expand All @@ -36,6 +37,7 @@ contract CentrifugeRouter is Auth, ICentrifugeRouter {
IEscrow public immutable escrow;
IGateway public immutable gateway;
IPoolManager public immutable poolManager;
IInterestDistributor public immutable interestDistributor;

/// @inheritdoc ICentrifugeRouter
mapping(address controller => mapping(address vault => uint256 amount)) public lockedRequests;
Expand Down Expand Up @@ -67,11 +69,11 @@ contract CentrifugeRouter is Auth, ICentrifugeRouter {
}

// --- Enable interactions with the vault ---
function enable(address vault) public protected {
function enable(address vault) public payable protected {
IERC7540Vault(vault).setEndorsedOperator(msg.sender, true);
}

function disable(address vault) external protected {
function disable(address vault) external payable protected {
IERC7540Vault(vault).setEndorsedOperator(msg.sender, false);
}

Expand Down Expand Up @@ -218,6 +220,12 @@ contract CentrifugeRouter is Auth, ICentrifugeRouter {
IERC7540Vault(vault).claimCancelRedeemRequest(REQUEST_ID, receiver, controller);
}

/// @inheritdoc ICentrifugeRouter
function distributeInterest(address vault, address controller, uint256 topUpAmount) external payable protected {
if (topUpAmount > 0) _pay(topUpAmount);
interestDistributor.distribute(vault, controller);
}

// --- Transfer ---
/// @inheritdoc ICentrifugeRouter
function transferAssets(address asset, bytes32 recipient, uint128 amount, uint256 topUpAmount)
Expand Down
7 changes: 5 additions & 2 deletions src/interfaces/ICentrifugeRouter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ interface ICentrifugeRouter is IRecoverable {
/// @dev After this is called, anyone can claim tokens to msg.sender.
/// Even any requests submitted directly to the vault (not through the CentrifugeRouter) will be
/// permissionlessly claimable through the CentrifugeRouter, until `disable()` is called.
function enable(address vault) external;
function enable(address vault) external payable;

/// @notice Disable permissionless claiming
function disable(address vault) external;
function disable(address vault) external payable;

// --- Deposit ---
/// @notice Check `IERC7540Deposit.requestDeposit`.
Expand Down Expand Up @@ -154,6 +154,9 @@ interface ICentrifugeRouter is IRecoverable {
/// @param controller Check IERC7540CancelRedeem.claimCancelRedeemRequest.controller
function claimCancelRedeemRequest(address vault, address receiver, address controller) external payable;

/// @notice TODO
function distributeInterest(address vault, address controller, uint256 topUpAmount) external payable;

// --- Transfer ---
/// @notice Check `IPoolManager.transferAssets`.
/// @dev This adds a mandatory prepayment for all the costs that will incur during the transaction.
Expand Down
34 changes: 34 additions & 0 deletions src/interfaces/operators/IInterestDistributor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity >=0.5.0;
Copy link
Contributor

Choose a reason for hiding this comment

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

Any specific reason why pragma is defined like that ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Allows any integration to pull in these interfaces, using any Solidity version.


struct InterestDetails {
/// @dev Time of the last price update after which a distribution happened.
/// Downcast from uint64 so only works until 2106.
uint32 lastUpdate;
/// @dev Highest recorded price (downcast from uint256)
uint96 peak;
/// @dev Outstanding shares on which the interest calculation is based.
uint128 shares;
}

interface IInterestDistributor {
// --- Events ---
event InterestRedeemRequest(
address indexed vault, address indexed controller, uint96 previousPrice, uint96 currentPrice, uint128 request
);
event OutstandingSharesUpdate(address indexed vault, address indexed controller, uint128 previous, uint128 current);
event Clear(address indexed vault, address indexed controller);

/// @notice Trigger redeem request of pending interest for a controller who has set the interest distributor
/// as an operator for the given vault.
/// Should be called after any fulfillment to update the outstanding shares.
/// Interest is only redeemed in the next price update after the first distribute() call post fulfillment.
function distribute(address vault, address controller) external;

/// @notice Called by controllers to disable the use of the interest distributor, after they have called
/// setOperator(address(interestDistributor), false)
function clear(address vault, address controller) external;

/// @notice Returns the pending interest to be redeemed.
function pending(address vault, address controller) external view returns (uint128 shares);
}
104 changes: 104 additions & 0 deletions src/operators/InterestDistributor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.26;

import {IERC20} from "src/interfaces/IERC20.sol";
import {IERC7540Vault} from "src/interfaces/IERC7540.sol";
import {IPoolManager} from "src/interfaces/IPoolManager.sol";
import {MathLib} from "src/libraries/MathLib.sol";
import {IInterestDistributor, InterestDetails} from "src/interfaces/operators/IInterestDistributor.sol";

/// @title InterestDistributor
/// @notice Contract that can be enabled by calling vault.setOperator(address(interestDistributor), true), which then
/// allows permissionless triggers of redeem requests for the accrued interest on a vault.
///
/// Whenever a user claims new tranche tokens or a principal redeem request is initiated,
/// distribute() should be called to update the outstanding shares that the interest is computed based on.
///
/// Uses peak price to calculate interest. If the price goes down, interest will only be redeemed
/// again once the price fully recovers above the previous high point (peak).
/// Peak is stored per user since if the price has globally gone down, and a user invests at that time,
/// they'd expect to redeem interest based on the price they invested in, not the previous high point.
/// @dev Requires that:
/// - Whenever orders are fulfilled, an UpdateTranchePrice message is also submitted
/// - Users claim the complete fulfilled amount using the router, rather than partial claims through the vault.
/// Otherwise the interest amounts may be off.
contract InterestDistributor is IInterestDistributor {
using MathLib for uint256;

mapping(address vault => mapping(address user => InterestDetails)) public users;

IPoolManager public immutable poolManager;

constructor(address poolManager_) {
poolManager = IPoolManager(poolManager_);
}

/// @inheritdoc IInterestDistributor
function distribute(address vault, address controller) external {
IERC7540Vault vault_ = IERC7540Vault(vault);
require(vault_.isOperator(controller, address(this)), "InterestDistributor/not-an-operator");

InterestDetails memory user = users[vault][controller];
uint128 prevShares = user.shares;

(address asset,) = poolManager.getVaultAsset(vault);
(uint128 currentPrice, uint64 priceLastUpdated) =
poolManager.getTranchePrice(vault_.poolId(), vault_.trancheId(), asset);
uint128 currentShares = IERC20(vault_.share()).balanceOf(controller).toUint128();

// Calculate request before updating user.shares, so it is based on the balance at the last price update.
// Assuming price updates coincide with epoch fulfillments, this results in only requesting
// interest on the previous outstanding balance before the new fulfillment.
uint128 request = priceLastUpdated > user.lastUpdate
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
uint128 request = priceLastUpdated > user.lastUpdate
uint128 redeemAmount = priceLastUpdated > user.lastUpdate

? _computeRequest(prevShares, currentShares, user.peak, uint96(currentPrice))
: 0;

user.lastUpdate = uint32(priceLastUpdated);
if (currentPrice > user.peak) user.peak = uint96(currentPrice);
user.shares = currentShares - request;
users[vault][controller] = user;

if (request > 0) {
vault_.requestRedeem(request, controller, controller);
emit InterestRedeemRequest(vault, controller, user.peak, uint96(currentPrice), request);
}

if (user.shares != prevShares) {
emit OutstandingSharesUpdate(vault, controller, prevShares, user.shares);
}
}

/// @inheritdoc IInterestDistributor
function clear(address vault, address controller) external {
Copy link
Contributor

Choose a reason for hiding this comment

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

I would rename this to dropOut. Meaning that you want to drop-out from the interest distribution process.

require(!IERC7540Vault(vault).isOperator(controller, address(this)), "InterestDistributor/still-an-operator");
require(users[vault][controller].lastUpdate > 0, "InterestDistributor/unknown-controller");

delete users[vault][controller];
emit Clear(vault, controller);
}

/// @inheritdoc IInterestDistributor
function pending(address vault, address controller) external view returns (uint128 shares) {
InterestDetails memory user = users[vault][controller];
IERC7540Vault vault_ = IERC7540Vault(vault);
(uint128 currentPrice, uint64 priceLastUpdated) =
poolManager.getTranchePrice(vault_.poolId(), vault_.trancheId(), vault_.asset());
if (user.lastUpdate == uint32(priceLastUpdated)) return 0;
shares =
_computeRequest(user.shares, IERC20(vault_.share()).balanceOf(controller), user.peak, uint96(currentPrice));
}

/// @dev Calculate shares to redeem based on outstandingShares * ((currentPrice - prevPrice) / currentPrice)
function _computeRequest(uint128 outstandingShares, uint256 currentShares, uint96 prevPrice, uint96 currentPrice)
internal
pure
returns (uint128)
{
if (outstandingShares == 0 || currentPrice <= prevPrice) return 0;

return MathLib.min(
uint256(outstandingShares).mulDiv(currentPrice - prevPrice, currentPrice, MathLib.Rounding.Down),
currentShares
).toUint128();
}
}
104 changes: 104 additions & 0 deletions test/integration/InterestDistributor.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.26;

import "test/BaseTest.sol";

contract InterestDistributorTest is BaseTest {
function testDistributeInterest(uint256 amount) public {
// If lower than 4 or odd, rounding down can lead to not receiving any tokens
amount = uint128(bound(amount, 4, MAX_UINT128));
vm.assume(amount % 2 == 0);

uint128 price = 1 * 10 ** 18;
address vault_ = deploySimpleVault();
address investor = makeAddr("investor");
ERC7540Vault vault = ERC7540Vault(vault_);

centrifugeChain.updateTranchePrice(
vault.poolId(), vault.trancheId(), defaultAssetId, price, uint64(block.timestamp)
);

erc20.mint(investor, amount);

centrifugeChain.updateMember(vault.poolId(), vault.trancheId(), investor, type(uint64).max);
vm.prank(investor);
erc20.approve(vault_, amount);

vm.prank(investor);
vault.requestDeposit(amount, investor, investor);

centrifugeChain.isFulfilledDepositRequest(
vault.poolId(),
vault.trancheId(),
bytes32(bytes20(investor)),
defaultAssetId,
uint128(amount),
uint128(amount)
);

vm.prank(investor);
vault.deposit(amount, investor, investor);

// Initially, pending interest is 0
assertApproxEqAbs(vault.pendingRedeemRequest(0, investor), 0, 1);
assertApproxEqAbs(interestDistributor.pending(vault_, investor), 0, 1);

vm.expectRevert(bytes("InterestDistributor/not-an-operator"));
interestDistributor.distribute(vault_, investor);

vm.prank(investor);
vault.setOperator(address(interestDistributor), true);

snapStart("InterestDistributor_distribute_updateShares");
interestDistributor.distribute(vault_, investor);
snapEnd();

assertApproxEqAbs(vault.pendingRedeemRequest(0, investor), 0, 1);
assertApproxEqAbs(interestDistributor.pending(vault_, investor), 0, 1);

// Once price goes to 1.25, 1/5th of the total shares are redeemed
vm.warp(block.timestamp + 1 days);
centrifugeChain.updateTranchePrice(
vault.poolId(), vault.trancheId(), defaultAssetId, 1.25 * 10 ** 18, uint64(block.timestamp)
);

assertApproxEqAbs(vault.pendingRedeemRequest(0, investor), 0, 1);
assertApproxEqAbs(interestDistributor.pending(vault_, investor), amount / 5, 1);

snapStart("InterestDistributor_distribute_requestRedeem");
interestDistributor.distribute(vault_, investor);
snapEnd();

assertApproxEqAbs(vault.pendingRedeemRequest(0, investor), amount / 5, 1);
assertApproxEqAbs(interestDistributor.pending(vault_, investor), 0, 1);

// When price goes down, no new redemption is submitted
vm.warp(block.timestamp + 1 days);
centrifugeChain.updateTranchePrice(
vault.poolId(), vault.trancheId(), defaultAssetId, 1.0 * 10 ** 18, uint64(block.timestamp)
);

assertApproxEqAbs(interestDistributor.pending(vault_, investor), 0, 1);
interestDistributor.distribute(vault_, investor);

vm.warp(block.timestamp + 1 days);
centrifugeChain.updateTranchePrice(
vault.poolId(), vault.trancheId(), defaultAssetId, 1.1 * 10 ** 18, uint64(block.timestamp)
);

assertApproxEqAbs(interestDistributor.pending(vault_, investor), 0, 1);
interestDistributor.distribute(vault_, investor);

// Once price goes above 1.25 (to 2.50) again, shares are redeemed
vm.warp(block.timestamp + 1 days);
centrifugeChain.updateTranchePrice(
vault.poolId(), vault.trancheId(), defaultAssetId, 2.5 * 10 ** 18, uint64(block.timestamp)
);

assertApproxEqAbs(interestDistributor.pending(vault_, investor), (amount - amount / 5) / 2, 1);
}

// TODO: testDistributeAfterAnotherDeposit
// TODO: testDistributeAfterPrincipalRedemption
// TODO: testClear
}
Loading