diff --git a/docs/plantuml/ethenaProcesses.png b/docs/plantuml/ethenaProcesses.png new file mode 100644 index 00000000..88768825 Binary files /dev/null and b/docs/plantuml/ethenaProcesses.png differ diff --git a/docs/plantuml/ethenaProcesses.puml b/docs/plantuml/ethenaProcesses.puml new file mode 100644 index 00000000..78ab9edc --- /dev/null +++ b/docs/plantuml/ethenaProcesses.puml @@ -0,0 +1,117 @@ + +@startuml + +title "Ethena ARM Processes" + +actor "Operator" as op +actor "Anyone" as any +' participant "Ethena\nAPI" as api <> + +box Blockchain +participant "Ethena\nARM" as arm <> +participant "Ethena\nUnstaker" as unstaker <> +participant "Ethena\nMinting" as mint <> +participant "sUSDe\nToken" as susde <> +participant "USDe\nToken" as usde <> +' participant "USDT\nToken" as usdt <> +end box + +group unstake sUSDe + +op -> arm : requestBaseWithdrawal(\n base amount) +activate arm + +' arm -> factory : assignHelper() +' activate factory + +note over arm: increment unstaker index + +arm -> mint : cooldowns(\n unstaker) +activate mint +return liquid amount + +arm -> susde : transfer(\n unstaker,\n base amount) +activate susde +note right: transfer sUSDe\nfrom ARM\nto unstaker +return + +arm -> unstaker : requestUnstake(\n base amount) +activate unstaker +unstaker -> susde : cooldownShares(\n base amount) +activate susde +note right +burn sUSDe from unstaker +increment underlying USDe +restart 7 day cooldown +end note +return liquid amount +return liquid amount +return +note right: emit unstaker, base amount, liquid amount + +... 7 day cooldown ... + +any -> arm : claimBaseWithdrawals(\n unstaker) +activate arm + +arm -> mint : cooldowns(\n unstaker) +activate mint +return liquid amount + +arm -> unstaker : claimUnstake() +activate unstaker +unstaker -> susde : unstake(arm) +activate susde +note right: check cooldown passed +susde -> usde : transfer(\n arm,\n amount) +activate usde +note right: transfer all\nunderlying USDe\nto ARM +return +return +return +note right: emit unstaker, liquid amount +return + +end group + +' group redeem USDe for USDT + +' op -> api : GET Request For Quote\npayload: {\n pair,\n side,\n size,\n benefactor} +' activate api +' return order details: {\n rfq_id,\n collateral_amount,\n amount...} + +' op -> op : sign(hash(order details)) +' note right: sign order hash using Operator's private key + +' op -> arm : redeemEthenaRequest (\n order details,\n signature) +' activate arm +' note right +' verify redeem price >= cross price +' verify collateral asset = USDT +' store map of order hash to signature +' end note + +' arm -> usde : approve(\n amount,\n Ethena Minting) +' activate usde +' return +' return + +' op -> api : POST Submit Order\nparams:\n signature,\n signature_type=EIP1271\npayload: order details +' activate api +' api -> mint : redeem (\n order details,\n signature: {\n bytes, type: 1}) +' activate mint +' mint -> arm : isValidSignature(\n order hash,\n signature bytes) +' activate arm +' return +' mint -> usde : burnFrom(\n arm,\n amount) +' activate usde +' note right: burn USDe from ARM +' return +' mint -> usdt : transfer(\n arm,\n amount) +' activate usdt +' note right: transfer USDT to ARM +' return +' return +' return tx hash + +' end group diff --git a/script/deploy/DeployManager.sol b/script/deploy/DeployManager.sol index c0f02f45..f36ca58e 100644 --- a/script/deploy/DeployManager.sol +++ b/script/deploy/DeployManager.sol @@ -25,6 +25,7 @@ import {UpgradeOriginARMSetBufferScript} from "./sonic/005_UpgradeOriginARMSetBu import {UpgradeLidoARMAssetScript} from "./mainnet/010_UpgradeLidoARMAssetScript.sol"; import {DeployEtherFiARMScript} from "./mainnet/011_DeployEtherFiARMScript.sol"; import {UpgradeEtherFiARMScript} from "./mainnet/012_UpgradeEtherFiARMScript.sol"; +import {DeployEthenaARMScript} from "./mainnet/014_DeployEthenaARMScript.sol"; contract DeployManager is Script { using stdJson for string; @@ -86,6 +87,7 @@ contract DeployManager is Script { _runDeployFile(new UpgradeLidoARMAssetScript()); _runDeployFile(new DeployEtherFiARMScript()); _runDeployFile(new UpgradeEtherFiARMScript()); + _runDeployFile(new DeployEthenaARMScript()); } else if (block.chainid == 17000) { // Holesky _runDeployFile(new DeployCoreHoleskyScript()); diff --git a/script/deploy/mainnet/014_DeployEthenaARMScript.sol b/script/deploy/mainnet/014_DeployEthenaARMScript.sol new file mode 100644 index 00000000..9de78f2e --- /dev/null +++ b/script/deploy/mainnet/014_DeployEthenaARMScript.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry imports +import {console} from "forge-std/console.sol"; + +// Contract imports +import {Proxy} from "contracts/Proxy.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; +import {EthenaARM} from "contracts/EthenaARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {MorphoMarket} from "contracts/markets/MorphoMarket.sol"; +import {EthenaUnstaker} from "contracts/EthenaARM.sol"; +import {IWETH, IStakedUSDe} from "contracts/Interfaces.sol"; +import {Abstract4626MarketWrapper} from "contracts/markets/Abstract4626MarketWrapper.sol"; + +// Deployment imports +import {GovProposal, GovSixHelper} from "contracts/utils/GovSixHelper.sol"; +import {AbstractDeployScript} from "../AbstractDeployScript.sol"; + +contract DeployEthenaARMScript is AbstractDeployScript { + using GovSixHelper for GovProposal; + + GovProposal public govProposal; + + string public constant override DEPLOY_NAME = "014_DeployEthenaARMScript"; + bool public constant override proposalExecuted = true; + + Proxy morphoMarketProxy; + EthenaARM armImpl; + MorphoMarket morphoMarket; + Proxy armProxy; + + uint256 public constant MAX_UNSTAKERS = 42; + + function _execute() internal override { + console.log("Deploy:", DEPLOY_NAME); + console.log("------------"); + + // 1. Deploy new ARM proxy contract + armProxy = new Proxy(); + _recordDeploy("ETHENA_ARM", address(armProxy)); + + // 2. Deploy proxy for the CapManager + Proxy capManProxy = new Proxy(); + _recordDeploy("ETHENA_ARM_CAP_MAN", address(capManProxy)); + + // 3. Deploy CapManager implementation + CapManager capManagerImpl = new CapManager(address(armProxy)); + _recordDeploy("ETHENA_ARM_CAP_IMPL", address(capManagerImpl)); + + // 4. Initialize Proxy with CapManager implementation and set the owner to the deployer for now + bytes memory capManData = abi.encodeWithSignature("initialize(address)", Mainnet.ARM_RELAYER); + capManProxy.initialize(address(capManagerImpl), deployer, capManData); + CapManager capManager = CapManager(address(capManProxy)); + + // 4. Set total assets and liquidity provider caps + capManager.setTotalAssetsCap(250 ether); + capManager.setAccountCapEnabled(true); + address[] memory lpAccounts = new address[](1); + lpAccounts[0] = Mainnet.TREASURY_LP; + capManager.setLiquidityProviderCaps(lpAccounts, 250 ether); + + // 5. Transfer ownership of CapManager to the mainnet 5/8 multisig + capManProxy.setOwner(Mainnet.GOV_MULTISIG); + + // 6. Deploy new Ethena implementation + uint256 claimDelay = tenderlyTestnet ? 1 minutes : 10 minutes; + armImpl = new EthenaARM( + Mainnet.USDE, + Mainnet.SUSDE, + claimDelay, + 1e7, // minSharesToRedeem + 1e18 // allocateThreshold + ); + _recordDeploy("ETHENA_ARM_IMPL", address(armImpl)); + + // 7. Give the deployer a tiny amount of USDe for the initialization + // This can be skipped if the deployer already has USDe + IWETH(Mainnet.USDE).approve(address(armProxy), 1e13); + + // 8. Initialize proxy, set the owner to deployer, set the operator to the ARM Relayer + bytes memory armData = abi.encodeWithSignature( + "initialize(string,string,address,uint256,address,address)", + "Ethena Staked USDe ARM", // name + "ARM-sUSDe-USDe", // symbol + Mainnet.ARM_RELAYER, // Operator + 2000, // 20% performance fee + Mainnet.BUYBACK_OPERATOR, // Fee collector + address(capManager) + ); + armProxy.initialize(address(armImpl), deployer, armData); + + console.log("Initialized Ethena ARM"); + + // 10. Deploy MorphoMarket proxy + morphoMarketProxy = new Proxy(); + _recordDeploy("MORPHO_MARKET_ETHENA", address(morphoMarketProxy)); + + // 11. Deploy MorphoMarket + morphoMarket = new MorphoMarket(address(armProxy), Mainnet.MORPHO_MARKET_ETHENA); + _recordDeploy("MORPHO_MARKET_ETHENA_IMPL", address(morphoMarket)); + + // 12. Initialize MorphoMarket proxy with the implementation, Timelock as owner + bytes memory data = abi.encodeWithSelector( + Abstract4626MarketWrapper.initialize.selector, Mainnet.STRATEGIST, Mainnet.MERKLE_DISTRIBUTOR + ); + morphoMarketProxy.initialize(address(morphoMarket), Mainnet.TIMELOCK, data); + + // 13. Set crossPrice to 0.9998 USDe + uint256 crossPrice = 0.9998 * 1e36; + EthenaARM(payable(address(armProxy))).setCrossPrice(crossPrice); + + // 14. Add Morpho Market as an active market + address[] memory markets = new address[](1); + markets[0] = address(morphoMarketProxy); + EthenaARM(payable(address(armProxy))).addMarkets(markets); + + // 15. Set Morpho Market as the active market + EthenaARM(payable(address(armProxy))).setActiveMarket(address(morphoMarketProxy)); + + // 16. Set ARM buffer to 10% + EthenaARM(payable(address(armProxy))).setARMBuffer(0.1e18); // 10% buffer + + // 17. Deploy Unstakers + address[MAX_UNSTAKERS] memory unstakers = _deployUnstakers(); + + // 18. Set Unstakers in the ARM + EthenaARM(payable(address(armProxy))).setUnstakers(unstakers); + + // 19. Transfer ownership of ARM to the 5/8 multisig + armProxy.setOwner(Mainnet.GOV_MULTISIG); + + console.log("Finished deploying", DEPLOY_NAME); + } + + function _deployUnstakers() internal returns (address[MAX_UNSTAKERS] memory unstakers) { + for (uint256 i = 0; i < MAX_UNSTAKERS; i++) { + address unstaker = address(new EthenaUnstaker(payable(armProxy), IStakedUSDe(Mainnet.SUSDE))); + unstakers[i] = address(unstaker); + console.log("Deployed unstaker", i, address(unstaker)); + } + return unstakers; + } +} diff --git a/src/contracts/EthenaARM.sol b/src/contracts/EthenaARM.sol new file mode 100644 index 00000000..e7a2e22e --- /dev/null +++ b/src/contracts/EthenaARM.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.23; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {AbstractARM} from "./AbstractARM.sol"; +import {EthenaUnstaker} from "./EthenaUnstaker.sol"; +import {IERC20, IStakedUSDe, UserCooldown} from "./Interfaces.sol"; + +/** + * @title Ethena sUSDe/USDe Automated Redemption Manager (ARM) + * @author Origin Protocol Inc + */ +contract EthenaARM is Initializable, AbstractARM { + /// @notice The delay before a new unstake request can be made + uint256 public constant DELAY_REQUEST = 3 hours; + /// @notice The maximum number of unstaker helper contracts + uint8 public constant MAX_UNSTAKERS = 42; + /// @notice The address of Ethena's synthetic dollar token (USDe) + IERC20 public immutable usde; + /// @notice The address of Ethena's staked synthetic dollar token (sUSDe) + IStakedUSDe public immutable susde; + + /// @notice The total amount of liquidity asset (USDe) currently in cooldown + uint256 internal _liquidityAmountInCooldown; + /// @notice Array of unstaker helper contracts + address[MAX_UNSTAKERS] public unstakers; + /// @notice The index of the next unstaker to use in the round robin + uint8 public nextUnstakerIndex; + /// @notice The timestamp of the last request made + uint32 public lastRequestTimestamp; + + event RequestBaseWithdrawal(address indexed unstaker, uint256 baseAmount, uint256 liquidityAmount); + event ClaimBaseWithdrawals(address indexed unstaker, uint256 liquidityAmount); + + /// @param _usde The address of Ethena's synthetic dollar token (USDe) + /// @param _susde The address of Ethena's staked synthetic dollar token (sUSDe) + /// @param _claimDelay The delay in seconds before a user can claim a redeem from the request + /// @param _minSharesToRedeem The minimum amount of shares to redeem from the active lending market + /// @param _allocateThreshold The minimum amount of liquidity assets in excess of the ARM buffer before + /// the ARM can allocate to a active lending market. + constructor( + address _usde, + address _susde, + uint256 _claimDelay, + uint256 _minSharesToRedeem, + int256 _allocateThreshold + ) AbstractARM(_usde, _susde, _usde, _claimDelay, _minSharesToRedeem, _allocateThreshold) { + usde = IERC20(_usde); + susde = IStakedUSDe(_susde); + + _disableInitializers(); + } + + /// @notice Initialize the storage variables stored in the proxy contract. + /// The deployer that calls initialize has to approve the ARM's proxy contract to transfer 1e12 USDe. + /// @param _name The name of the liquidity provider (LP) token. + /// @param _symbol The symbol of the liquidity provider (LP) token. + /// @param _operator The address of the account that can request and claim withdrawals. + /// @param _fee The performance fee that is collected by the feeCollector measured in basis points (1/100th of a percent). + /// 10,000 = 100% performance fee + /// 1,500 = 15% performance fee + /// @param _feeCollector The account that can collect the performance fee + /// @param _capManager The address of the CapManager contract + function initialize( + string calldata _name, + string calldata _symbol, + address _operator, + uint256 _fee, + address _feeCollector, + address _capManager + ) external initializer { + _initARM(_operator, _name, _symbol, _fee, _feeCollector, _capManager); + } + + /// @notice Request a cooldown of USDe from Ethena's Staked USDe (sUSDe) contract. + /// @dev Uses a round robin to select the next unstaker helper contract. + /// @param baseAmount The amount of staked USDe (sUSDe) to withdraw. + function requestBaseWithdrawal(uint256 baseAmount) external onlyOperatorOrOwner { + require(block.timestamp >= lastRequestTimestamp + DELAY_REQUEST, "EthenaARM: Delay not passed"); + lastRequestTimestamp = uint32(block.timestamp); + + // Get the next unstaker contract in the round robin + address unstaker = unstakers[nextUnstakerIndex]; + // Ensure unstaker is valid + require(unstaker != address(0), "EthenaARM: Invalid unstaker"); + + // Ensure unstaker isn't used during last 7 days + UserCooldown memory cooldown = susde.cooldowns(address(unstaker)); + require(cooldown.underlyingAmount == 0, "EthenaARM: Unstaker in cooldown"); + + // Update last used unstaker for the day. Safe to cast as there is a maximum of MAX_UNSTAKERS + nextUnstakerIndex = uint8((nextUnstakerIndex + 1) % MAX_UNSTAKERS); + + // Transfer sUSDe to the helper contract + susde.transfer(unstaker, baseAmount); + + uint256 liquidityAmount = EthenaUnstaker(unstaker).requestUnstake(baseAmount); + + _liquidityAmountInCooldown += liquidityAmount; + + // Emit event for the request + emit RequestBaseWithdrawal(unstaker, baseAmount, liquidityAmount); + } + + /// @notice Claim all the USDe that is now claimable from the Staked USDe contract. + /// Reverts with `InvalidCooldown` from the Staked USDe contract if the cooldown period has not yet passed. + function claimBaseWithdrawals(address unstaker) external { + UserCooldown memory cooldown = susde.cooldowns(address(unstaker)); + require(cooldown.underlyingAmount > 0, "EthenaARM: No cooldown amount"); + + _liquidityAmountInCooldown -= cooldown.underlyingAmount; + + // Claim all the underlying USDe that has cooled down for the unstaker and send to the ARM + EthenaUnstaker(unstaker).claimUnstake(); + + emit ClaimBaseWithdrawals(unstaker, cooldown.underlyingAmount); + } + + /// @dev Gets the total amount of USDe waiting to be claimed from the Staked USDe contract. + /// This can be for many different cooldowns. + /// This can be either in the cooldown period or ready to be claimed. + function _externalWithdrawQueue() internal view override returns (uint256) { + return _liquidityAmountInCooldown; + } + + /// @dev Convert between base asset (sUSDe) and liquidity asset (USDe). + /// ERC-4626 convert functions are used as the preview functions can return a + /// smaller amount if the contract is paused or has high utilization. + /// Although that is not the case the the sUSDe implementation. + /// @param token The address of the token to convert from. sUSDe or USDe. + /// @param amount The amount of the token to convert from. + /// @return The converted to amount. + function _convert(address token, uint256 amount) internal view override returns (uint256) { + if (token == baseAsset) { + // Convert base asset (sUSDe) to liquidity asset (USDe) + return susde.convertToAssets(amount); + } else if (token == liquidityAsset) { + // Convert liquidity asset (USDe) to base asset (sUSDe) + return susde.convertToShares(amount); + } else { + revert("EthenaARM: Invalid token"); + } + } + + /// @notice Set the unstaker helper contracts. + /// @param _unstakers The array of unstaker contract addresses. + function setUnstakers(address[MAX_UNSTAKERS] calldata _unstakers) external onlyOwner { + require(_unstakers.length == MAX_UNSTAKERS, "EthenaARM: Invalid unstakers length"); + unstakers = _unstakers; + } +} diff --git a/src/contracts/EthenaUnstaker.sol b/src/contracts/EthenaUnstaker.sol new file mode 100644 index 00000000..1fd66dc6 --- /dev/null +++ b/src/contracts/EthenaUnstaker.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.23; + +import {IStakedUSDe} from "./Interfaces.sol"; + +/** + * @title A helper contract that allows the ARM to have multiple sUSDe cooldowns in parallel. + * @author Origin Protocol Inc + */ +contract EthenaUnstaker { + /// The parent Ethena ARM contract + address public immutable arm; + /// @notice The address of Ethena's staked synthetic dollar token (sUSDe) + IStakedUSDe public immutable susde; + + constructor(address _arm, IStakedUSDe _susde) { + arm = _arm; + susde = _susde; + } + + /// @notice Request a cooldown of USDe from Ethena's Staked USDe (sUSDe) contract. + /// @param sUSDeAmount The amount of staked USDe (sUSDe) to withdraw. + /// @return usde The amount of underlying USDe that will be withdrawable after the cooldown period. + function requestUnstake(uint256 sUSDeAmount) external returns (uint256 usde) { + require(msg.sender == arm, "Only ARM can request unstake"); + usde = susde.cooldownShares(sUSDeAmount); + } + + /// @notice Claim the underlying USDe after the cooldown period has ended and send to the ARM. + /// Reverts with `InvalidCooldown` from the Staked USDe contract if the cooldown period has not yet passed. + function claimUnstake() external { + require(msg.sender == arm, "Only ARM can request unstake"); + susde.unstake(arm); + } +} diff --git a/src/contracts/Interfaces.sol b/src/contracts/Interfaces.sol index 04d3d997..d013642c 100644 --- a/src/contracts/Interfaces.sol +++ b/src/contracts/Interfaces.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + interface IERC20 { function totalSupply() external view returns (uint256); function balanceOf(address account) external view returns (uint256); @@ -333,3 +335,26 @@ interface IDistributor { bytes32[][] calldata proofs ) external; } + +// Ethena Interfaces + +struct UserCooldown { + uint104 cooldownEnd; + uint152 underlyingAmount; +} + +interface IStakedUSDe is IERC4626 { + // Errors // + /// @notice Error emitted when the shares amount to redeem is greater than the shares balance of the owner + error ExcessiveRedeemAmount(); + /// @notice Error emitted when the shares amount to withdraw is greater than the shares balance of the owner + error ExcessiveWithdrawAmount(); + + function cooldownAssets(uint256 assets) external returns (uint256 shares); + + function cooldownShares(uint256 shares) external returns (uint256 assets); + + function unstake(address receiver) external; + + function cooldowns(address receiver) external view returns (UserCooldown memory); +} diff --git a/src/contracts/utils/Addresses.sol b/src/contracts/utils/Addresses.sol index cd67ec18..394855cd 100644 --- a/src/contracts/utils/Addresses.sol +++ b/src/contracts/utils/Addresses.sol @@ -32,6 +32,8 @@ library Mainnet { address public constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; address public constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; address public constant MORPHO = 0x58D97B57BB95320F9a05dC918Aef65434969c2B2; + address public constant USDE = 0x4c9EDD5852cd905f086C759E8383e09bff1E68B3; + address public constant SUSDE = 0x9D39A5DE30e57443BfF2A8307A4256c8797A3497; // Contracts address public constant OETH_VAULT = 0x39254033945AA2E4809Cc2977E7087BEE48bd7Ab; @@ -54,6 +56,8 @@ library Mainnet { // Morpho Market address public constant MORPHO_MARKET_MEVCAPITAL = 0x9a8bC3B04b7f3D87cfC09ba407dCED575f2d61D8; address public constant MORPHO_MARKET_ETHERFI = 0x4881Ef0BF6d2365D3dd6499ccd7532bcdBCE0658; + // Apostro Ethena USDe is currently the only curated Morpho market that takes USDe + address public constant MORPHO_MARKET_ETHENA = 0x4EDfaB296F8Eb15aC0907CF9eCb7079b1679Da57; // Merkle Distributor address public constant MERKLE_DISTRIBUTOR = 0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae; diff --git a/src/js/tasks/liquidity.js b/src/js/tasks/liquidity.js index b9e50724..fddb93be 100644 --- a/src/js/tasks/liquidity.js +++ b/src/js/tasks/liquidity.js @@ -72,7 +72,8 @@ const snap = async ({ arm, block, gas, amount, oneInch, kyber }) => { if (arm !== "Oeth") { await logWithdrawalQueue(armContract, blockTag, liquidityBalance); - const armPrices = await logArmPrices({ block, gas }, armContract); + const days = arm === "EtherFi" ? 5 : undefined; + const armPrices = await logArmPrices({ block, gas, days }, armContract); const pair = arm === "Lido" @@ -82,12 +83,12 @@ const snap = async ({ arm, block, gas, amount, oneInch, kyber }) => { : arm == "Origin" ? "OS/wS" : "Unknown"; + const assets = { + liquid: await armContract.liquidityAsset(), + base: await armContract.baseAsset(), + }; if (oneInch) { - const assets = { - liquid: await armContract.liquidityAsset(), - base: await armContract.baseAsset(), - }; const fee = arm === "Lido" ? 10n : 30n; const chainId = await (await ethers.provider.getNetwork()).chainId; @@ -100,11 +101,6 @@ const snap = async ({ arm, block, gas, amount, oneInch, kyber }) => { if (kyber && arm !== "Origin") { // Kyber does not support Sonic - const assets = { - liquid: await armContract.liquidityAsset(), - base: await armContract.baseAsset(), - }; - await logKyberPrices({ amount, assets, pair }, armPrices); } } diff --git a/src/js/tasks/markets.js b/src/js/tasks/markets.js index a50f3805..087dca86 100644 --- a/src/js/tasks/markets.js +++ b/src/js/tasks/markets.js @@ -8,10 +8,113 @@ const { getUniswapV3SpotPrices } = require("../utils/uniswap"); const { getSigner } = require("../utils/signers"); const { getFluidSpotPrices } = require("../utils/fluid"); const { mainnet } = require("../utils/addresses"); +const { resolveAddress } = require("../utils/assets"); const log = require("../utils/logger")("task:markets"); -const logArmPrices = async ({ blockTag, gas }, arm) => { +const snapMarket = async ({ + amount, + base, + liquid, + wrapped, + days, + fee1Inch, + oneInch, + kyber, +}) => { + const baseAddress = await resolveAddress(base.toUpperCase()); + const liquidAddress = await resolveAddress(liquid.toUpperCase()); + const assets = { + liquid: liquidAddress, + base: baseAddress, + }; + + // Assume the wrapped base asset is ERC-4626 + let wrapPrice; + if (wrapped) { + const vault = await ethers.getContractAt("IERC4626", baseAddress); + const assetAmount = await vault.convertToAssets( + parseUnits(amount.toString(), 18), + ); + wrapPrice = + (assetAmount * parseUnits("1")) / parseUnits(amount.toString(), 18); + + console.log( + `\nWrapped price: ${formatUnits(wrapPrice, 18)} ${base}/${liquid}`, + ); + } + + const pair = wrapped ? `unwrapped ${base}->${liquid}` : `${base}/${liquid}`; + if (oneInch) { + const fee = BigInt(fee1Inch); + + const chainId = await (await ethers.provider.getNetwork()).chainId; + const marketPrices = await log1InchPrices({ + amount, + assets, + fee, + pair, + chainId, + wrapPrice, + }); + + if (days) { + logDiscount(marketPrices.sellPrice, days); + } + } + + if (kyber) { + const marketPrices = await logKyberPrices({ + amount, + days, + assets, + pair, + wrapPrice, + }); + + if (days) { + logDiscount(marketPrices.sellPrice, days); + } + } +}; + +const logDiscountsOverDays = (marketPrice, daysArray) => { + // take 80% of the discount to cover the 20% fee + const discount = BigInt(1e18) - marketPrice; + const discountPostFee = (discount * 8n) / 10n; + console.log( + `\nYield on ${formatUnits( + discountPostFee * 10000n, + 18, + )} bps discount after 20% fee`, + ); + + let output = ""; + for (const days of daysArray) { + const discountAPY = + (discountPostFee * 36500n) / BigInt(days) / parseUnits("1", 16); + output += `${days} days ${formatUnits(discountAPY, 2)}%, `; + } + output = output.slice(0, -2); // remove trailing comma and space + output += " APY"; + + console.log(output); +}; + +const logDiscount = (marketPrice, days) => { + const discount = BigInt(1e18) - marketPrice; + // take 80% of the discount to cover the 20% fee + const discountPostFee = (discount * 8n) / 10n; + const discountAPY = (discountPostFee * 365n) / BigInt(days); + console.log( + `Discount over ${days} days after 20% fee: ${formatUnits( + discountPostFee * 10000n, + 18, + )} bps, ${formatUnits(discountAPY * 100n, 18)}% APY`, + ); +}; + +const logArmPrices = async ({ blockTag, gas, days }, arm) => { console.log(`\nARM Prices`); // The rate of 1 WETH for stETH to 36 decimals from the perspective of the AMM. ie WETH/stETH // from the trader's perspective, this is the stETH/WETH buy price @@ -80,31 +183,11 @@ const logArmPrices = async ({ blockTag, gas }, arm) => { // Origin rates are to 36 decimals console.log(`spread : ${formatUnits(spread, 14)} bps`); - // take 80% of the discount to cover the 20% fee - const buyDiscount = BigInt(1e18) - buyPrice; - const buyDiscountPostFee = (buyDiscount * 8n) / 10n; - - console.log( - `\nYield on ${formatUnits( - buyDiscount * 10000n, - 18, - )} bps buy discount after fee`, - ); - console.log( - `1 day ${formatUnits( - buyDiscountPostFee * 36500n, - 18, - )}%, 2 days ${formatUnits( - (buyDiscountPostFee * 36500n) / 2n, - 18, - )}%, 3 days ${formatUnits( - (buyDiscountPostFee * 36500n) / 3n, - 18, - )}%, 4 days ${formatUnits( - (buyDiscountPostFee * 36500n) / 4n, - 18, - )}%, 5 days ${formatUnits((buyDiscountPostFee * 36500n) / 5n, 18)}% APY`, - ); + if (days) { + logDiscount(buyPrice, days); + } else { + logDiscountsOverDays(buyPrice, [1, 2, 3, 4, 5, 7]); + } return { buyPrice, @@ -130,16 +213,20 @@ const logMarketPrices = async ({ ); console.log(`\n${marketName} prices for swap size ${amount}`); - // Note market sell is from the trader's perspective while the ARM sell price is from the AMM's perspective - const buyRateDiff = marketPrices.buyPrice - armPrices.sellPrice; - const armBuyToMarketSellDiff = marketPrices.buyPrice - armPrices.buyPrice; - const buyGasCosts = gas - ? `, ${marketPrices.buyGas.toLocaleString()} gas` - : ""; + let armDiff = ""; + if (armPrices) { + // Note market sell is from the trader's perspective while the ARM sell price is from the AMM's perspective + const buyRateDiff = marketPrices.buyPrice - armPrices.sellPrice; + const armBuyToMarketSellDiff = marketPrices.buyPrice - armPrices.buyPrice; + const buyGasCosts = gas + ? `, ${marketPrices.buyGas.toLocaleString()} gas` + : ""; + armDiff = `, ${formatUnits(armBuyToMarketSellDiff, 14)} bps from ARM buy, ${formatUnits(buyRateDiff, 14)} bps from ARM sell${buyGasCosts}`; + } console.log( `buy : ${formatUnits(marketPrices.buyPrice, 18).padEnd( 20, - )} ${pair}, ${formatUnits(armBuyToMarketSellDiff, 14)} bps from ARM buy, ${formatUnits(buyRateDiff, 14)} bps from ARM sell${buyGasCosts}`, + )} ${pair}${armDiff}`, ); console.log( @@ -147,25 +234,32 @@ const logMarketPrices = async ({ ); // Note market buy is from the trader's perspective while the ARM buy price is from the AMM's perspective - const sellRateDiff = marketPrices.sellPrice - armPrices.buyPrice; - const sellGasCosts = gas - ? `, ${marketPrices.sellGas.toLocaleString()} gas` - : ""; + if (armPrices) { + const sellRateDiff = marketPrices.sellPrice - armPrices.buyPrice; + const sellGasCosts = gas + ? `, ${marketPrices.sellGas.toLocaleString()} gas` + : ""; + armDiff = `, ${formatUnits(sellRateDiff, 14).padEnd( + 17, + )} bps from ARM buy${sellGasCosts}`; + } console.log( `sell : ${formatUnits(marketPrices.sellPrice, 18).padEnd( 20, - )} ${pair}, ${formatUnits(sellRateDiff, 14).padEnd( - 17, - )} bps from ARM buy${sellGasCosts}`, + )} ${pair}${armDiff}`, ); console.log(`spread : ${formatUnits(marketPrices.spread, 14)} bps`); }; const log1InchPrices = async (options, armPrices) => { - const { amount, assets, fee, chainId } = options; + const { amount, assets, fee, chainId, wrapPrice } = options; const marketPrices = await get1InchPrices(amount, assets, fee, chainId); + if (wrapPrice) { + unwrapPrices(marketPrices, wrapPrice); + } + await logMarketPrices({ ...options, marketPrices, @@ -173,6 +267,8 @@ const log1InchPrices = async (options, armPrices) => { marketName: "1Inch", }); + if (armPrices === undefined) return marketPrices; + console.log( `\nBest buy : ${ armPrices.sellPrice < marketPrices.buyPrice ? "Origin" : "1Inch" @@ -186,10 +282,14 @@ const log1InchPrices = async (options, armPrices) => { }; const logKyberPrices = async (options, armPrices) => { - const { amount, assets } = options; + const { amount, assets, wrapPrice } = options; const marketPrices = await getKyberPrices(amount, assets); + if (wrapPrice) { + unwrapPrices(marketPrices, wrapPrice); + } + await logMarketPrices({ ...options, marketPrices, @@ -197,6 +297,8 @@ const logKyberPrices = async (options, armPrices) => { marketName: "Kyber", }); + if (armPrices === undefined) return marketPrices; + console.log( `\nBest buy : ${ armPrices.sellPrice < marketPrices.buyPrice ? "Origin" : "Kyber" @@ -209,6 +311,18 @@ const logKyberPrices = async (options, armPrices) => { return marketPrices; }; +const unwrapPrices = (marketPrices, wrapPrice) => { + // Adjust prices back to unwrapped base asset + marketPrices.buyPrice = (marketPrices.buyPrice * parseUnits("1")) / wrapPrice; + marketPrices.sellPrice = + (marketPrices.sellPrice * parseUnits("1")) / wrapPrice; + marketPrices.midPrice = (marketPrices.midPrice * parseUnits("1")) / wrapPrice; + marketPrices.buyToAmount = + (marketPrices.buyToAmount * parseUnits("1")) / wrapPrice; + marketPrices.sellToAmount = + (marketPrices.sellToAmount * parseUnits("1")) / wrapPrice; +}; + const logCurvePrices = async (options, armPrices) => { const marketPrices = await getCurvePrices(options); @@ -297,6 +411,7 @@ const logWrappedEtherFiPrices = async ({ amount, armPrices }) => { }; module.exports = { + snapMarket, log1InchPrices, logKyberPrices, logArmPrices, diff --git a/src/js/tasks/tasks.js b/src/js/tasks/tasks.js index 63e712a3..3467e494 100644 --- a/src/js/tasks/tasks.js +++ b/src/js/tasks/tasks.js @@ -33,6 +33,7 @@ const { snap, withdrawRequestStatus, } = require("./liquidity"); +const { snapMarket } = require("./markets"); const { autoRequestWithdraw, autoClaimWithdraw, @@ -613,7 +614,12 @@ task("claimRedeemARM").setAction(async (_, __, runSuper) => { // Capital Management subtask("setLiquidityProviderCaps", "Set deposit cap for liquidity providers") - .addParam("arm", "Name of the ARM. eg Lido, Origin or EtherFi", "Lido", types.string) + .addParam( + "arm", + "Name of the ARM. eg Lido, Origin or EtherFi", + "Lido", + types.string, + ) .addParam( "cap", "Amount of WETH not scaled to 18 decimals", @@ -632,7 +638,12 @@ task("setLiquidityProviderCaps").setAction(async (_, __, runSuper) => { }); subtask("setTotalAssetsCap", "Set total assets cap") - .addParam("arm", "Name of the ARM. eg Lido, Origin or EtherFi", "Lido", types.string) + .addParam( + "arm", + "Name of the ARM. eg Lido, Origin or EtherFi", + "Lido", + types.string, + ) .addParam( "cap", "Amount of WETH not scaled to 18 decimals", @@ -1094,6 +1105,25 @@ task("setOperator").setAction(async (_, __, runSuper) => { // ARM Snapshots +subtask("snapMarket", "Take a market snapshot of prices") + .addParam("base", "Symbol of base asset", undefined, types.string) + .addParam("wrapped", "Is the base asset wrapped?", false, types.boolean) + .addParam("liquid", "Symbol of liquid asset", undefined, types.string) + .addOptionalParam("amount", "Swap quantity", 100, types.int) + .addOptionalParam( + "days", + "Days to unwrap the base asset", + undefined, + types.float, + ) + .addOptionalParam("oneInch", "Include 1Inch prices", true, types.boolean) + .addOptionalParam("fee1Inch", "1Inch infrastructure fee", 10, types.int) + .addOptionalParam("kyber", "Include Kyber prices", true, types.boolean) + .setAction(snapMarket); +task("snapMarket").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + subtask("snap", "Take a snapshot of the an ARM") .addOptionalParam( "arm", diff --git a/test/Base.sol b/test/Base.sol index 234a59f3..0cd1e45d 100644 --- a/test/Base.sol +++ b/test/Base.sol @@ -8,6 +8,7 @@ import {Test} from "forge-std/Test.sol"; import {Proxy} from "contracts/Proxy.sol"; import {OethARM} from "contracts/OethARM.sol"; import {LidoARM} from "contracts/LidoARM.sol"; +import {EthenaARM} from "contracts/EthenaARM.sol"; import {EtherFiARM} from "contracts/EtherFiARM.sol"; import {OriginARM} from "contracts/OriginARM.sol"; import {SonicHarvester} from "contracts/SonicHarvester.sol"; @@ -40,12 +41,14 @@ abstract contract Base_Test_ is Test { Proxy public proxy; Proxy public lpcProxy; Proxy public lidoProxy; + Proxy public ethenaProxy; Proxy public etherfiProxy; Proxy public originARMProxy; Proxy public harvesterProxy; Proxy public morphoMarketProxy; OethARM public oethARM; LidoARM public lidoARM; + EthenaARM public ethenaARM; EtherFiARM public etherfiARM; SonicHarvester public harvester; OriginARM public originARM; @@ -57,6 +60,7 @@ abstract contract Base_Test_ is Test { IERC20 public ws; IERC20 public os; IERC20 public wos; + IERC20 public usde; IERC20 public oeth; IERC20 public weth; IERC20 public eeth; @@ -65,6 +69,7 @@ abstract contract Base_Test_ is Test { IERC20 public wsteth; IERC20 public morpho; IERC20 public badToken; + IERC4626 public susde; IERC4626 public market; IERC4626 public market2; IOriginVault public vault; @@ -108,6 +113,7 @@ abstract contract Base_Test_ is Test { _labelNotNull(address(proxy), "DEFAULT PROXY"); _labelNotNull(address(lpcProxy), "LPC PROXY"); _labelNotNull(address(lidoProxy), "LIDO ARM PROXY"); + _labelNotNull(address(ethenaProxy), "ETHENA ARM PROXY"); _labelNotNull(address(etherfiProxy), "ETHERFI ARM PROXY"); _labelNotNull(address(oethARM), "OETH ARM"); _labelNotNull(address(lidoARM), "LIDO ARM"); @@ -118,6 +124,8 @@ abstract contract Base_Test_ is Test { _labelNotNull(address(ws), "WS"); _labelNotNull(address(os), "OS"); + _labelNotNull(address(wos), "WOS"); + _labelNotNull(address(usde), "USDE"); _labelNotNull(address(oeth), "OETH"); _labelNotNull(address(weth), "WETH"); _labelNotNull(address(eeth), "EETH"); @@ -127,6 +135,9 @@ abstract contract Base_Test_ is Test { _labelNotNull(address(wsteth), " WRAPPED STETH"); _labelNotNull(address(badToken), "BAD TOKEN"); _labelNotNull(address(vault), "OETH VAULT"); + _labelNotNull(address(market), "MARKET"); + _labelNotNull(address(market2), "MARKET 2"); + _labelNotNull(address(susde), "SUSDE"); // Governance, multisig and EOAs _labelNotNull(alice, "Alice"); diff --git a/test/fork/EthenaARM/ClaimBaseWithdrawals.t.sol b/test/fork/EthenaARM/ClaimBaseWithdrawals.t.sol new file mode 100644 index 00000000..0c5a9e4a --- /dev/null +++ b/test/fork/EthenaARM/ClaimBaseWithdrawals.t.sol @@ -0,0 +1,48 @@ +/// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Fork_Shared_Test} from "test/fork/EthenaARM/shared/Shared.sol"; + +// Contracts +import {EthenaARM} from "contracts/EthenaARM.sol"; +import {EthenaUnstaker} from "contracts/EthenaUnstaker.sol"; + +contract Fork_Concrete_EthenaARM_ClaimBaseWithdrawals_Test_ is Fork_Shared_Test { + uint256 public AMOUNT_IN = 100 ether; + + ////////////////////////////////////////////////////// + /// --- TESTS + ////////////////////////////////////////////////////// + function test_ClaimBaseWithdrawals_FirstRequest() public { + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + + uint256 amountOut = susde.convertToAssets(AMOUNT_IN); + address unstakerAddress = ethenaARM.unstakers(ethenaARM.nextUnstakerIndex() - 1); + skip(7 days + 1); + + vm.expectEmit({emitter: address(ethenaARM)}); + emit EthenaARM.ClaimBaseWithdrawals(unstakerAddress, amountOut); + ethenaARM.claimBaseWithdrawals(unstakerAddress); + } + + ////////////////////////////////////////////////////// + /// --- REVERT TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_ClaimBaseWithdrawals_NoCooldownAmount() public { + address unstakerAddress = ethenaARM.unstakers(0); + + vm.expectRevert("EthenaARM: No cooldown amount"); + ethenaARM.claimBaseWithdrawals(unstakerAddress); + } + + function test_RevertWhen_ClaimBaseWithdrawals_InvalidUnstaker() public { + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + address unstaker = ethenaARM.unstakers(0); + skip(7 days + 1); + vm.expectRevert("Only ARM can request unstake"); + EthenaUnstaker(unstaker).claimUnstake(); + } +} diff --git a/test/fork/EthenaARM/RequestWithdraw.t.sol b/test/fork/EthenaARM/RequestWithdraw.t.sol new file mode 100644 index 00000000..3bc16434 --- /dev/null +++ b/test/fork/EthenaARM/RequestWithdraw.t.sol @@ -0,0 +1,121 @@ +/// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Fork_Shared_Test} from "test/fork/EthenaARM/shared/Shared.sol"; + +// Contracts +import {EthenaARM} from "contracts/EthenaARM.sol"; +import {IStakedUSDe, UserCooldown} from "contracts/Interfaces.sol"; +import {EthenaUnstaker} from "contracts/EthenaUnstaker.sol"; + +contract Fork_Concrete_EthenaARM_RequestWithdraw_Test_ is Fork_Shared_Test { + uint256 public AMOUNT_IN = 100 ether; + + ////////////////////////////////////////////////////// + /// --- TESTS + ////////////////////////////////////////////////////// + function test_RequestWithdraw_FirstRequest() public { + uint256 susdeBalanceBefore = susde.balanceOf(address(ethenaARM)); + uint256 nextUnstakerIndex = ethenaARM.nextUnstakerIndex(); + + vm.expectEmit({emitter: address(ethenaARM)}); + emit EthenaARM.RequestBaseWithdrawal( + ethenaARM.unstakers(nextUnstakerIndex), AMOUNT_IN, susde.convertToAssets(AMOUNT_IN) + ); + + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + + EthenaUnstaker unstaker = EthenaUnstaker(ethenaARM.unstakers(nextUnstakerIndex)); + UserCooldown memory cooldown = IStakedUSDe(address(susde)).cooldowns(address(unstaker)); + uint256 susdeBalanceAfter = susde.balanceOf(address(ethenaARM)); + assertEq(susdeBalanceAfter, susdeBalanceBefore - AMOUNT_IN, "sUSDe balance after request incorrect"); + assertEq(ethenaARM.nextUnstakerIndex(), nextUnstakerIndex + 1, "nextUnstakerIndex not incremented"); + assertEq(cooldown.underlyingAmount, susde.convertToAssets(AMOUNT_IN), "unstaker cooldown amount incorrect"); + } + + function test_RequestWithdraw_SecondRequest() public { + // First request + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + skip(ethenaARM.DELAY_REQUEST()); + + // Second request + uint256 susdeBalanceBefore = susde.balanceOf(address(ethenaARM)); + uint256 nextUnstakerIndex = ethenaARM.nextUnstakerIndex(); + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN * 2); + + UserCooldown memory cooldown = IStakedUSDe(address(susde)).cooldowns(ethenaARM.unstakers(nextUnstakerIndex)); + uint256 susdeBalanceAfter = susde.balanceOf(address(ethenaARM)); + assertEq(ethenaARM.nextUnstakerIndex(), 2, "nextUnstakerIndex not incremented"); + assertEq(susdeBalanceAfter, susdeBalanceBefore - (2 * AMOUNT_IN), "sUSDe balance after requests incorrect"); + assertEq(cooldown.underlyingAmount, susde.convertToAssets(AMOUNT_IN * 2), "second unstaker cooldown incorrect"); + } + + function test_RequestWithdraw_MaxRequest() public { + uint256 balanceBefore = susde.balanceOf(address(ethenaARM)); + uint256 delay = ethenaARM.DELAY_REQUEST(); + + // Make MAX_UNSTAKERS requests + for (uint256 i; i < MAX_UNSTAKERS; i++) { + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + skip(delay); + } + + uint256 balanceAfter = susde.balanceOf(address(ethenaARM)); + assertEq(ethenaARM.nextUnstakerIndex(), 0, "nextUnstakerIndex not wrapped around"); + assertEq(balanceBefore - balanceAfter, AMOUNT_IN * MAX_UNSTAKERS, "sUSDe balance after max requests incorrect"); + } + + ////////////////////////////////////////////////////// + /// --- REVERT TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_RequestWithdraw_RequestDelayNotPassed() public { + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + + vm.expectRevert("EthenaARM: Delay not passed"); + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + } + + function test_RevertWhen_RequestWithdraw_InvalidUnstaker() public { + address[42] memory emptyUnstakers; + vm.prank(governor); + ethenaARM.setUnstakers(emptyUnstakers); + + vm.expectRevert("EthenaARM: Invalid unstaker"); + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + } + + function test_RevertWhen_RequestWithdraw_UnstakerInCooldown() public { + uint256 delay = ethenaARM.DELAY_REQUEST(); + + // Make MAX_UNSTAKERS requests + for (uint256 i; i < MAX_UNSTAKERS; i++) { + vm.prank(operator); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + skip(delay); + } + + vm.prank(operator); + vm.expectRevert("EthenaARM: Unstaker in cooldown"); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + } + + function test_RevertWhen_RequestWithdraw_NotOperatorOrOwner() public { + vm.expectRevert("ARM: Only operator or owner can call this function."); + ethenaARM.requestBaseWithdrawal(AMOUNT_IN); + } + + function test_RevertWhen_RequestWithdraw_UnauthorizedCaller() public { + address unstakerAddress = ethenaARM.unstakers(0); + + vm.expectRevert("Only ARM can request unstake"); + EthenaUnstaker(unstakerAddress).requestUnstake(AMOUNT_IN); + } +} diff --git a/test/fork/EthenaARM/SwapExactTokensForTokens.t.sol b/test/fork/EthenaARM/SwapExactTokensForTokens.t.sol new file mode 100644 index 00000000..b184abfb --- /dev/null +++ b/test/fork/EthenaARM/SwapExactTokensForTokens.t.sol @@ -0,0 +1,194 @@ +/// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Fork_Shared_Test} from "test/fork/EthenaARM/shared/Shared.sol"; + +// Contracts +import {EthenaARM} from "contracts/EthenaARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +contract Fork_Concrete_EthenaARM_swapExactTokensForTokens_Test_ is Fork_Shared_Test { + uint256 public AMOUNT_IN = 100 ether; + + ////////////////////////////////////////////////////// + /// --- TESTS + ////////////////////////////////////////////////////// + function test_swapExactTokensForTokens_USDE_To_SUSDE_Sig1() public { + // Record balances before swap + uint256 usdeBalanceBefore = usde.balanceOf(address(this)); + uint256 susdeBalanceBefore = susde.balanceOf(address(this)); + + // Precompute expected amount out + uint256 traderate = ethenaARM.traderate0(); + uint256 expectedAmountOut = (susde.convertToShares(AMOUNT_IN) * 1e36) / traderate; + + // Expected events + vm.expectEmit({emitter: address(usde)}); + emit IERC20.Transfer(address(this), address(ethenaARM), AMOUNT_IN); + vm.expectEmit({emitter: address(susde)}); + emit IERC20.Transfer(address(ethenaARM), address(this), expectedAmountOut); + + // Perform the swap + uint256[] memory obtained = + ethenaARM.swapExactTokensForTokens(usde, IERC20(address(susde)), AMOUNT_IN, 0, address(this)); + + // Record balances after swap + uint256 usdeBalanceAfter = usde.balanceOf(address(this)); + uint256 susdeBalanceAfter = susde.balanceOf(address(this)); + + // Assertions + assertEq(obtained[0], AMOUNT_IN, "Obtained USDe amount should match input"); + assertEq(obtained[1], expectedAmountOut, "Obtained SUSDe amount should match expected output"); + assertEq(usdeBalanceBefore, usdeBalanceAfter + AMOUNT_IN, "USDe balance should have decreased"); + assertEq(susdeBalanceAfter, susdeBalanceBefore + expectedAmountOut, "SUSDe balance should have increased"); + } + + function test_swapExactTokensForTokens_USDE_To_SUSDE_Sig2() public { + // Record balances before swap + uint256 usdeBalanceBefore = usde.balanceOf(address(this)); + uint256 susdeBalanceBefore = susde.balanceOf(address(this)); + + // Precompute expected amount out + uint256 traderate = ethenaARM.traderate0(); + uint256 expectedAmountOut = (susde.convertToShares(AMOUNT_IN) * 1e36) / traderate; + + // Expected events + vm.expectEmit({emitter: address(usde)}); + emit IERC20.Transfer(address(this), address(ethenaARM), AMOUNT_IN); + vm.expectEmit({emitter: address(susde)}); + emit IERC20.Transfer(address(ethenaARM), address(this), expectedAmountOut); + + // Perform the swap + address[] memory path = new address[](2); + path[0] = address(usde); + path[1] = address(susde); + + uint256[] memory obtained = + ethenaARM.swapExactTokensForTokens(AMOUNT_IN, 0, path, address(this), block.timestamp + 1 hours); + + // Record balances after swap + uint256 usdeBalanceAfter = usde.balanceOf(address(this)); + uint256 susdeBalanceAfter = susde.balanceOf(address(this)); + + // Assertions + assertEq(obtained[0], AMOUNT_IN, "Obtained USDe amount should match input"); + assertEq(obtained[1], expectedAmountOut, "Obtained SUSDe amount should match expected output"); + assertEq(usdeBalanceBefore, usdeBalanceAfter + AMOUNT_IN, "USDe balance should have decreased"); + assertEq(susdeBalanceAfter, susdeBalanceBefore + expectedAmountOut, "SUSDe balance should have increased"); + } + + function test_swapExactTokensForTokens_SUSDE_To_USDE_NoOutstandingWithdrawals_Sig1() public { + // Record balances before swap + uint256 usdeBalanceBefore = usde.balanceOf(address(this)); + uint256 susdeBalanceBefore = susde.balanceOf(address(this)); + + // Precompute expected amount out + uint256 traderate = ethenaARM.traderate1(); + uint256 expectedAmountOut = (susde.convertToAssets(AMOUNT_IN) * traderate) / 1e36; + + // Expected events + vm.expectEmit({emitter: address(susde)}); + emit IERC20.Transfer(address(this), address(ethenaARM), AMOUNT_IN); + vm.expectEmit({emitter: address(usde)}); + emit IERC20.Transfer(address(ethenaARM), address(this), expectedAmountOut); + + // Perform the swap + uint256[] memory obtained = + ethenaARM.swapExactTokensForTokens(IERC20(address(susde)), usde, AMOUNT_IN, 0, address(this)); + + // Record balances after swap + uint256 usdeBalanceAfter = usde.balanceOf(address(this)); + uint256 susdeBalanceAfter = susde.balanceOf(address(this)); + + // Assertions + assertEq(obtained[0], AMOUNT_IN, "Obtained SUSDe amount should match input"); + assertEq(obtained[1], expectedAmountOut, "Obtained USDe amount should match expected output"); + assertEq(usdeBalanceAfter, usdeBalanceBefore + expectedAmountOut, "USDe balance should have increased"); + assertEq(susdeBalanceBefore, susdeBalanceAfter + AMOUNT_IN, "SUSDe balance should have decreased"); + } + + function test_swapExactTokensForTokens_SUSDE_To_USDE_WithOutstandingWithdrawals_Sig1() public { + ethenaARM.requestRedeem(AMOUNT_IN); + + // Precompute expected amount out + uint256 traderate = ethenaARM.traderate1(); + uint256 expectedAmountOut = (susde.convertToAssets(AMOUNT_IN) * traderate) / 1e36; + + // Perform the swap + uint256[] memory obtained = + ethenaARM.swapExactTokensForTokens(IERC20(address(susde)), usde, AMOUNT_IN, 0, address(this)); + + // Assertions + assertEq(obtained[0], AMOUNT_IN, "Obtained SUSDe amount should match input"); + assertEq(obtained[1], expectedAmountOut, "Obtained USDe amount should match expected output"); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_swapExactTokensForTokens_Because_InvalidInToken() public { + vm.expectRevert(bytes("EthenaARM: Invalid token")); + ethenaARM.swapExactTokensForTokens(badToken, usde, AMOUNT_IN, 0, address(this)); + } + + function test_RevertWhen_swapExactTokensForTokens_Because_InvalidOutToken() public { + vm.expectRevert(bytes("ARM: Invalid out token")); + ethenaARM.swapExactTokensForTokens(usde, badToken, AMOUNT_IN, 0, address(this)); + + vm.expectRevert(bytes("ARM: Invalid out token")); + ethenaARM.swapExactTokensForTokens(IERC20(address(susde)), badToken, AMOUNT_IN, 0, address(this)); + } + + function test_RevertWhen_swapExactTokensForTokens_Because_InsufficientOutputAmount() public { + uint256 highMinAmountOut = 1_000_000 ether; + + vm.expectRevert(bytes("ARM: Insufficient output amount")); + ethenaARM.swapExactTokensForTokens(IERC20(address(susde)), usde, AMOUNT_IN, highMinAmountOut, address(this)); + + vm.expectRevert(bytes("ARM: Insufficient output amount")); + ethenaARM.swapExactTokensForTokens(usde, IERC20(address(susde)), AMOUNT_IN, highMinAmountOut, address(this)); + + address[] memory path = new address[](2); + path[0] = address(usde); + path[1] = address(susde); + + vm.expectRevert(bytes("ARM: Insufficient output amount")); + ethenaARM.swapExactTokensForTokens(AMOUNT_IN, highMinAmountOut, path, address(this), block.timestamp + 1 hours); + } + + function test_RevertWhen_swapExactTokensForTokens_Because_DeadlineExpired() public { + uint256 pastDeadline = block.timestamp - 1; + address[] memory path = new address[](2); + path[0] = address(susde); + path[1] = address(usde); + + vm.expectRevert(bytes("ARM: Deadline expired")); + ethenaARM.swapExactTokensForTokens(AMOUNT_IN, 0, path, address(this), pastDeadline); + } + + function test_RevertWhen_swapExactTokensForTokens_Because_InvalidePathLength() public { + address[] memory shortPath = new address[](1); + shortPath[0] = address(susde); + + vm.expectRevert(bytes("ARM: Invalid path length")); + ethenaARM.swapExactTokensForTokens(AMOUNT_IN, 0, shortPath, address(this), block.timestamp + 1 hours); + + address[] memory longPath = new address[](3); + longPath[0] = address(susde); + longPath[1] = address(usde); + longPath[2] = address(susde); + + vm.expectRevert(bytes("ARM: Invalid path length")); + ethenaARM.swapExactTokensForTokens(AMOUNT_IN, 0, longPath, address(this), block.timestamp + 1 hours); + } + + function test_RevertWhen_swapExactTokensForTokens_Because_InsufficientLiquidity() public { + ethenaARM.requestRedeem(ethenaARM.balanceOf(address(this))); + + vm.expectRevert(bytes("ARM: Insufficient liquidity")); + ethenaARM.swapExactTokensForTokens(IERC20(address(susde)), usde, AMOUNT_IN, 0, address(this)); + } +} diff --git a/test/fork/EthenaARM/SwapTokensForExactTokens.t.sol b/test/fork/EthenaARM/SwapTokensForExactTokens.t.sol new file mode 100644 index 00000000..1923988d --- /dev/null +++ b/test/fork/EthenaARM/SwapTokensForExactTokens.t.sol @@ -0,0 +1,174 @@ +/// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Fork_Shared_Test} from "test/fork/EthenaARM/shared/Shared.sol"; + +// Contracts +import {EthenaARM} from "contracts/EthenaARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +contract Fork_Concrete_EthenaARM_swapTokensForExactTokens_Test_ is Fork_Shared_Test { + uint256 public AMOUNT_OUT = 100 ether; + + ////////////////////////////////////////////////////// + /// --- TESTS + ////////////////////////////////////////////////////// + function test_swapTokensForExactTokens_USDE_To_SUSDE_Sig1() public { + // Record balances before swap + uint256 usdeBalanceBefore = usde.balanceOf(address(this)); + uint256 susdeBalanceBefore = susde.balanceOf(address(this)); + + // Precompute expected amount out + uint256 traderate = ethenaARM.traderate0(); + uint256 expectedAmountIn = ((susde.convertToAssets(AMOUNT_OUT) * 1e36) / traderate) + 3; + + // Expected events + vm.expectEmit({emitter: address(usde)}); + emit IERC20.Transfer(address(this), address(ethenaARM), expectedAmountIn); + vm.expectEmit({emitter: address(susde)}); + emit IERC20.Transfer(address(ethenaARM), address(this), AMOUNT_OUT); + + // Perform the swap + uint256[] memory obtained = ethenaARM.swapTokensForExactTokens( + usde, IERC20(address(susde)), AMOUNT_OUT, type(uint256).max, address(this) + ); + + // Record balances after swap + uint256 usdeBalanceAfter = usde.balanceOf(address(this)); + uint256 susdeBalanceAfter = susde.balanceOf(address(this)); + + // Assertions + assertEq(obtained[0], expectedAmountIn, "Obtained USDe amount should match expected input"); + assertEq(obtained[1], AMOUNT_OUT, "Obtained SUSDe amount should match expected output"); + assertEq(usdeBalanceBefore, usdeBalanceAfter + expectedAmountIn, "USDe balance should have decreased"); + assertEq(susdeBalanceAfter, susdeBalanceBefore + AMOUNT_OUT, "SUSDe balance should have increased"); + } + + function test_swapTokensForExactTokens_USDE_To_SUSDE_Sig2() public { + // Record balances before swap + uint256 usdeBalanceBefore = usde.balanceOf(address(this)); + uint256 susdeBalanceBefore = susde.balanceOf(address(this)); + + // Precompute expected amount out + uint256 traderate = ethenaARM.traderate0(); + uint256 expectedAmountIn = ((susde.convertToAssets(AMOUNT_OUT) * 1e36) / traderate) + 3; + + address[] memory path = new address[](2); + path[0] = address(usde); + path[1] = address(susde); + + // Expected events + vm.expectEmit({emitter: address(usde)}); + emit IERC20.Transfer(address(this), address(ethenaARM), expectedAmountIn); + vm.expectEmit({emitter: address(susde)}); + emit IERC20.Transfer(address(ethenaARM), address(this), AMOUNT_OUT); + + // Perform the swap + uint256[] memory obtained = ethenaARM.swapTokensForExactTokens( + AMOUNT_OUT, type(uint256).max, path, address(this), block.timestamp + 1 hours + ); + + // Record balances after swap + uint256 usdeBalanceAfter = usde.balanceOf(address(this)); + uint256 susdeBalanceAfter = susde.balanceOf(address(this)); + + // Assertions + assertEq(obtained[0], expectedAmountIn, "Obtained USDe amount should match expected input"); + assertEq(obtained[1], AMOUNT_OUT, "Obtained SUSDe amount should match expected output"); + assertEq(usdeBalanceBefore, usdeBalanceAfter + expectedAmountIn, "USDe balance should have decreased"); + assertEq(susdeBalanceAfter, susdeBalanceBefore + AMOUNT_OUT, "SUSDe balance should have increased"); + } + + function test_swapTokensForExactTokens_SUSDE_To_USDE_NoOutstandingWithdrawals_Sig1() public { + // Record balances before swap + uint256 usdeBalanceBefore = usde.balanceOf(address(this)); + uint256 susdeBalanceBefore = susde.balanceOf(address(this)); + + // Precompute expected amount out + uint256 traderate = ethenaARM.traderate1(); + uint256 expectedAmountIn = (susde.convertToShares(AMOUNT_OUT) * 1e36) / traderate + 3; + + // Expected events + vm.expectEmit({emitter: address(susde)}); + emit IERC20.Transfer(address(this), address(ethenaARM), expectedAmountIn); + vm.expectEmit({emitter: address(usde)}); + emit IERC20.Transfer(address(ethenaARM), address(this), AMOUNT_OUT); + + // Perform the swap + uint256[] memory obtained = ethenaARM.swapTokensForExactTokens( + IERC20(address(susde)), usde, AMOUNT_OUT, type(uint256).max, address(this) + ); + + // Record balances after swap + uint256 usdeBalanceAfter = usde.balanceOf(address(this)); + uint256 susdeBalanceAfter = susde.balanceOf(address(this)); + + // Assertions + assertEq(obtained[0], expectedAmountIn, "Obtained USDe amount should match expected input"); + assertEq(obtained[1], AMOUNT_OUT, "Obtained SUSDe amount should match expected output"); + assertEq(susdeBalanceBefore, susdeBalanceAfter + expectedAmountIn, "SUSDe balance should have decreased"); + assertEq(usdeBalanceAfter, usdeBalanceBefore + AMOUNT_OUT, "USDe balance should have increased"); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_swapTokensForExactTokens_Because_InvalidInToken() public { + vm.expectRevert(bytes("ARM: Invalid in token")); + ethenaARM.swapTokensForExactTokens(badToken, usde, AMOUNT_OUT, 0, address(this)); + } + + function test_RevertWhen_swapTokensForExactTokens_Because_InvalidOutToken() public { + vm.expectRevert(bytes("EthenaARM: Invalid token")); + ethenaARM.swapTokensForExactTokens(usde, badToken, AMOUNT_OUT, 0, address(this)); + + vm.expectRevert(bytes("EthenaARM: Invalid token")); + ethenaARM.swapTokensForExactTokens(IERC20(address(susde)), badToken, AMOUNT_OUT, 0, address(this)); + } + + function test_RevertWhen_swapTokensForExactTokens_Because_InsufficientOutputAmount() public { + uint256 lowMaxAmountIn = 10 ether; + + vm.expectRevert(bytes("ARM: Excess input amount")); + ethenaARM.swapTokensForExactTokens(IERC20(address(susde)), usde, AMOUNT_OUT, lowMaxAmountIn, address(this)); + + vm.expectRevert(bytes("ARM: Excess input amount")); + ethenaARM.swapTokensForExactTokens(usde, IERC20(address(susde)), AMOUNT_OUT, lowMaxAmountIn, address(this)); + + address[] memory path = new address[](2); + path[0] = address(usde); + path[1] = address(susde); + + vm.expectRevert(bytes("ARM: Excess input amount")); + ethenaARM.swapTokensForExactTokens(AMOUNT_OUT, lowMaxAmountIn, path, address(this), block.timestamp + 1 hours); + } + + function test_RevertWhen_swapTokensForExactTokens_Because_DeadlineExpired() public { + uint256 pastDeadline = block.timestamp - 1; + address[] memory path = new address[](2); + path[0] = address(susde); + path[1] = address(usde); + + vm.expectRevert(bytes("ARM: Deadline expired")); + ethenaARM.swapTokensForExactTokens(AMOUNT_OUT, type(uint256).max, path, address(this), pastDeadline); + } + + function test_RevertWhen_swapTokensForExactTokens_Because_InvalidePathLength() public { + address[] memory shortPath = new address[](1); + shortPath[0] = address(susde); + + vm.expectRevert(bytes("ARM: Invalid path length")); + ethenaARM.swapTokensForExactTokens(AMOUNT_OUT, 0, shortPath, address(this), block.timestamp + 1 hours); + + address[] memory longPath = new address[](3); + longPath[0] = address(susde); + longPath[1] = address(usde); + longPath[2] = address(susde); + + vm.expectRevert(bytes("ARM: Invalid path length")); + ethenaARM.swapTokensForExactTokens(AMOUNT_OUT, 0, longPath, address(this), block.timestamp + 1 hours); + } +} diff --git a/test/fork/EthenaARM/shared/Shared.sol b/test/fork/EthenaARM/shared/Shared.sol new file mode 100644 index 00000000..bd9502ae --- /dev/null +++ b/test/fork/EthenaARM/shared/Shared.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Base_Test_} from "test/Base.sol"; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {EthenaARM} from "contracts/EthenaARM.sol"; +import {EthenaUnstaker} from "contracts/EthenaUnstaker.sol"; + +// Interfaces +import {Mainnet} from "src/contracts/utils/Addresses.sol"; +import {IERC20, IERC4626, IStakedUSDe} from "contracts/Interfaces.sol"; + +abstract contract Fork_Shared_Test is Base_Test_ { + uint256 public constant MAX_UNSTAKERS = 42; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + super.setUp(); + + // Generate a fork + _createAndSelectFork(); + + // Deploy Mock contracts + _deployMockContracts(); + + // Generate addresses + _generateAddresses(); + + // Deploy contracts + _deployContracts(); + + // Ignite test contract + _ignite(); + + // Label contracts + labelAll(); + } + + function _createAndSelectFork() internal { + // Check if the PROVIDER_URL is set. + require(vm.envExists("PROVIDER_URL"), "PROVIDER_URL not set"); + + // Create and select a fork. + if (vm.envExists("FORK_BLOCK_NUMBER_MAINNET")) { + vm.createSelectFork("mainnet", vm.envUint("FORK_BLOCK_NUMBER_MAINNET")); + } else { + vm.createSelectFork("mainnet"); + } + } + + function _deployMockContracts() internal { + usde = IERC20(Mainnet.USDE); + susde = IERC4626(Mainnet.SUSDE); + badToken = IERC20(address(0xDEADBEEF)); + } + + function _generateAddresses() internal { + // Generate addresses + governor = makeAddr("governor"); + deployer = makeAddr("deployer"); + operator = makeAddr("operator"); + feeCollector = makeAddr("feeCollector"); + } + + function _deployContracts() internal { + vm.startPrank(deployer); + // 1. Deploy Ethena ARM + ethenaARM = new EthenaARM({ + _usde: address(usde), + _susde: address(susde), + _claimDelay: 10 minutes, + _minSharesToRedeem: 1e7, + _allocateThreshold: 1 ether + }); + + // 2. Deploy Ethena ARM Proxy + ethenaProxy = new Proxy(); + + // Fund deployer with USDe and approve proxy to pull USDe for initialization + deal(address(usde), deployer, 1e12); + usde.approve(address(ethenaProxy), 1e12); + + // 3. Initialize Ethena ARM Proxy + bytes memory data = abi.encodeWithSelector( + EthenaARM.initialize.selector, + "Ethena Staked USDe ARM", + "ARM-sUSDe-USDe", + operator, // operator + 2000, // 20% fee + feeCollector, // feeCollector + address(0) // capManager + ); + + ethenaProxy.initialize(address(ethenaARM), governor, data); + vm.stopPrank(); + + // Assign Ethena ARM instance + ethenaARM = EthenaARM(address(ethenaProxy)); + } + + function _ignite() internal virtual { + // Assign contract instances + deal(address(usde), address(this), 1_000_000 ether); + deal(address(susde), address(this), 1_000_000 ether); + + // Approve USDe and SUSDe to Ethena ARM + usde.approve(address(ethenaARM), type(uint256).max); + susde.approve(address(ethenaARM), type(uint256).max); + + // Deposit some usde in the ARM + ethenaARM.deposit(10_000 ether); + + // Swap usde to susde using ARM to have some susde balance + ethenaARM.swapExactTokensForTokens(IERC20(address(susde)), usde, 5_000 ether, 0, address(this)); + + vm.startPrank(ethenaARM.owner()); + ethenaARM.setUnstakers(_deployUnstakers()); + vm.stopPrank(); + } + + function _deployUnstakers() internal returns (address[MAX_UNSTAKERS] memory unstakers) { + for (uint256 i; i < MAX_UNSTAKERS; i++) { + address unstaker = address(new EthenaUnstaker(payable(ethenaProxy), IStakedUSDe(Mainnet.SUSDE))); + unstakers[i] = address(unstaker); + } + } +}