-
Notifications
You must be signed in to change notification settings - Fork 13
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
base: main
Are you sure you want to change the base?
Changes from 33 commits
f1752ab
b0e8b46
d5f8565
2e08230
d8734c3
6332830
8b783f5
cd4574b
b4bcb40
32794b3
0fa9cb3
1f03053
a97764c
0203703
c252e0e
71d27a6
5cb8f7d
2468d58
c385393
7c10b3e
df1bd32
4c23d13
ba20fc0
c772ab0
d23dc7f
44dbe84
95998e6
f5336ff
25bbb14
50848c2
7c79c27
86cca45
db7fe6d
eceb3bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 @@ | ||
434922 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
71669 |
+4 −0 | src/StdChains.sol | |
+2 −2 | src/StdCheats.sol | |
+104 −0 | src/StdJson.sol | |
+104 −0 | src/StdToml.sol | |
+34 −1 | src/Vm.sol | |
+1 −1 | test/StdAssertions.t.sol | |
+14 −12 | test/StdChains.t.sol | |
+10 −10 | test/StdCheats.t.sol | |
+12 −12 | test/StdError.t.sol | |
+1 −1 | test/StdJson.t.sol | |
+4 −14 | test/StdMath.t.sol | |
+5 −5 | test/StdStorage.t.sol | |
+1 −1 | test/StdStyle.t.sol | |
+1 −1 | test/StdToml.t.sol | |
+12 −12 | test/StdUtils.t.sol | |
+2 −2 | test/Vm.t.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; | ||
|
||
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); | ||
} |
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)) internal _users; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
? _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); | ||||||
} | ||||||
hieronx marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
} | ||||||
|
||||||
/// @inheritdoc IInterestDistributor | ||||||
function clear(address vault, address controller) external { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would rename this to |
||||||
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(); | ||||||
} | ||||||
} |
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 | ||
} |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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.