diff --git a/.gitignore b/.gitignore index 6144e466c..876d5a8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ broadcast node_modules/ yarn-error.log +# Hardhat +artifacts/ + # random files on MacOs .DS_Store diff --git a/contracts/src/interfaces/IRenzo.sol b/contracts/src/interfaces/IRenzo.sol index e78de5953..3f71d00a9 100644 --- a/contracts/src/interfaces/IRenzo.sol +++ b/contracts/src/interfaces/IRenzo.sol @@ -58,7 +58,7 @@ interface IRenzoOracle { uint256 _ezETHBeingBurned, uint256 _existingEzETHSupply, uint256 _currentValueInProtocol - ) external pure returns (uint256); + ) external view returns (uint256); } interface IDepositQueue { diff --git a/contracts/test/MockEzEthPool.sol b/contracts/test/MockEzEthPool.sol new file mode 100644 index 000000000..7ac26c1bb --- /dev/null +++ b/contracts/test/MockEzEthPool.sol @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.20; + +import { MultiRolesAuthority } from "solmate/auth/authorities/MultiRolesAuthority.sol"; +import { FixedPointMath } from "../src/libraries/FixedPointMath.sol"; +import { ERC20Mintable } from "./ERC20Mintable.sol"; +import { IERC20 } from "../src/interfaces/IERC20.sol"; +import { IRestakeManager, IRenzoOracle } from "../src/interfaces/IRenzo.sol"; + +/// @author DELV +/// @title MockLido +/// @notice This mock yield source will accrue interest at a specified rate +/// Every stateful interaction will accrue interest, so the interest +/// accrual will approximate continuous compounding as the contract +/// is called more frequently. +/// @custom:disclaimer The language used in this code is for coding convenience +/// only, and is not intended to, and does not, have any +/// particular legal or regulatory significance. +contract MockEzEthPool is + IRestakeManager, + IRenzoOracle, + MultiRolesAuthority, + ERC20Mintable +{ + using FixedPointMath for uint256; + + // Interest State + uint256 internal _rate; + uint256 internal _lastUpdated; + + // Lido State + uint256 totalPooledEther; + uint256 totalShares; + + constructor( + uint256 _initialRate, + address _admin, + bool _isCompetitionMode, + uint256 _maxMintAmount + ) + ERC20Mintable( + "Renzo ezETH", + "ezETH", + 18, + _admin, + _isCompetitionMode, + _maxMintAmount + ) + { + _rate = _initialRate; + _lastUpdated = block.timestamp; + } + + /// Overrides /// + + function submit(address) external payable returns (uint256) { + // Accrue interest. + _accrue(); + + // If this is the first deposit, mint shares 1:1. + if (getTotalShares() == 0) { + totalShares = msg.value; + totalPooledEther = msg.value; + _mint(msg.sender, msg.value); + return msg.value; + } + + // Calculate the amount of stETH shares that should be minted. + uint256 shares = msg.value.mulDivDown( + getTotalShares(), + getTotalPooledEther() + ); + + // Update the Lido state. + totalPooledEther += msg.value; + totalShares += shares; + + // Mint the stETH tokens to the user. + _mint(msg.sender, msg.value); + + return shares; + } + + function transferShares( + address _recipient, + uint256 _sharesAmount + ) external returns (uint256) { + // Accrue interest. + _accrue(); + + // Calculate the amount of tokens that should be transferred. + uint256 tokenAmount = _sharesAmount.mulDivDown( + getTotalPooledEther(), + getTotalShares() + ); + + // Transfer the tokens to the user. + transfer(_recipient, tokenAmount); + + return tokenAmount; + } + + function transferSharesFrom( + address _sender, + address _recipient, + uint256 _sharesAmount + ) external returns (uint256) { + // Accrue interest. + _accrue(); + + // Calculate the amount of tokens that should be transferred. + uint256 tokenAmount = _sharesAmount.mulDivDown( + getTotalPooledEther(), + getTotalShares() + ); + + // Transfer the tokens to the user. + transferFrom(_sender, _recipient, tokenAmount); + + return tokenAmount; + } + + function getSharesByPooledEth( + uint256 _ethAmount + ) external view returns (uint256) { + return _ethAmount.mulDivDown(getTotalShares(), getTotalPooledEther()); + } + + function getPooledEthByShares( + uint256 _sharesAmount + ) public view returns (uint256) { + return + _sharesAmount.mulDivDown(getTotalPooledEther(), getTotalShares()); + } + + function getBufferedEther() external pure returns (uint256) { + return 0; + } + + function getTotalPooledEther() public view returns (uint256) { + return totalPooledEther + _getAccruedInterest(); + } + + function getTotalShares() public view returns (uint256) { + return totalShares; + } + + function sharesOf(address _account) external view returns (uint256) { + uint256 tokenBalance = balanceOf[_account]; + return tokenBalance.mulDivDown(getTotalShares(), getTotalPooledEther()); + } + + /// Mock /// + + function setRate(uint256 _rate_) external requiresAuthDuringCompetition { + _accrue(); + _rate = _rate_; + } + + function getRate() external view returns (uint256) { + return _rate; + } + + function _accrue() internal { + uint256 interest = _getAccruedInterest(); + if (interest > 0) { + totalPooledEther += interest; + } + _lastUpdated = block.timestamp; + } + + function _getAccruedInterest() internal view returns (uint256) { + if (_rate == 0) { + return 0; + } + + // base_balance = base_balance * (1 + r * t) + uint256 timeElapsed = (block.timestamp - _lastUpdated).divDown( + 365 days + ); + uint256 accrued = totalPooledEther.mulDown(_rate.mulDown(timeElapsed)); + return accrued; + } + + function calculateTVLs() + public + view + override + returns (uint256[][] memory, uint256[] memory, uint256) + { + uint256[][] memory operator_tokens_tvls; + uint256[] memory operator_tvls; + uint256 tvl = getTotalPooledEther(); + return (operator_tokens_tvls, operator_tvls, tvl); + } + + function depositETH() external payable { + revert("depositETH: Not Implemented"); + } + + function ezETH() external view returns (address) { + return address(this); + } + + function renzoOracle() external view returns (address) { + return address(this); + } + + // Renzo Oracle Functions // + + function lookupTokenValue( + IERC20, //_token, + uint256 //_balance + ) external pure returns (uint256) { + revert("lookupTokenValue: Not Implemented"); + } + + function lookupTokenAmountFromValue( + IERC20, // _token, + uint256 // _value + ) external pure returns (uint256) { + revert("lookupTokenValue: Not Implemented"); + } + + function lookupTokenValues( + IERC20[] memory, // _tokens, + uint256[] memory // _balances + ) external pure returns (uint256) { + revert("lookupTokenValue: Not Implemented"); + } + + function calculateMintAmount( + uint256, // _currentValueInProtocol, + uint256, // _newValueAdded, + uint256 // _existingEzETHSupply + ) external pure returns (uint256) { + revert("lookupTokenValue: Not Implemented"); + } + + function calculateRedeemAmount( + uint256 _ezETHBeingBurned, + uint256, // _existingEzETHSupply, + uint256 // _currentValueInProtocol + ) public view returns (uint256) { + return getPooledEthByShares(_ezETHBeingBurned); + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index 7f35f8027..a1e081a96 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -10,6 +10,7 @@ import { StETHInstanceDeployConfigInput, } from "./tasks"; import { RETHInstanceDeployConfigInput } from "./tasks/deploy/instances/reth"; +import { EzETHInstanceDeployConfigInput } from "./tasks/deploy/instances/ezeth"; const TEST_FACTORY: FactoryDeployConfigInput = { governance: "0xd94a3A0BfC798b98a700a785D5C610E8a2d5DBD8", @@ -103,6 +104,37 @@ const TEST_STETH: StETHInstanceDeployConfigInput = { }, }; +const TEST_EZETH: EzETHInstanceDeployConfigInput = { + name: "TEST_EZETH", + deploymentId: "0xabbabac", + salt: "0x69420", + contribution: "0.1", + fixedAPR: "0.5", + timestretchAPR: "0.5", + options: { + // destination: "0xsomeone", + asBase: false, + // extraData: "0x", + }, + poolDeployConfig: { + // vaultSharesToken: "0x...", + minimumShareReserves: "0.001", + minimumTransactionAmount: "0.001", + positionDuration: "30 days", + checkpointDuration: "1 day", + timeStretch: "0", + governance: "0xd94a3A0BfC798b98a700a785D5C610E8a2d5DBD8", + feeCollector: "0xd94a3A0BfC798b98a700a785D5C610E8a2d5DBD8", + sweepCollector: "0xd94a3A0BfC798b98a700a785D5C610E8a2d5DBD8", + fees: { + curve: "0.001", + flat: "0.0001", + governanceLP: "0.15", + governanceZombie: "0.03", + }, + }, +}; + const TEST_RETH: RETHInstanceDeployConfigInput = { name: "TEST_RETH", deploymentId: "0x666666666", @@ -165,6 +197,7 @@ const config: HardhatUserConfig = { erc4626: [TEST_ERC4626], steth: [TEST_STETH], reth: [TEST_RETH], + ezeth: [TEST_EZETH], }, }, sepolia: { diff --git a/tasks/README.md b/tasks/README.md index 8661df925..3b3ad5ec6 100644 --- a/tasks/README.md +++ b/tasks/README.md @@ -26,6 +26,7 @@ The complete list of tasks can be seen by running `npx hardhat --help` in your t deploy:coordinators:erc4626 deploys the ERC4626 deployment coordinator deploy:coordinators:reth deploys the RETH deployment coordinator deploy:coordinators:steth deploys the STETH deployment coordinator + deploy:coordinators:ezeth deploys the EzETH deployment coordinator deploy:factory deploys the hyperdrive factory to the configured chain deploy:forwarder deploys the ERC20ForwarderFactory to the configured chain deploy:instances:all deploys the ERC4626 deployment coordinator diff --git a/tasks/deploy/coordinators/ezeth.ts b/tasks/deploy/coordinators/ezeth.ts new file mode 100644 index 000000000..658458fdc --- /dev/null +++ b/tasks/deploy/coordinators/ezeth.ts @@ -0,0 +1,132 @@ +import { task, types } from "hardhat/config"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +dayjs.extend(duration); +import { parseEther, toFunctionSelector, zeroAddress } from "viem"; +import { zAddress } from "../utils"; +import { z } from "zod"; +import { DeployCoordinatorsBaseParams } from "./shared"; + +export let zEzETHCoordinatorDeployConfig = z.object({ + ezeth: zAddress.optional(), +}); + +export type EzETHCoordinatorDeployConfigInput = z.input< + typeof zEzETHCoordinatorDeployConfig +>; + +export type EzETHCoordinatorDeployConfig = z.infer< + typeof zEzETHCoordinatorDeployConfig +>; + +export type DeployCoordinatorsEzethParams = { + admin?: string; +}; + +task("deploy:coordinators:ezeth", "deploys the EzETHdeployment coordinator") + .addOptionalParam("admin", "admin address", undefined, types.string) + .setAction( + async ( + { admin }: DeployCoordinatorsEzethParams, + { + deployments, + run, + network, + viem, + getNamedAccounts, + config: hardhatConfig, + }, + ) => { + // Retrieve the HyperdriveFactory deployment artifact for the current network + let factory = await deployments.get("HyperdriveFactory"); + let factoryAddress = factory.address as `0x${string}`; + + // Set the admin address to the deployer address if one was not provided + if (!admin?.length) admin = (await getNamedAccounts())["deployer"]; + + // Deploy a mock Lido contract if no adress was provided + let ezeth = hardhatConfig.networks[network.name].coordinators?.ezeth; + if (!ezeth?.length) { + let mockEzEth = await viem.deployContract("MockEzEthPool", [ + parseEther("0.035"), + admin as `0x${string}`, + true, + parseEther("500"), + ]); + await deployments.save("MockEzEthPool", { + ...mockEzEth, + args: [ + parseEther("0.035"), + admin as `0x${string}`, + true, + parseEther("500"), + ], + }); + await run("deploy:verify", { + name: "MockEzEthPool", + }); + // allow minting by the general public + await mockEzEth.write.setPublicCapability([ + toFunctionSelector("mint(uint256)"), + true, + ]); + await mockEzEth.write.setPublicCapability([ + toFunctionSelector("mint(address,uint256)"), + true, + ]); + await mockEzEth.write.submit([zeroAddress], { + value: parseEther("0.001"), + }); + ezeth = mockEzEth.address; + } + + // Deploy the core deployer and all targets + await run("deploy:coordinators:shared", { + prefix: "ezeth", + } as DeployCoordinatorsBaseParams); + + // Deploy the coordinator + console.log("deploying EzETHHyperdriveDeployerCoordinator..."); + let args = [ + factoryAddress, + (await deployments.get("EzETHHyperdriveCoreDeployer")) + .address as `0x${string}`, + (await deployments.get("EzETHTarget0Deployer")) + .address as `0x${string}`, + (await deployments.get("EzETHTarget1Deployer")) + .address as `0x${string}`, + (await deployments.get("EzETHTarget2Deployer")) + .address as `0x${string}`, + (await deployments.get("EzETHTarget3Deployer")) + .address as `0x${string}`, + (await deployments.get("EzETHTarget4Deployer")) + .address as `0x${string}`, + ezeth, + ]; + let ezethCoordinator = await viem.deployContract( + "EzETHHyperdriveDeployerCoordinator", + args as any, + ); + await deployments.save("EzETHHyperdriveDeployerCoordinator", { + ...ezethCoordinator, + args, + }); + await run("deploy:verify", { + name: "EzETHHyperdriveDeployerCoordinator", + }); + + // Register the coordinator with governance if the factory's governance address is the deployer's address + let factoryContract = await viem.getContractAt( + "HyperdriveFactory", + factoryAddress, + ); + let factoryGovernanceAddress = await factoryContract.read.governance(); + let deployer = (await getNamedAccounts())["deployer"]; + if (deployer === factoryGovernanceAddress) { + console.log("adding EzETHHyperdriveDeployerCoordinator to factory"); + await factoryContract.write.addDeployerCoordinator([ + ezethCoordinator.address, + ]); + } + }, + ); diff --git a/tasks/deploy/coordinators/schema.ts b/tasks/deploy/coordinators/schema.ts index a3ecf078f..245a642e4 100644 --- a/tasks/deploy/coordinators/schema.ts +++ b/tasks/deploy/coordinators/schema.ts @@ -2,10 +2,11 @@ import { z } from "zod"; import { Prettify } from "../utils"; import { zRETHCoordinatorDeployConfig } from "./reth"; import { zStETHCoordinatorDeployConfig } from "./steth"; +import { zEzETHCoordinatorDeployConfig } from "./ezeth"; -export const zCoordinatorDeployConfig = zRETHCoordinatorDeployConfig.merge( - zStETHCoordinatorDeployConfig, -); +export const zCoordinatorDeployConfig = zRETHCoordinatorDeployConfig + .merge(zStETHCoordinatorDeployConfig) + .merge(zEzETHCoordinatorDeployConfig); export type CoordinatorDeployConfigInput = Prettify< z.input diff --git a/tasks/deploy/instances/ezeth.ts b/tasks/deploy/instances/ezeth.ts new file mode 100644 index 000000000..01e06fc04 --- /dev/null +++ b/tasks/deploy/instances/ezeth.ts @@ -0,0 +1,109 @@ +import { task, types } from "hardhat/config"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import { z } from "zod"; +import { + DeployInstanceParams, + PoolConfig, + PoolDeployConfig, + zInstanceDeployConfig, +} from "./schema"; + +dayjs.extend(duration); + +// Set the base token to always be the ETH letant. +export let zEzETHInstanceDeployConfig = zInstanceDeployConfig + .transform((v) => ({ + ...v, + poolDeployConfig: { + ...v.poolDeployConfig, + baseToken: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as `0x${string}`, + }, + })) + .superRefine((v, c) => { + // Contribution via the base token (ETH) are not allowed for EzETH. + if (v.options.asBase) { + c.addIssue({ + code: z.ZodIssueCode.custom, + message: + "`options.asBase` must be set to false for EzETH Hyperdrive instances.", + path: ["options.asBase"], + }); + } + }); + +export type EzETHInstanceDeployConfigInput = z.input< + typeof zEzETHInstanceDeployConfig +>; +export type EzETHInstanceDeployConfig = z.infer< + typeof zEzETHInstanceDeployConfig +>; + +task("deploy:instances:ezeth", "deploys the EzETH deployment coordinator") + .addParam("name", "name of the instance to deploy", undefined, types.string) + .addOptionalParam("admin", "admin address", undefined, types.string) + .setAction( + async ( + { name, admin }: DeployInstanceParams, + { + deployments, + run, + network, + viem, + getNamedAccounts, + config: hardhatConfig, + }, + ) => { + console.log(`starting hyperdrive deployment ${name}`); + let deployer = (await getNamedAccounts())["deployer"] as `0x${string}`; + // Read and parse the provided configuration file + let config = hardhatConfig.networks[network.name].instances?.ezeth?.find( + (i) => i.name === name, + ); + if (!config) + throw new Error( + `unable to find instance for network ${network.name} with name ${name}`, + ); + + // Set 'admin' to the deployer address if not specified via param + if (!admin?.length) admin = deployer; + + // Get the ezeth token address from the deployer coordinator + let coordinatorAddress = ( + await deployments.get("EzETHHyperdriveDeployerCoordinator") + ).address as `0x${string}`; + let coordinator = await viem.getContractAt( + "EzETHHyperdriveDeployerCoordinator", + coordinatorAddress, + ); + let ezeth = await viem.getContractAt( + "MockEzEthPool", + await coordinator.read.ezETH(), + ); + config.poolDeployConfig.vaultSharesToken = ezeth.address; + + // Ensure the deployer has sufficiet funds for the contribution. + let pc = await viem.getPublicClient(); + if ((await pc.getBalance({ address: deployer })) < config.contribution) + throw new Error("insufficient ETH balance for contribution"); + + // Obtain shares for the contribution. + await ezeth.write.submit([deployer], { value: config.contribution }); + + // Ensure the deployer has approved the deployer coordinator for ezeth shares. + let allowance = await ezeth.read.allowance([ + deployer, + coordinatorAddress, + ]); + if (allowance < config.contribution) { + console.log("approving coordinator for contribution..."); + await ezeth.write.approve([ + coordinatorAddress, + config.contribution * 2n, + ]); + } + + // Deploy the targets and hyperdrive instance + await run("deploy:instances:shared", { prefix: "ezeth", name }); + }, + ); diff --git a/tasks/deploy/type-extensions.ts b/tasks/deploy/type-extensions.ts index 868e8b4f5..0726ebed2 100644 --- a/tasks/deploy/type-extensions.ts +++ b/tasks/deploy/type-extensions.ts @@ -34,6 +34,11 @@ import { RETHInstanceDeployConfigInput, zRETHInstanceDeployConfig, } from "./instances/reth"; +import { + EzETHInstanceDeployConfig, + EzETHInstanceDeployConfigInput, + zEzETHInstanceDeployConfig, +} from "./instances/ezeth"; declare module "hardhat/types/config" { // We extend the user's HardhatNetworkUserConfig with our factory and instance configuration inputs. @@ -54,6 +59,7 @@ declare module "hardhat/types/config" { erc4626?: ERC4626InstanceDeployConfigInput[]; steth?: StETHInstanceDeployConfigInput[]; reth?: RETHInstanceDeployConfigInput[]; + ezeth?: EzETHInstanceDeployConfigInput[]; }; } @@ -65,6 +71,7 @@ declare module "hardhat/types/config" { erc4626?: ERC4626InstanceDeployConfig[]; steth?: StETHInstanceDeployConfig[]; reth?: RETHInstanceDeployConfig[]; + ezeth?: EzETHInstanceDeployConfig[]; }; } export interface HardhatNetworkConfig { @@ -74,6 +81,7 @@ declare module "hardhat/types/config" { erc4626?: ERC4626InstanceDeployConfig[]; steth?: StETHInstanceDeployConfig[]; reth?: RETHInstanceDeployConfig[]; + ezeth?: EzETHInstanceDeployConfig[]; }; } } @@ -97,6 +105,9 @@ extendConfig( steth: v.instances?.steth?.map((i) => zStETHInstanceDeployConfig.parse(i), ), + ezeth: v.instances?.steth?.map((i) => + zEzETHInstanceDeployConfig.parse(i), + ), reth: v.instances?.reth?.map((i) => zRETHInstanceDeployConfig.parse(i)), }; }); diff --git a/tasks/deploy/utils.ts b/tasks/deploy/utils.ts index 22554dc02..ce9c567c6 100644 --- a/tasks/deploy/utils.ts +++ b/tasks/deploy/utils.ts @@ -94,4 +94,5 @@ export const validHyperdrivePrefixes = { erc4626: "ERC4626", steth: "StETH", reth: "RETH", + ezeth: "EZETH", } as const;