-
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
Open
hieronx
wants to merge
34
commits into
main
Choose a base branch
from
interest-distributor
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
f1752ab
WIP
hieronx b0e8b46
Make it work
hieronx d5f8565
Add to router
hieronx 2e08230
Cleanup
hieronx d8734c3
Work on tests
hieronx 6332830
Comment
hieronx 8b783f5
Improve events, use uint64 for price
hieronx cd4574b
Naming
hieronx b4bcb40
Fix peak calculations
hieronx 32794b3
Add comments, clean up clear()
hieronx 0fa9cb3
Cleanup
hieronx 1f03053
Remove auth
hieronx a97764c
Use max method, deploy deterministically
hieronx 0203703
Remove duplicate price storage
hieronx c252e0e
Fix shares count
hieronx 71d27a6
Fix redemption logic
hieronx 5cb8f7d
Fix paying in router for distribute call
hieronx 2468d58
Add overload
hieronx c385393
Add snapshots
hieronx 7c10b3e
Use poolManager to get price
hieronx df1bd32
Disable deterministic deployment
hieronx 4c23d13
Fix pending method not accounting for reduced principal balance
hieronx ba20fc0
Fix comment
hieronx c772ab0
Check vault input
hieronx d23dc7f
Allow outstanding share updates without price updates
hieronx 44dbe84
Use ternary operator
hieronx 95998e6
Remove unused method
hieronx f5336ff
Add caveat
hieronx 25bbb14
Fix clear check
hieronx 50848c2
Fix event arg
hieronx 7c79c27
Remove comment
hieronx 86cca45
Optimize storage writes
hieronx db7fe6d
Simplify
hieronx eceb3bc
Public var
hieronx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
574684 | ||
597184 |
1 change: 1 addition & 0 deletions
1
.forge-snapshots/InterestDistributor_distribute_requestRedeem.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
434944 |
1 change: 1 addition & 0 deletions
1
.forge-snapshots/InterestDistributor_distribute_updateShares.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
71691 |
Submodule forge-std
updated
16 files
+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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||||||
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(); | ||||||
} | ||||||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.