diff --git a/src/core/VaultManagerV6.sol b/src/core/VaultManagerV6.sol index 74b0249c..b1a356c2 100644 --- a/src/core/VaultManagerV6.sol +++ b/src/core/VaultManagerV6.sol @@ -9,6 +9,8 @@ import {DyadXP} from "../staking/DyadXP.sol"; import {IVaultManagerV5} from "../interfaces/IVaultManagerV5.sol"; import {DyadHooks} from "./DyadHooks.sol"; import "../interfaces/IExtension.sol"; +import {KeroseneValuer} from "../staking/KeroseneValuer.sol"; +import {KerosineManager} from "../core/KerosineManager.sol"; import {FixedPointMathLib} from "@solmate/src/utils/FixedPointMathLib.sol"; import {ERC20} from "@solmate/src/tokens/ERC20.sol"; @@ -47,6 +49,8 @@ contract VaultManagerV6 is IVaultManagerV5, UUPSUpgradeable, OwnableUpgradeable /// @notice Extensions authorized by a user for use on their notes mapping(address user => EnumerableSet.AddressSet) private _authorizedExtensions; + KeroseneValuer public keroseneValuer; + modifier isValidDNft(uint256 id) { if (dNft.ownerOf(id) == address(0)) revert InvalidDNft(); _; @@ -57,8 +61,12 @@ contract VaultManagerV6 is IVaultManagerV5, UUPSUpgradeable, OwnableUpgradeable _disableInitializers(); } - function initialize() public reinitializer(6) { - // Nothing to initialize right now + function initialize(address _keroseneValuer) public reinitializer(6) { + keroseneValuer = KeroseneValuer(_keroseneValuer); + } + + function setKeroseneValuer(address _newKeroseneValuer) external onlyOwner { + keroseneValuer = KeroseneValuer(_newKeroseneValuer); } /// @inheritdoc IVaultManagerV5 @@ -344,19 +352,29 @@ contract VaultManagerV6 is IVaultManagerV5, UUPSUpgradeable, OwnableUpgradeable uint256 numberOfVaults = vaults[id].length(); vaultValues = new uint256[](numberOfVaults); + uint256 keroseneVaultIndex; + uint256 noteKeroseneAmount; + for (uint256 i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[id].at(i)); if (vaultLicenser.isLicensed(address(vault))) { - uint256 value = vault.getUsdValue(id); - vaultValues[i] = value; if (vaultLicenser.isKerosene(address(vault))) { - keroValue += value; + noteKeroseneAmount = vault.id2asset(id); + keroseneVaultIndex = i; + continue; } else { + uint256 value = vault.getUsdValue(id); + vaultValues[i] = value; exoValue += value; } } } + if (noteKeroseneAmount > 0) { + keroValue = (noteKeroseneAmount * keroseneValuer.deterministicValue()) / 1e8; + vaultValues[keroseneVaultIndex] = keroValue; + } + mintedDyad = dyad.mintedDyad(id); uint256 totalValue = exoValue + keroValue; cr = _collatRatio(mintedDyad, totalValue); diff --git a/src/staking/KeroseneValuer.sol b/src/staking/KeroseneValuer.sol new file mode 100644 index 00000000..abdea0e2 --- /dev/null +++ b/src/staking/KeroseneValuer.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Owned} from "@solmate/src/auth/Owned.sol"; +import {ERC20} from "@solmate/src/tokens/ERC20.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; +import {Parameters} from "../params/Parameters.sol"; +import {Kerosine} from "../staking/Kerosine.sol"; +import {Dyad} from "../core/Dyad.sol"; +import {Vault} from "../core/Vault.sol"; +import {KerosineManager} from "../core/KerosineManager.sol"; +import {Dyad} from "../core/Dyad.sol"; + +contract KeroseneValuer is Owned { + using EnumerableSet for EnumerableSet.AddressSet; + using FixedPointMathLib for uint256; + + Kerosine public immutable KEROSENE; + KerosineManager public immutable KEROSENE_MANAGER; + Dyad public immutable DYAD; + + uint64 public dyadMultiplierSnapshot = 1e12; + uint64 public targetDyadMultiplier = 1e12; + + uint32 public dyadMultiplierSnapshotTimestamp; + uint32 public targetDyadMultiplierTimestamp; + + EnumerableSet.AddressSet private _excludedAddresses; + + event DyadMultiplierUpdated(uint64 previous, uint64 target, uint32 fromTimestamp, uint32 toTimestamp); + + error TargetMultiplierTooSmall(); + + constructor(Kerosine _kerosine, KerosineManager _keroseneManager, Dyad _dyad) + Owned(0xDeD796De6a14E255487191963dEe436c45995813) + { + KEROSENE = _kerosine; + KEROSENE_MANAGER = _keroseneManager; + DYAD = _dyad; + _excludedAddresses.add(0xDeD796De6a14E255487191963dEe436c45995813); // Team Multisig + _excludedAddresses.add(0x3962f6585946823440d274aD7C719B02b49DE51E); // Sablier Linear Lockup + } + + function setAddressExcluded(address _address, bool exclude) external onlyOwner { + if (exclude) { + _excludedAddresses.add(_address); + } else { + _excludedAddresses.remove(_address); + } + } + + function setTargetDyadMultiplier(uint64 _targetMultiplier, uint32 _duration) external onlyOwner { + if (_targetMultiplier < 1e12) { + revert TargetMultiplierTooSmall(); + } + + uint64 previousMultiplier = _getDyadSupplyMultiplier(); + + dyadMultiplierSnapshot = previousMultiplier; + targetDyadMultiplier = _targetMultiplier; + dyadMultiplierSnapshotTimestamp = uint32(block.timestamp); + targetDyadMultiplierTimestamp = uint32(block.timestamp) + _duration; + + emit DyadMultiplierUpdated( + previousMultiplier, _targetMultiplier, uint32(block.timestamp), uint32(block.timestamp) + _duration + ); + } + + function currentDyadMultiplier() external view returns (uint64) { + return _getDyadSupplyMultiplier(); + } + + function isExcludedAddress(address _address) external view returns (bool) { + return _excludedAddresses.contains(_address); + } + + function excludedAddresses() external view returns (address[] memory) { + return _excludedAddresses.values(); + } + + function deterministicValue() external view returns (uint256) { + uint256 dyadMultiplier = _getDyadSupplyMultiplier(); + + uint256 normalizedSupply = DYAD.totalSupply().mulDiv(dyadMultiplier, 1e12); + + uint256 tvl; + + address[] memory exoVaults = KEROSENE_MANAGER.getVaults(); + + uint256 numberOfExoVaults = exoVaults.length; + for (uint256 i = 0; i < numberOfExoVaults; i++) { + Vault vault = Vault(exoVaults[i]); + ERC20 asset = vault.asset(); + tvl += asset.balanceOf(address(vault)) * vault.assetPrice() * 1e18 / (10 ** asset.decimals()) + / (10 ** vault.oracle().decimals()); + } + + if (normalizedSupply >= tvl) { + return 0; + } + + uint256 adjustedKerosineSupply = KEROSENE.totalSupply(); + uint256 excludedAddressLength = _excludedAddresses.length(); + for (uint256 i = 0; i < excludedAddressLength; ++i) { + adjustedKerosineSupply -= KEROSENE.balanceOf(_excludedAddresses.at(i)); + } + + return (tvl - normalizedSupply).mulDiv(1e8, adjustedKerosineSupply); + } + + function _getDyadSupplyMultiplier() internal view returns (uint64) { + uint32 targetTimestamp = targetDyadMultiplierTimestamp; + if (block.timestamp >= targetTimestamp) { + return targetDyadMultiplier; + } + + uint64 target = targetDyadMultiplier; + uint64 snapshot = dyadMultiplierSnapshot; + uint32 snapshotTimestamp = dyadMultiplierSnapshotTimestamp; + + uint32 timeDelta = targetTimestamp - snapshotTimestamp; + uint64 multiplierDelta = target > snapshot ? target - snapshot : snapshot - target; + + uint64 ratePerSecond = multiplierDelta / timeDelta; + + uint32 secondsPassed = uint32(block.timestamp) - snapshotTimestamp; + + if (target > snapshot) { + return snapshot + (secondsPassed * ratePerSecond); + } + + return snapshot - (secondsPassed * ratePerSecond); + } +} diff --git a/test/KeroseneValuer.t.sol b/test/KeroseneValuer.t.sol new file mode 100644 index 00000000..2611a315 --- /dev/null +++ b/test/KeroseneValuer.t.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {KeroseneValuer} from "../src/staking/KeroseneValuer.sol"; +import {Kerosine} from "../src/staking/Kerosine.sol"; +import {KerosineManager} from "../src/core/KerosineManager.sol"; +import {Dyad} from "../src/core/Dyad.sol"; + +import {OracleMock} from "./OracleMock.sol"; +import {ERC20Mock} from "./ERC20Mock.sol"; + +contract TokenMock is ERC20Mock { + constructor(string memory _name, string memory _symbol) ERC20Mock(_name, _symbol) {} + + function setBalance(address _target, uint256 _amount) external { + _burn(_target, balanceOf[_target]); + _mint(_target, _amount); + } +} + +contract VaultMock { + OracleMock public oracle = new OracleMock(1e8); + TokenMock public asset = new TokenMock("TokenMock", "mToken"); + + function assetPrice() public view returns (uint256) { + (, uint256 answer,,,) = oracle.latestRoundData(); + return answer; + } + + function setAssetBalance(uint256 _balance) external { + asset.setBalance(address(this), _balance); + } + + function getTvl() external view returns (uint256) { + return + asset.balanceOf(address(this)) * assetPrice() * 1e18 / (10 ** asset.decimals()) / (10 ** oracle.decimals()); + } +} + +contract VaultManagerMock { + address[] _vaults; + + function addVault(VaultMock _vault) external { + _vaults.push(address(_vault)); + } + + function getVaults() external view returns (address[] memory) { + return _vaults; + } + + function getTvl() external view returns (uint256) { + uint256 tvl; + uint256 numberOfVaults = _vaults.length; + for (uint256 i = 0; i < numberOfVaults; i++) { + VaultMock vault = VaultMock(_vaults[i]); + tvl += vault.getTvl(); + } + + return tvl; + } +} + +contract KeroseneValuerTest is Test { + address OWNER; + address ALICE = makeAddr("ALICE"); + + KeroseneValuer valuer; + TokenMock kerosene; + TokenMock dyad; + VaultManagerMock keroseneManager; + VaultMock vault; + + function setUp() external { + kerosene = new TokenMock("Kerosene Mock", "Kerosene"); + dyad = new TokenMock("Dyad Mock", "Dyad"); + keroseneManager = new VaultManagerMock(); + vault = new VaultMock(); + + keroseneManager.addVault(vault); + + valuer = new KeroseneValuer( + Kerosine(address(kerosene)), KerosineManager(address(keroseneManager)), Dyad(address(dyad)) + ); + + OWNER = valuer.owner(); + } + + function test_get_multiplier() external view { + uint64 multiplier = valuer.currentDyadMultiplier(); + + // Default value + assertEq(multiplier, 1e12); + } + + function test_set_multiplier() external { + uint64 newMultiplier = 1.25e12; + uint32 duration = 2 hours; + + vm.prank(OWNER); + valuer.setTargetDyadMultiplier(newMultiplier, duration); + + assertEq(valuer.dyadMultiplierSnapshot(), 1e12); + assertEq(valuer.dyadMultiplierSnapshotTimestamp(), vm.getBlockTimestamp()); + assertEq(valuer.targetDyadMultiplier(), newMultiplier); + assertEq(valuer.targetDyadMultiplierTimestamp(), vm.getBlockTimestamp() + duration); + // Current multiplier stays the same right after update + assertEq(valuer.currentDyadMultiplier(), 1e12); + } + + function test_multiplier_increase() external { + uint64 newMultiplier = 2e12; + uint32 duration = 10 seconds; + + uint64 increasePerSecond = (newMultiplier - valuer.currentDyadMultiplier()) / duration; + + vm.prank(OWNER); + valuer.setTargetDyadMultiplier(newMultiplier, duration); + + uint64 multiplierSnapshot = valuer.dyadMultiplierSnapshot(); + + // after 1 second + vm.warp(vm.getBlockTimestamp() + 1 seconds); + assertEq(valuer.currentDyadMultiplier(), multiplierSnapshot + increasePerSecond); + + // after 8 seconds + vm.warp(vm.getBlockTimestamp() + 7 seconds); + assertEq(valuer.currentDyadMultiplier(), multiplierSnapshot + 8 * increasePerSecond); + + // after 10 seconds + vm.warp(vm.getBlockTimestamp() + 2 seconds); + assertEq(valuer.currentDyadMultiplier(), newMultiplier); + + // after some time + vm.warp(vm.getBlockTimestamp() + 1 hours); + assertEq(valuer.currentDyadMultiplier(), newMultiplier); + } + + function test_multiplier_decrease() external { + vm.prank(OWNER); + valuer.setTargetDyadMultiplier(2e12, 0); + + uint64 newMultiplier = 1.2e12; + uint32 duration = 10 seconds; + + uint64 decreasePerSecond = (valuer.currentDyadMultiplier() - newMultiplier) / duration; + + vm.prank(OWNER); + valuer.setTargetDyadMultiplier(newMultiplier, duration); + + uint64 multiplierSnapshot = valuer.dyadMultiplierSnapshot(); + + // after 1 second + vm.warp(vm.getBlockTimestamp() + 1 seconds); + assertEq(valuer.currentDyadMultiplier(), multiplierSnapshot - decreasePerSecond); + + // after 8 seconds + vm.warp(vm.getBlockTimestamp() + 7 seconds); + assertEq(valuer.currentDyadMultiplier(), multiplierSnapshot - 8 * decreasePerSecond); + + // after 10 seconds + vm.warp(vm.getBlockTimestamp() + 2 seconds); + assertEq(valuer.currentDyadMultiplier(), newMultiplier); + + // after some time + vm.warp(vm.getBlockTimestamp() + 1 hours); + assertEq(valuer.currentDyadMultiplier(), newMultiplier); + } + + function test_kerosine_deterministic_value() external { + uint64 multiplier = 1.25e12; + vm.prank(OWNER); + valuer.setTargetDyadMultiplier(multiplier, 0); + + uint256 dyadSupply = 100_000_000e18; + dyad.mint(address(this), dyadSupply); + + uint256 keroseneSupply = 1_000_000_000e18; + kerosene.mint(address(this), keroseneSupply); + + uint96[25] memory systemTvls = [ + 1_000_000_000e18, + 950_000_000e18, + 900_000_000e18, + 850_000_000e18, + 800_000_000e18, + 750_000_000e18, + 700_000_000e18, + 650_000_000e18, + 600_000_000e18, + 550_000_000e18, + 500_000_000e18, + 450_000_000e18, + 400_000_000e18, + 350_000_000e18, + 300_000_000e18, + 250_000_000e18, + 200_000_000e18, + 150_000_000e18, + 140_000_000e18, + 130_000_000e18, + 127_500_000e18, + 125_000_000e18, + 122_500_000e18, + 120_000_000e18, + 100_000_000e18 + ]; + + uint32[25] memory expectedPrices = [ + 0.875e8, + 0.825e8, + 0.775e8, + 0.725e8, + 0.675e8, + 0.625e8, + 0.575e8, + 0.525e8, + 0.475e8, + 0.425e8, + 0.375e8, + 0.325e8, + 0.275e8, + 0.225e8, + 0.175e8, + 0.125e8, + 0.075e8, + 0.025e8, + 0.015e8, + 0.005e8, + 0.0025e8, + 0.0, + 0.0, + 0.0, + 0.0 + ]; + + for (uint256 i; i < systemTvls.length; i++) { + uint256 expectedPrice = expectedPrices[i]; + + vault.setAssetBalance(systemTvls[i]); + + uint256 deterministicValue = valuer.deterministicValue(); + + assertEq(deterministicValue, expectedPrice); + } + } + + function test_kerosine_deterministic_value_is_zero() external { + uint64 multiplier = 1.25e12; + vm.prank(OWNER); + valuer.setTargetDyadMultiplier(multiplier, 0); + + uint256 dyadSupply = 100_000_000e18; + dyad.mint(address(this), dyadSupply); + + uint256 keroseneSupply = dyadSupply; + kerosene.mint(address(this), keroseneSupply); + + // kerosene supply < multiplier * dyadSupply + assertEq(valuer.deterministicValue(), 0); + + keroseneSupply = (multiplier * dyadSupply) / 1e12; + kerosene.mint(address(this), keroseneSupply); + + // kerosene supply == multiplier * dyadSupply + assertEq(valuer.deterministicValue(), 0); + } +} diff --git a/test/fork/v6/BaseV6.sol b/test/fork/v6/BaseV6.sol index 326aa41b..a1dabd6e 100644 --- a/test/fork/v6/BaseV6.sol +++ b/test/fork/v6/BaseV6.sol @@ -9,6 +9,9 @@ import {Licenser} from "../../../src/core/Licenser.sol"; import {Modifiers} from "../../Modifiers.sol"; import {IVault} from "../../../src/interfaces/IVault.sol"; import {VaultManagerV6} from "../../../src/core/VaultManagerV6.sol"; +import {KeroseneValuer} from "../../../src/staking/KeroseneValuer.sol"; +import {Kerosine} from "../../../src/staking/Kerosine.sol"; +import {KerosineManager} from "../../../src/core/KerosineManager.sol"; import {FixedPointMathLib} from "@solmate/src/utils/FixedPointMathLib.sol"; import {ERC20} from "@solmate/src/tokens/ERC20.sol"; @@ -26,6 +29,7 @@ struct Contracts { Vault ethVault; VaultWstEth wstEth; KeroseneVault keroseneVault; + KeroseneValuer keroseneValuer; } contract BaseTestV6 is Test, Modifiers, Parameters { @@ -49,12 +53,16 @@ contract BaseTestV6 is Test, Modifiers, Parameters { address bob = address(0x42); function setUp() public { - vm.createSelectFork(vm.envString("RPC_URL"), 21086097); + vm.createSelectFork(vm.envString("RPC_URL"), 21_086_097); + + KeroseneValuer keroseneValuer = new KeroseneValuer( + Kerosine(MAINNET_KEROSENE), KerosineManager(MAINNET_V2_KEROSENE_MANAGER), Dyad(MAINNET_V2_DYAD) + ); VaultManagerV6 impl = new VaultManagerV6(); vm.prank(MAINNET_FEE_RECIPIENT); VaultManagerV6(MAINNET_V2_VAULT_MANAGER).upgradeToAndCall( - address(impl), abi.encodeWithSelector(impl.initialize.selector) + address(impl), abi.encodeWithSelector(impl.initialize.selector, address(keroseneValuer)) ); weth = ERC20(MAINNET_WETH); @@ -66,7 +74,8 @@ contract BaseTestV6 is Test, Modifiers, Parameters { vaultManager: VaultManagerV6(MAINNET_V2_VAULT_MANAGER), ethVault: Vault(MAINNET_V2_WETH_VAULT), wstEth: VaultWstEth(MAINNET_V2_WSTETH_VAULT), - keroseneVault: KeroseneVault(MAINNET_V2_KEROSENE_V2_VAULT) + keroseneVault: KeroseneVault(MAINNET_V2_KEROSENE_V2_VAULT), + keroseneValuer: keroseneValuer }); ETH_TO_USD = contracts.ethVault.assetPrice();