diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 532168b..9f64538 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,5 +40,8 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + - name: Install Dependencies + run: forge soldeer install + - name: Run fork tests run: forge test -vvv --summary --detailed diff --git a/.gitignore b/.gitignore index 4b3bc4a..8a07b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,11 @@ artifacts # Forge .gas-snapshot +dependencies/ +soldeer.lock + +# Coverage +lcov.info* # Defender Actions dist diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 888d42d..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std diff --git a/.vscode/settings.json b/.vscode/settings.json index 5382847..9356b92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,6 @@ "solidity.packageDefaultDependenciesContractsDirectory": "src", "solidity.packageDefaultDependenciesDirectory": "lib", "solidity.compileUsingRemoteVersion": "v0.8.23+commit.f704f362", - "solidity.formatter": "forge" + "solidity.formatter": "forge", + "cSpell.words": ["traderate"] } diff --git a/Makefile b/Makefile index 69f8604..886fa46 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,14 @@ default: # Always keep Forge up to date install: foundryup - forge install + forge soldeer install + yarn install + +clean: + @rm -rf broadcast cache out + +clean-all: + @rm -rf broadcast cache out dependencies node_modules soldeer.lock gas: @forge test --gas-report @@ -19,14 +26,20 @@ snapshot: @forge snapshot # Tests +test-std: + forge test --summary --fail-fast --show-progress + test: - @forge test --summary + @FOUNDRY_NO_MATCH_CONTRACT=Invariant make test-std test-f-%: - @FOUNDRY_MATCH_TEST=$* make test + @FOUNDRY_MATCH_TEST=$* make test-std test-c-%: - @FOUNDRY_MATCH_CONTRACT=$* make test + @FOUNDRY_MATCH_CONTRACT=$* make test-std + +test-all: + @make test-std # Coverage coverage: @@ -49,20 +62,10 @@ deploy: @forge script script/deploy/DeployManager.sol --rpc-url $(PROVIDER_URL) --private-key ${DEPLOYER_PRIVATE_KEY} --broadcast --slow --verify -vvvv deploy-testnet: - @forge script script/deploy/DeployManager.sol --rpc-url $(TESTNET_URL) --private-key ${DEPLOYER_PRIVATE_KEY} --broadcast --slow -vvvv + @forge script script/deploy/DeployManager.sol --rpc-url $(TESTNET_URL) --broadcast --slow --unlocked -vvvv deploy-holesky: - @forge script script/deploy/DeployManager.sol --rpc-url $(HOLESKY_URL) --private-key ${DEPLOYER_PRIVATE_KEY} --broadcast --slow --verify -vvvv - -# Upgrade scripts -upgrade: - @forge script script/002_Upgrade.s.sol --rpc-url $(PROVIDER_URL) --private-key ${DEPLOYER_PRIVATE_KEY} --broadcast --slow --verify -vvvv - -upgrade-testnet: - @forge script script/002_Upgrade.s.sol --rpc-url $(TESTNET_URL) --private-key ${DEPLOYER_PRIVATE_KEY} --broadcast --slow -vvvv - -upgrade-holesky: - @forge script script/002_Upgrade.s.sol --rpc-url $(HOLESKY_URL) --private-key ${DEPLOYER_PRIVATE_KEY} --broadcast --slow --verify -vvvv + @forge script script/deploy/DeployManager.sol --rpc-url $(HOLESKY_URL) --private-key ${DEPLOYER_PRIVATE_KEY} --broadcast --slow --verify -vvv # Override default `test` and `coverage` targets .PHONY: test coverage diff --git a/README.md b/README.md index 9c492bc..571b645 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,18 @@ function swapTokensForExactTokens( ### Install ``` -foundryup -forge install -forge compile +make install cp .env.example .env ``` In the `.env` file, set the environment variables as needed. eg `PROVIDER_URL` for the RPC endpoint. +### Format and Compile + +``` +make +``` + ### Running tests Fork and Unit tests run with the same command, as the fork is initiated on the test file itself if needed. diff --git a/build/deployments-1.json b/build/deployments-1.json index 50a7d9c..0d4b24f 100644 --- a/build/deployments-1.json +++ b/build/deployments-1.json @@ -1,7 +1,16 @@ { - "executions": { "001_CoreMainnet": 1723685111 }, + "executions": { + "001_CoreMainnet": 1723685111, + "002_UpgradeMainnet": 1726812322, + "003_UpgradeLidoARMScript": 1729073099 + }, "contracts": { + "LIDO_ARM": "0x85B78AcA6Deae198fBF201c82DAF6Ca21942acc6", + "LIDO_ARM_CAP_IMPL": "0x8506486813d025C5935dF481E450e27D2e483dc9", + "LIDO_ARM_CAP_MAN": "0xf54ebff575f699d281645c6F14Fe427dFFE629CF", + "LIDO_ARM_IMPL": "0x3d724176c8f1F965eF4020CB5DA5ad1a891BEEf1", + "LIDO_ARM_ZAPPER": "0x01F30B7358Ba51f637d1aa05D9b4A60f76DAD680", "OETH_ARM": "0x6bac785889A4127dB0e0CeFEE88E0a9F1Aaf3cC7", - "OETH_ARM_IMPL": "0xd8fF298eAed581f74ab845Af62C48aCF85B2f05e" + "OETH_ARM_IMPL": "0x187FfF686a5f42ACaaF56469FcCF8e6Feca18248" } } diff --git a/docs/CapManagerHierarchy.svg b/docs/CapManagerHierarchy.svg new file mode 100644 index 0000000..4954d4f --- /dev/null +++ b/docs/CapManagerHierarchy.svg @@ -0,0 +1,46 @@ + + + + + + +UmlClassDiagram + + + +2 + +CapManager +../src/contracts/CapManager.sol + + + +19 + +OwnableOperable +../src/contracts/OwnableOperable.sol + + + +2->19 + + + + + +18 + +Ownable +../src/contracts/Ownable.sol + + + +19->18 + + + + + diff --git a/docs/CapManagerSquashed.svg b/docs/CapManagerSquashed.svg new file mode 100644 index 0000000..8fbf96f --- /dev/null +++ b/docs/CapManagerSquashed.svg @@ -0,0 +1,58 @@ + + + + + + +UmlClassDiagram + + + +2 + +CapManager +../src/contracts/CapManager.sol + +Private: +   _gap: uint256[49] <<OwnableOperable>> +   _gap: uint256[48] <<CapManager>> +Internal: +   OWNER_SLOT: bytes32 <<Ownable>> +Public: +   operator: address <<OwnableOperable>> +   arm: address <<CapManager>> +   accountCapEnabled: bool <<CapManager>> +   totalAssetsCap: uint248 <<CapManager>> +   liquidityProviderCaps: mapping(address=>uint256) <<CapManager>> + +Internal: +    _owner(): (ownerOut: address) <<Ownable>> +    _setOwner(newOwner: address) <<Ownable>> +    _onlyOwner() <<Ownable>> +    _initOwnableOperable(_operator: address) <<OwnableOperable>> +    _setOperator(newOperator: address) <<OwnableOperable>> +External: +    owner(): address <<Ownable>> +    setOwner(newOwner: address) <<onlyOwner>> <<Ownable>> +    setOperator(newOperator: address) <<onlyOwner>> <<OwnableOperable>> +    initialize(_operator: address) <<initializer>> <<CapManager>> +    postDepositHook(liquidityProvider: address, assets: uint256) <<CapManager>> +    setLiquidityProviderCaps(_liquidityProviders: address[], cap: uint256) <<onlyOperatorOrOwner>> <<CapManager>> +    setTotalAssetsCap(_totalAssetsCap: uint248) <<onlyOperatorOrOwner>> <<CapManager>> +    setAccountCapEnabled(_accountCapEnabled: bool) <<onlyOwner>> <<CapManager>> +Public: +    <<event>> AdminChanged(previousAdmin: address, newAdmin: address) <<Ownable>> +    <<event>> OperatorChanged(newAdmin: address) <<OwnableOperable>> +    <<event>> LiquidityProviderCap(liquidityProvider: address, cap: uint256) <<CapManager>> +    <<event>> TotalAssetsCap(cap: uint256) <<CapManager>> +    <<event>> AccountCapEnabled(enabled: bool) <<CapManager>> +    <<modifier>> onlyOwner() <<Ownable>> +    <<modifier>> onlyOperatorOrOwner() <<OwnableOperable>> +    constructor() <<Ownable>> +    constructor(_arm: address) <<CapManager>> + + + diff --git a/docs/LidoARMHierarchy.svg b/docs/LidoARMHierarchy.svg new file mode 100644 index 0000000..9a92f7e --- /dev/null +++ b/docs/LidoARMHierarchy.svg @@ -0,0 +1,60 @@ + + + + + + +UmlClassDiagram + + + +0 + +<<Abstract>> +AbstractARM +../src/contracts/AbstractARM.sol + + + +19 + +OwnableOperable +../src/contracts/OwnableOperable.sol + + + +0->19 + + + + + +15 + +LidoARM +../src/contracts/LidoARM.sol + + + +15->0 + + + + + +18 + +Ownable +../src/contracts/Ownable.sol + + + +19->18 + + + + + diff --git a/docs/LidoARMPublicSquashed.svg b/docs/LidoARMPublicSquashed.svg new file mode 100644 index 0000000..917334b --- /dev/null +++ b/docs/LidoARMPublicSquashed.svg @@ -0,0 +1,94 @@ + + + + + + +UmlClassDiagram + + + +15 + +LidoARM +../src/contracts/LidoARM.sol + +Public: +   operator: address <<OwnableOperable>> +   MAX_CROSS_PRICE_DEVIATION: uint256 <<AbstractARM>> +   PRICE_SCALE: uint256 <<AbstractARM>> +   FEE_SCALE: uint256 <<AbstractARM>> +   liquidityAsset: address <<AbstractARM>> +   baseAsset: address <<AbstractARM>> +   token0: IERC20 <<AbstractARM>> +   token1: IERC20 <<AbstractARM>> +   claimDelay: uint256 <<AbstractARM>> +   traderate0: uint256 <<AbstractARM>> +   traderate1: uint256 <<AbstractARM>> +   crossPrice: uint256 <<AbstractARM>> +   withdrawsQueued: uint128 <<AbstractARM>> +   withdrawsClaimed: uint128 <<AbstractARM>> +   nextWithdrawalIndex: uint256 <<AbstractARM>> +   withdrawalRequests: mapping(uint256=>WithdrawalRequest) <<AbstractARM>> +   fee: uint16 <<AbstractARM>> +   lastAvailableAssets: int128 <<AbstractARM>> +   feeCollector: address <<AbstractARM>> +   capManager: address <<AbstractARM>> +   steth: IERC20 <<LidoARM>> +   weth: IWETH <<LidoARM>> +   lidoWithdrawalQueue: IStETHWithdrawal <<LidoARM>> +   lidoWithdrawalQueueAmount: uint256 <<LidoARM>> + +External: +    <<payable>> null() <<LidoARM>> +    owner(): address <<Ownable>> +    setOwner(newOwner: address) <<onlyOwner>> <<Ownable>> +    setOperator(newOperator: address) <<onlyOwner>> <<OwnableOperable>> +    swapExactTokensForTokens(inToken: IERC20, outToken: IERC20, amountIn: uint256, amountOutMin: uint256, to: address) <<AbstractARM>> +    swapExactTokensForTokens(amountIn: uint256, amountOutMin: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<AbstractARM>> +    swapTokensForExactTokens(inToken: IERC20, outToken: IERC20, amountOut: uint256, amountInMax: uint256, to: address) <<AbstractARM>> +    swapTokensForExactTokens(amountOut: uint256, amountInMax: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<AbstractARM>> +    setPrices(buyT1: uint256, sellT1: uint256) <<onlyOperatorOrOwner>> <<AbstractARM>> +    setCrossPrice(newCrossPrice: uint256) <<onlyOwner>> <<AbstractARM>> +    previewDeposit(assets: uint256): (shares: uint256) <<AbstractARM>> +    deposit(assets: uint256): (shares: uint256) <<AbstractARM>> +    deposit(assets: uint256, receiver: address): (shares: uint256) <<AbstractARM>> +    previewRedeem(shares: uint256): (assets: uint256) <<AbstractARM>> +    requestRedeem(shares: uint256): (requestId: uint256, assets: uint256) <<AbstractARM>> +    claimRedeem(requestId: uint256): (assets: uint256) <<AbstractARM>> +    setCapManager(_capManager: address) <<onlyOwner>> <<AbstractARM>> +    setFee(_fee: uint256) <<onlyOwner>> <<AbstractARM>> +    setFeeCollector(_feeCollector: address) <<onlyOwner>> <<AbstractARM>> +    feesAccrued(): (fees: uint256) <<AbstractARM>> +    initialize(_name: string, _symbol: string, _operator: address, _fee: uint256, _feeCollector: address, _capManager: address) <<initializer>> <<LidoARM>> +    requestLidoWithdrawals(amounts: uint256[]): (requestIds: uint256[]) <<onlyOperatorOrOwner>> <<LidoARM>> +    claimLidoWithdrawals(requestIds: uint256[]) <<onlyOperatorOrOwner>> <<LidoARM>> +Public: +    <<event>> AdminChanged(previousAdmin: address, newAdmin: address) <<Ownable>> +    <<event>> OperatorChanged(newAdmin: address) <<OwnableOperable>> +    <<event>> TraderateChanged(traderate0: uint256, traderate1: uint256) <<AbstractARM>> +    <<event>> CrossPriceUpdated(crossPrice: uint256) <<AbstractARM>> +    <<event>> Deposit(owner: address, assets: uint256, shares: uint256) <<AbstractARM>> +    <<event>> RedeemRequested(withdrawer: address, requestId: uint256, assets: uint256, queued: uint256, claimTimestamp: uint256) <<AbstractARM>> +    <<event>> RedeemClaimed(withdrawer: address, requestId: uint256, assets: uint256) <<AbstractARM>> +    <<event>> FeeCollected(feeCollector: address, fee: uint256) <<AbstractARM>> +    <<event>> FeeUpdated(fee: uint256) <<AbstractARM>> +    <<event>> FeeCollectorUpdated(newFeeCollector: address) <<AbstractARM>> +    <<event>> CapManagerUpdated(capManager: address) <<AbstractARM>> +    <<event>> RequestLidoWithdrawals(amounts: uint256[], requestIds: uint256[]) <<LidoARM>> +    <<event>> ClaimLidoWithdrawals(requestIds: uint256[]) <<LidoARM>> +    <<modifier>> onlyOwner() <<Ownable>> +    <<modifier>> onlyOperatorOrOwner() <<OwnableOperable>> +    constructor() <<Ownable>> +    constructor(_steth: address, _weth: address, _lidoWithdrawalQueue: address, _claimDelay: uint256) <<LidoARM>> +    claimable(): uint256 <<AbstractARM>> +    totalAssets(): uint256 <<AbstractARM>> +    convertToShares(assets: uint256): (shares: uint256) <<AbstractARM>> +    convertToAssets(shares: uint256): (assets: uint256) <<AbstractARM>> +    collectFees(): (fees: uint256) <<AbstractARM>> + + + diff --git a/docs/LidoARMSquashed.svg b/docs/LidoARMSquashed.svg new file mode 100644 index 0000000..0d51cac --- /dev/null +++ b/docs/LidoARMSquashed.svg @@ -0,0 +1,120 @@ + + + + + + +UmlClassDiagram + + + +15 + +LidoARM +../src/contracts/LidoARM.sol + +Private: +   _gap: uint256[49] <<OwnableOperable>> +   _gap: uint256[41] <<AbstractARM>> +Internal: +   OWNER_SLOT: bytes32 <<Ownable>> +   MIN_TOTAL_SUPPLY: uint256 <<AbstractARM>> +   DEAD_ACCOUNT: address <<AbstractARM>> +Public: +   operator: address <<OwnableOperable>> +   MAX_CROSS_PRICE_DEVIATION: uint256 <<AbstractARM>> +   PRICE_SCALE: uint256 <<AbstractARM>> +   FEE_SCALE: uint256 <<AbstractARM>> +   liquidityAsset: address <<AbstractARM>> +   baseAsset: address <<AbstractARM>> +   token0: IERC20 <<AbstractARM>> +   token1: IERC20 <<AbstractARM>> +   claimDelay: uint256 <<AbstractARM>> +   traderate0: uint256 <<AbstractARM>> +   traderate1: uint256 <<AbstractARM>> +   crossPrice: uint256 <<AbstractARM>> +   withdrawsQueued: uint128 <<AbstractARM>> +   withdrawsClaimed: uint128 <<AbstractARM>> +   nextWithdrawalIndex: uint256 <<AbstractARM>> +   withdrawalRequests: mapping(uint256=>WithdrawalRequest) <<AbstractARM>> +   fee: uint16 <<AbstractARM>> +   lastAvailableAssets: int128 <<AbstractARM>> +   feeCollector: address <<AbstractARM>> +   capManager: address <<AbstractARM>> +   steth: IERC20 <<LidoARM>> +   weth: IWETH <<LidoARM>> +   lidoWithdrawalQueue: IStETHWithdrawal <<LidoARM>> +   lidoWithdrawalQueueAmount: uint256 <<LidoARM>> + +Internal: +    _owner(): (ownerOut: address) <<Ownable>> +    _setOwner(newOwner: address) <<Ownable>> +    _onlyOwner() <<Ownable>> +    _initOwnableOperable(_operator: address) <<OwnableOperable>> +    _setOperator(newOperator: address) <<OwnableOperable>> +    _initARM(_operator: address, _name: string, _symbol: string, _fee: uint256, _feeCollector: address, _capManager: address) <<AbstractARM>> +    _inDeadline(deadline: uint256) <<AbstractARM>> +    _transferAsset(asset: address, to: address, amount: uint256) <<AbstractARM>> +    _transferAssetFrom(asset: address, from: address, to: address, amount: uint256) <<AbstractARM>> +    _swapExactTokensForTokens(inToken: IERC20, outToken: IERC20, amountIn: uint256, to: address): (amountOut: uint256) <<AbstractARM>> +    _swapTokensForExactTokens(inToken: IERC20, outToken: IERC20, amountOut: uint256, to: address): (amountIn: uint256) <<AbstractARM>> +    _deposit(assets: uint256, receiver: address): (shares: uint256) <<AbstractARM>> +    _requireLiquidityAvailable(amount: uint256) <<AbstractARM>> +    _availableAssets(): uint256 <<AbstractARM>> +    _externalWithdrawQueue(): uint256 <<LidoARM>> +    _setFee(_fee: uint256) <<AbstractARM>> +    _setFeeCollector(_feeCollector: address) <<AbstractARM>> +    _feesAccrued(): (fees: uint256, newAvailableAssets: uint256) <<AbstractARM>> +External: +    <<payable>> null() <<LidoARM>> +    owner(): address <<Ownable>> +    setOwner(newOwner: address) <<onlyOwner>> <<Ownable>> +    setOperator(newOperator: address) <<onlyOwner>> <<OwnableOperable>> +    swapExactTokensForTokens(inToken: IERC20, outToken: IERC20, amountIn: uint256, amountOutMin: uint256, to: address) <<AbstractARM>> +    swapExactTokensForTokens(amountIn: uint256, amountOutMin: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<AbstractARM>> +    swapTokensForExactTokens(inToken: IERC20, outToken: IERC20, amountOut: uint256, amountInMax: uint256, to: address) <<AbstractARM>> +    swapTokensForExactTokens(amountOut: uint256, amountInMax: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<AbstractARM>> +    setPrices(buyT1: uint256, sellT1: uint256) <<onlyOperatorOrOwner>> <<AbstractARM>> +    setCrossPrice(newCrossPrice: uint256) <<onlyOwner>> <<AbstractARM>> +    previewDeposit(assets: uint256): (shares: uint256) <<AbstractARM>> +    deposit(assets: uint256): (shares: uint256) <<AbstractARM>> +    deposit(assets: uint256, receiver: address): (shares: uint256) <<AbstractARM>> +    previewRedeem(shares: uint256): (assets: uint256) <<AbstractARM>> +    requestRedeem(shares: uint256): (requestId: uint256, assets: uint256) <<AbstractARM>> +    claimRedeem(requestId: uint256): (assets: uint256) <<AbstractARM>> +    setCapManager(_capManager: address) <<onlyOwner>> <<AbstractARM>> +    setFee(_fee: uint256) <<onlyOwner>> <<AbstractARM>> +    setFeeCollector(_feeCollector: address) <<onlyOwner>> <<AbstractARM>> +    feesAccrued(): (fees: uint256) <<AbstractARM>> +    initialize(_name: string, _symbol: string, _operator: address, _fee: uint256, _feeCollector: address, _capManager: address) <<initializer>> <<LidoARM>> +    requestLidoWithdrawals(amounts: uint256[]): (requestIds: uint256[]) <<onlyOperatorOrOwner>> <<LidoARM>> +    claimLidoWithdrawals(requestIds: uint256[]) <<onlyOperatorOrOwner>> <<LidoARM>> +Public: +    <<event>> AdminChanged(previousAdmin: address, newAdmin: address) <<Ownable>> +    <<event>> OperatorChanged(newAdmin: address) <<OwnableOperable>> +    <<event>> TraderateChanged(traderate0: uint256, traderate1: uint256) <<AbstractARM>> +    <<event>> CrossPriceUpdated(crossPrice: uint256) <<AbstractARM>> +    <<event>> Deposit(owner: address, assets: uint256, shares: uint256) <<AbstractARM>> +    <<event>> RedeemRequested(withdrawer: address, requestId: uint256, assets: uint256, queued: uint256, claimTimestamp: uint256) <<AbstractARM>> +    <<event>> RedeemClaimed(withdrawer: address, requestId: uint256, assets: uint256) <<AbstractARM>> +    <<event>> FeeCollected(feeCollector: address, fee: uint256) <<AbstractARM>> +    <<event>> FeeUpdated(fee: uint256) <<AbstractARM>> +    <<event>> FeeCollectorUpdated(newFeeCollector: address) <<AbstractARM>> +    <<event>> CapManagerUpdated(capManager: address) <<AbstractARM>> +    <<event>> RequestLidoWithdrawals(amounts: uint256[], requestIds: uint256[]) <<LidoARM>> +    <<event>> ClaimLidoWithdrawals(requestIds: uint256[]) <<LidoARM>> +    <<modifier>> onlyOwner() <<Ownable>> +    <<modifier>> onlyOperatorOrOwner() <<OwnableOperable>> +    constructor() <<Ownable>> +    constructor(_steth: address, _weth: address, _lidoWithdrawalQueue: address, _claimDelay: uint256) <<LidoARM>> +    claimable(): uint256 <<AbstractARM>> +    totalAssets(): uint256 <<AbstractARM>> +    convertToShares(assets: uint256): (shares: uint256) <<AbstractARM>> +    convertToAssets(shares: uint256): (assets: uint256) <<AbstractARM>> +    collectFees(): (fees: uint256) <<AbstractARM>> + + + diff --git a/docs/OEthARMHierarchy.svg b/docs/OEthARMHierarchy.svg index ace4558..89dbae3 100644 --- a/docs/OEthARMHierarchy.svg +++ b/docs/OEthARMHierarchy.svg @@ -4,123 +4,110 @@ - - + + UmlClassDiagram - + 0 - -<<Interface>> -IERC20 -../src/contracts/Interfaces.sol + +<<Abstract>> +AbstractARM +../src/contracts/AbstractARM.sol - + + +22 + +OwnableOperable +../src/contracts/OwnableOperable.sol + + + +0->22 + + + + -3 - -<<Interface>> -IOETHVault -../src/contracts/Interfaces.sol +19 + +OethARM +../src/contracts/OethARM.sol - + -7 - -OethARM -../src/contracts/OethARM.sol - - - -8 - -OethLiquidityManager -../src/contracts/OethLiquidityManager.sol +20 + +OethLiquidityManager +../src/contracts/OethLiquidityManager.sol - - -7->8 - - + + +19->20 + + - - -11 - -PeggedARM -../src/contracts/PeggedARM.sol + + +23 + +<<Abstract>> +OwnerLP +../src/contracts/OwnerLP.sol - + -7->11 - - +19->23 + + - - -20 - -<<Abstract>> -Initializable -../src/contracts/utils/Initializable.sol + + +24 + +<<Abstract>> +PeggedARM +../src/contracts/PeggedARM.sol - - -7->20 - - + + +19->24 + + - + -8->0 - - +20->22 + + - - -8->3 - - - - - -10 - -OwnableOperable -../src/contracts/OwnableOperable.sol - - - -8->10 - - + + +21 + +Ownable +../src/contracts/Ownable.sol - - -9 - -Ownable -../src/contracts/Ownable.sol + + +22->21 + + - + -10->9 - - - - - -11->0 - - +23->21 + + - + -11->10 - - +24->0 + + diff --git a/docs/OEthARMSquashed.svg b/docs/OEthARMSquashed.svg index 0a1c7b8..62cc57f 100644 --- a/docs/OEthARMSquashed.svg +++ b/docs/OEthARMSquashed.svg @@ -4,49 +4,53 @@ - - + + UmlClassDiagram - - + + -7 - -OethARM -../src/contracts/OethARM.sol - -Private: -   initialized: bool <<Initializable>> -   initializing: bool <<Initializable>> -   gap: uint256[50] <<Initializable>> -   _gap: uint256[50] <<OwnableOperable>> -Internal: -   OWNER_SLOT: bytes32 <<Ownable>> -Public: -   operator: address <<OwnableOperable>> -   token0: IERC20 <<PeggedARM>> -   token1: IERC20 <<PeggedARM>> -   oeth: address <<OethLiquidityManager>> -   oethVault: address <<OethLiquidityManager>> - -Internal: -    _owner(): (ownerOut: address) <<Ownable>> -    _setOwner(newOwner: address) <<Ownable>> -    _onlyOwner() <<Ownable>> -    _setOperator(newOperator: address) <<OwnableOperable>> -    _swap(inToken: IERC20, outToken: IERC20, amount: uint256, to: address) <<PeggedARM>> -    _inDeadline(deadline: uint256) <<PeggedARM>> +19 + +OethARM +../src/contracts/OethARM.sol + +Private: +   _gap: uint256[49] <<OwnableOperable>> +   _gap: uint256[50] <<AbstractARM>> +Internal: +   OWNER_SLOT: bytes32 <<Ownable>> +Public: +   operator: address <<OwnableOperable>> +   token0: IERC20 <<AbstractARM>> +   token1: IERC20 <<AbstractARM>> +   bothDirections: bool <<PeggedARM>> +   oeth: address <<OethLiquidityManager>> +   oethVault: address <<OethLiquidityManager>> + +Internal: +    _owner(): (ownerOut: address) <<Ownable>> +    _setOwner(newOwner: address) <<Ownable>> +    _onlyOwner() <<Ownable>> +    _initOwnableOperable(_operator: address) <<OwnableOperable>> +    _setOperator(newOperator: address) <<OwnableOperable>> +    _swapExactTokensForTokens(inToken: IERC20, outToken: IERC20, amountIn: uint256, to: address): (amountOut: uint256) <<PeggedARM>> +    _swapTokensForExactTokens(inToken: IERC20, outToken: IERC20, amountOut: uint256, to: address): (amountIn: uint256) <<PeggedARM>> +    _inDeadline(deadline: uint256) <<AbstractARM>> +    _transferAsset(asset: address, to: address, amount: uint256) <<AbstractARM>> +    _transferAssetFrom(asset: address, from: address, to: address, amount: uint256) <<AbstractARM>> +    _swap(inToken: IERC20, outToken: IERC20, amount: uint256, to: address): uint256 <<PeggedARM>>    _approvals() <<OethLiquidityManager>> External:    owner(): address <<Ownable>>    setOwner(newOwner: address) <<onlyOwner>> <<Ownable>> -    setOperator(newOperator: address) <<onlyOwner>> <<OwnableOperable>> -    swapExactTokensForTokens(inToken: IERC20, outToken: IERC20, amountIn: uint256, amountOutMin: uint256, to: address) <<PeggedARM>> -    swapExactTokensForTokens(amountIn: uint256, amountOutMin: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<PeggedARM>> -    swapTokensForExactTokens(inToken: IERC20, outToken: IERC20, amountOut: uint256, amountInMax: uint256, to: address) <<PeggedARM>> -    swapTokensForExactTokens(amountOut: uint256, amountInMax: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<PeggedARM>> -    transferToken(token: address, to: address, amount: uint256) <<onlyOwner>> <<PeggedARM>> +    transferToken(token: address, to: address, amount: uint256) <<onlyOwner>> <<OwnerLP>> +    setOperator(newOperator: address) <<onlyOwner>> <<OwnableOperable>> +    swapExactTokensForTokens(inToken: IERC20, outToken: IERC20, amountIn: uint256, amountOutMin: uint256, to: address) <<AbstractARM>> +    swapExactTokensForTokens(amountIn: uint256, amountOutMin: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<AbstractARM>> +    swapTokensForExactTokens(inToken: IERC20, outToken: IERC20, amountOut: uint256, amountInMax: uint256, to: address) <<AbstractARM>> +    swapTokensForExactTokens(amountOut: uint256, amountInMax: uint256, path: address[], to: address, deadline: uint256): (amounts: uint256[]) <<AbstractARM>>    approvals() <<onlyOwner>> <<OethLiquidityManager>>    requestWithdrawal(amount: uint256): (requestId: uint256, queued: uint256) <<onlyOperatorOrOwner>> <<OethLiquidityManager>>    claimWithdrawal(requestId: uint256) <<onlyOperatorOrOwner>> <<OethLiquidityManager>> @@ -55,11 +59,11 @@ Public:    <<event>> AdminChanged(previousAdmin: address, newAdmin: address) <<Ownable>>    <<event>> OperatorChanged(newAdmin: address) <<OwnableOperable>> -    <<modifier>> initializer() <<Initializable>> -    <<modifier>> onlyOwner() <<Ownable>> -    <<modifier>> onlyOperatorOrOwner() <<OwnableOperable>> -    constructor() <<Ownable>> -    constructor(_oeth: address, _oethVault: address) <<OethLiquidityManager>> +    <<modifier>> onlyOwner() <<Ownable>> +    <<modifier>> onlyOperatorOrOwner() <<OwnableOperable>> +    constructor() <<Ownable>> +    constructor(_oeth: address, _oethVault: address) <<OethLiquidityManager>> +    constructor(_bothDirections: bool) <<PeggedARM>>    constructor(_oeth: address, _weth: address, _oethVault: address) <<OethARM>> diff --git a/docs/ProxyHierarchy.svg b/docs/ProxyHierarchy.svg index 1454642..e224dc9 100644 --- a/docs/ProxyHierarchy.svg +++ b/docs/ProxyHierarchy.svg @@ -9,23 +9,23 @@ UmlClassDiagram - + -9 +19 Ownable ../src/contracts/Ownable.sol - + -12 +23 Proxy ../src/contracts/Proxy.sol - + -12->9 +23->19 diff --git a/docs/ZapperLidoARMHierarchy.svg b/docs/ZapperLidoARMHierarchy.svg new file mode 100644 index 0000000..0136bf7 --- /dev/null +++ b/docs/ZapperLidoARMHierarchy.svg @@ -0,0 +1,33 @@ + + + + + + +UmlClassDiagram + + + +18 + +Ownable +../src/contracts/Ownable.sol + + + +23 + +ZapperLidoARM +../src/contracts/ZapperLidoARM.sol + + + +23->18 + + + + + diff --git a/docs/ZapperLidoARMSquashed.svg b/docs/ZapperLidoARMSquashed.svg new file mode 100644 index 0000000..c270069 --- /dev/null +++ b/docs/ZapperLidoARMSquashed.svg @@ -0,0 +1,43 @@ + + + + + + +UmlClassDiagram + + + +23 + +ZapperLidoARM +../src/contracts/ZapperLidoARM.sol + +Internal: +   OWNER_SLOT: bytes32 <<Ownable>> +Public: +   weth: IWETH <<ZapperLidoARM>> +   lidoArm: ILiquidityProviderARM <<ZapperLidoARM>> + +Internal: +    _owner(): (ownerOut: address) <<Ownable>> +    _setOwner(newOwner: address) <<Ownable>> +    _onlyOwner() <<Ownable>> +External: +    <<payable>> null() <<ZapperLidoARM>> +    owner(): address <<Ownable>> +    setOwner(newOwner: address) <<onlyOwner>> <<Ownable>> +    rescueERC20(token: address, amount: uint256) <<onlyOwner>> <<ZapperLidoARM>> +Public: +    <<payable>> deposit(): (shares: uint256) <<ZapperLidoARM>> +    <<event>> AdminChanged(previousAdmin: address, newAdmin: address) <<Ownable>> +    <<event>> Zap(sender: address, assets: uint256, shares: uint256) <<ZapperLidoARM>> +    <<modifier>> onlyOwner() <<Ownable>> +    constructor() <<Ownable>> +    constructor(_weth: address, _lidoArm: address) <<ZapperLidoARM>> + + + diff --git a/docs/generate.sh b/docs/generate.sh index 4089405..3c607ab 100644 --- a/docs/generate.sh +++ b/docs/generate.sh @@ -1,14 +1,28 @@ -sol2uml ../src/contracts -v -hv -hf -he -hs -hl -b OethARM -o OethARMHierarchy.svg + +sol2uml ../src/contracts -v -hv -hf -he -hs -hl -hi -b Proxy -o ProxyHierarchy.svg +sol2uml ../src/contracts -s -d 0 -b Proxy -o ProxySquashed.svg +sol2uml storage ../src/contracts -c Proxy -o ProxyStorage.svg \ + -sn eip1967.proxy.implementation,eip1967.proxy.admin \ + -st address,address + +sol2uml ../src/contracts -v -hv -hf -he -hs -hl -hi -b OethARM -o OethARMHierarchy.svg sol2uml ../src/contracts -s -d 0 -b OethARM -o OethARMSquashed.svg sol2uml storage ../src/contracts -c OethARM -o OethARMStorage.svg \ -sn eip1967.proxy.implementation,eip1967.proxy.admin \ -st address,address \ --hideExpand gap,_gap -sol2uml ../src/contracts -v -hv -hf -he -hs -hl -b Proxy -o ProxyHierarchy.svg -sol2uml ../src/contracts -s -d 0 -b Proxy -o ProxySquashed.svg -sol2uml storage ../src/contracts -c Proxy -o ProxyStorage.svg \ +sol2uml ../src/contracts -v -hv -hf -he -hs -hl -hi -b LidoARM -o LidoARMHierarchy.svg +sol2uml ../src/contracts -s -d 0 -b LidoARM -o LidoARMSquashed.svg +sol2uml ../src/contracts -hp -s -d 0 -b LidoARM -o LidoARMPublicSquashed.svg +sol2uml storage ../src/contracts,../lib -c LidoARM -o LidoARMStorage.svg \ -sn eip1967.proxy.implementation,eip1967.proxy.admin \ - -st address,address - \ No newline at end of file + -st address,address \ + --hideExpand gap,_gap + +sol2uml ../src/contracts -v -hv -hf -he -hs -hl -hi -b CapManager -o CapManagerHierarchy.svg +sol2uml ../src/contracts -s -d 0 -b CapManager -o CapManagerSquashed.svg + +sol2uml ../src/contracts -v -hv -hf -he -hs -hl -hi -b ZapperLidoARM -o ZapperLidoARMHierarchy.svg +sol2uml ../src/contracts -s -d 0 -b ZapperLidoARM -o ZapperLidoARMSquashed.svg \ No newline at end of file diff --git a/docs/plantuml/README.md b/docs/plantuml/README.md new file mode 100644 index 0000000..b7fc2a9 --- /dev/null +++ b/docs/plantuml/README.md @@ -0,0 +1,22 @@ +# PlantUML + +[PlantUML](http://plantuml.com) is used for the technical diagrams using [Unified Modeling Language (UML)](https://en.wikipedia.org/wiki/Unified_Modeling_Language) and [Archimate](https://www.itmg-int.com/itmg-int-wp-content/Archimate/An%20Introduction%20to%20Archimate%203.0.pdf). + +The PlantUML files have the `.puml` file extension. + +## VS Code extension + +[Jebbs PlantUML](https://marketplace.visualstudio.com/items?itemName=jebbs.plantuml) extension for VS Code is used to authoring the PlantUML diagrams. + +The following settings are used in VS Code settings.json file: + +```json + "plantuml.exportOutDir": "contracts/docs/plantuml", + "plantuml.exportFormat": "png", + "plantuml.exportIncludeFolderHeirarchy": false, + "plantuml.exportSubFolder": false, +``` + +`Alt-D` on Windows, or `Option-D` on Mac, to start PlantUML preview in VS Code. + +To save the PlantUML diagram as a PNG file, right-click on the diagram and select `Export Current Diagram`. This will save to where the `plantuml.exportOutDir` setting is set to. diff --git a/docs/plantuml/lidoContracts.png b/docs/plantuml/lidoContracts.png new file mode 100644 index 0000000..1d284ef Binary files /dev/null and b/docs/plantuml/lidoContracts.png differ diff --git a/docs/plantuml/lidoContracts.puml b/docs/plantuml/lidoContracts.puml new file mode 100644 index 0000000..012a21e --- /dev/null +++ b/docs/plantuml/lidoContracts.puml @@ -0,0 +1,39 @@ +@startuml + +!$originColor = DeepSkyBlue +' !$originColor = WhiteSmoke +!$newColor = LightGreen +!$changedColor = Orange +!$thirdPartyColor = WhiteSmoke + +' legend +' blue - Origin +' ' green - new +' ' orange - changed +' white - 3rd Party +' end legend + +title "Lido Automated Redemption Manager (ARM) Contract Dependencies" + + +object "ZapperLidoARM" as zap <> #$originColor { +} + +object "LidoARM" as arm <><> #$originColor { + shares: ARM-stETH-WETH + assets: stETH, WETH +} + +object "CapManager" as capMan <><> #$originColor { +} + +object "WithdrawalQueueERC721" as lidoQ <><> #$thirdPartyColor { + assets: stETH, WETH +} + +zap <..> arm +arm <.> capMan +arm ..> lidoQ + + +@enduml \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 7a15fa0..a576e61 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,22 +1,50 @@ [profile.default] src = "src/contracts" out = "out" -libs = ["lib"] +libs = ["dependencies"] verbosity = 3 sender = "0x0165C55EF814dEFdd658532A48Bd17B2c8356322" tx_origin = "0x0165C55EF814dEFdd658532A48Bd17B2c8356322" -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options - -gas_reports = ["OEthARM", "Proxy" ] - +auto_detect_remappings = false +gas_reports = ["OEthARM", "Proxy"] +fs_permissions = [{ access = "read-write", path = "./build" }] +extra_output_files = ["metadata"] +ignored_warnings_from = ["src/contracts/Proxy.sol"] remappings = [ "contracts/=./src/contracts", "script/=./script", "test/=./test", - "utils/=./src/contracts/utils" + "utils/=./src/contracts/utils", + "forge-std/=dependencies/forge-std-1.9.3/src/", + "@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-5.0.2/", + "@openzeppelin/contracts-upgradeable/=dependencies/@openzeppelin-contracts-upgradeable-5.0.2/", + "@solmate/=dependencies/solmate-6.7.0/src/", ] -fs_permissions = [{ access = "read-write", path = "./build"}] -extra_output_files = [ - "metadata" -] \ No newline at end of file +[fuzz] +runs = 1_000 + +[invariant] +runs = 256 +depth = 500 +fail_on_revert = true +shrink_run_limit = 5_000 + +[dependencies] +"@openzeppelin-contracts" = "5.0.2" +"@openzeppelin-contracts-upgradeable" = "5.0.2" +solmate = "6.7.0" +forge-std = "1.9.3" + +[soldeer] +recursive_deps = false +remappings_version = false +remappings_generate = false +remappings_regenerate = false +remappings_prefix = "@" +remappings_location = "config" + +[rpc_endpoints] +mainnet = "${PROVIDER_URL}" + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std deleted file mode 160000 index 978ac6f..0000000 --- a/lib/forge-std +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 978ac6fadb62f5f0b723c996f64be52eddba6801 diff --git a/script/deploy/AbstractDeployScript.sol b/script/deploy/AbstractDeployScript.sol index 79a16e9..effa442 100644 --- a/script/deploy/AbstractDeployScript.sol +++ b/script/deploy/AbstractDeployScript.sol @@ -12,8 +12,11 @@ import {GovProposal, GovSixHelper} from "contracts/utils/GovSixHelper.sol"; abstract contract AbstractDeployScript is Script { using GovSixHelper for GovProposal; + address deployer; uint256 public deployBlockNum = type(uint256).max; + bool public tenderlyTestnet; + // DeployerRecord stuff to be extracted as well struct DeployRecord { string name; @@ -40,10 +43,25 @@ abstract contract AbstractDeployScript is Script { } function isForked() public view returns (bool) { - return vm.isContext(VmSafe.ForgeContext.ScriptDryRun) || vm.isContext(VmSafe.ForgeContext.TestGroup); + return tenderlyTestnet || vm.isContext(VmSafe.ForgeContext.ScriptDryRun) + || vm.isContext(VmSafe.ForgeContext.TestGroup); } - function setUp() external virtual {} + /// @notice Detect if the RPC URL is a tendrly testnet, by trying to call a specific tenderly method on rpc. + /// @dev if the call success, it means we are on a tenderly testnet, otherwise we arn't. + function isTenderlyRpc() public returns (bool) { + // Try to give ethers to "ARM_MULTISIG" + try vm.rpc("tenderly_setBalance", "[[\"0xC8F2cF4742C86295653f893214725813B16f7410\"], \"0xDE0B6B3A7640000\"]") { + tenderlyTestnet = true; + return true; + } catch { + return false; + } + } + + function setUp() external virtual { + isTenderlyRpc(); + } function run() external { // Will not execute script if after this block number @@ -53,12 +71,21 @@ abstract contract AbstractDeployScript is Script { } if (this.isForked()) { - address impersonator = Mainnet.INITIAL_DEPLOYER; - console.log("Running script on mainnet fork impersonating: %s", impersonator); - vm.startPrank(impersonator); + deployer = Mainnet.INITIAL_DEPLOYER; + if (tenderlyTestnet) { + // Give enough ethers to deployer + vm.rpc( + "tenderly_setBalance", "[[\"0x0000000000000000000000000000000000001001\"], \"0xDE0B6B3A7640000\"]" + ); + console.log("Deploying on Tenderly testnet with deployer: %s", deployer); + vm.startBroadcast(deployer); + } else { + console.log("Running script on mainnet fork impersonating: %s", deployer); + vm.startPrank(deployer); + } } else { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); - address deployer = vm.rememberKey(deployerPrivateKey); + deployer = vm.rememberKey(deployerPrivateKey); vm.startBroadcast(deployer); console.log("Deploying on mainnet with deployer: %s", deployer); } @@ -66,9 +93,15 @@ abstract contract AbstractDeployScript is Script { _execute(); if (this.isForked()) { - vm.stopPrank(); - _buildGovernanceProposal(); - _fork(); + if (tenderlyTestnet) { + _buildGovernanceProposal(); + vm.stopBroadcast(); + _fork(); + } else { + vm.stopPrank(); + _buildGovernanceProposal(); + _fork(); + } } else { vm.stopBroadcast(); } @@ -94,6 +127,6 @@ abstract contract AbstractDeployScript is Script { } _buildGovernanceProposal(); - _fork(); + // _fork(); } } diff --git a/script/deploy/DeployManager.sol b/script/deploy/DeployManager.sol index 63ca209..dd8780d 100644 --- a/script/deploy/DeployManager.sol +++ b/script/deploy/DeployManager.sol @@ -7,6 +7,7 @@ import {VmSafe} from "forge-std/Vm.sol"; import {AbstractDeployScript} from "./AbstractDeployScript.sol"; import {DeployCoreMainnetScript} from "./mainnet/001_DeployCoreScript.sol"; import {UpgradeMainnetScript} from "./mainnet/002_UpgradeScript.sol"; +import {UpgradeLidoARMMainnetScript} from "./mainnet/003_UpgradeLidoARMScript.sol"; import {DeployCoreHoleskyScript} from "./holesky/001_DeployCoreScript.sol"; import {UpgradeHoleskyScript} from "./holesky/002_UpgradeScript.sol"; @@ -58,6 +59,7 @@ contract DeployManager is Script { // TODO: Use vm.readDir to recursively build this? _runDeployFile(new DeployCoreMainnetScript()); _runDeployFile(new UpgradeMainnetScript(this)); + _runDeployFile(new UpgradeLidoARMMainnetScript()); } else if (block.chainid == 17000) { _runDeployFile(new DeployCoreHoleskyScript()); _runDeployFile(new UpgradeHoleskyScript(this)); diff --git a/script/deploy/holesky/002_UpgradeScript.sol b/script/deploy/holesky/002_UpgradeScript.sol index 15894b4..4f6172c 100644 --- a/script/deploy/holesky/002_UpgradeScript.sol +++ b/script/deploy/holesky/002_UpgradeScript.sol @@ -33,7 +33,7 @@ contract UpgradeHoleskyScript is AbstractDeployScript { function _fork() internal override { // Upgrade the proxy - Proxy proxy = Proxy(deployManager.getDeployment("OETH_ARM")); + Proxy proxy = Proxy(payable(deployManager.getDeployment("OETH_ARM"))); vm.prank(Holesky.RELAYER); proxy.upgradeTo(newImpl); diff --git a/script/deploy/mainnet/001_DeployCoreScript.sol b/script/deploy/mainnet/001_DeployCoreScript.sol index 362c1bf..2b97c61 100644 --- a/script/deploy/mainnet/001_DeployCoreScript.sol +++ b/script/deploy/mainnet/001_DeployCoreScript.sol @@ -17,7 +17,7 @@ contract DeployCoreMainnetScript is AbstractDeployScript { GovProposal public govProposal; string public constant override DEPLOY_NAME = "001_CoreMainnet"; - bool public constant override proposalExecuted = false; + bool public constant override proposalExecuted = true; function _execute() internal override { console.log("Deploy:", DEPLOY_NAME); @@ -31,8 +31,8 @@ contract DeployCoreMainnetScript is AbstractDeployScript { OethARM implementation = new OethARM(Mainnet.OETH, Mainnet.WETH, Mainnet.OETH_VAULT); _recordDeploy("OETH_ARM_IMPL", address(implementation)); - // 3. Initialize proxy, set the owner to TIMELOCK, set the operator to RELAYER and approve the OETH Vault to transfer OETH - bytes memory data = abi.encodeWithSignature("initialize(address)", Mainnet.RELAYER); + // 3. Initialize proxy, set the owner to TIMELOCK, set the operator to the OETH Relayer and approve the OETH Vault to transfer OETH + bytes memory data = abi.encodeWithSignature("initialize(address)", Mainnet.OETH_RELAYER); proxy.initialize(address(implementation), Mainnet.TIMELOCK, data); } @@ -43,7 +43,7 @@ contract DeployCoreMainnetScript is AbstractDeployScript { // but doing this here to test governance flow. // Set operator - // govProposal.action(deployedContracts["OETH_ARM"], "initialize(address)", abi.encode(Mainnet.RELAYER)); + // govProposal.action(deployedContracts["OETH_ARM"], "initialize(address)", abi.encode(Mainnet.OETH_RELAYER)); } function _fork() internal override { diff --git a/script/deploy/mainnet/002_UpgradeScript.sol b/script/deploy/mainnet/002_UpgradeScript.sol index d042cf7..7d13bc1 100644 --- a/script/deploy/mainnet/002_UpgradeScript.sol +++ b/script/deploy/mainnet/002_UpgradeScript.sol @@ -13,7 +13,7 @@ import {DeployManager} from "../DeployManager.sol"; contract UpgradeMainnetScript is AbstractDeployScript { string public constant override DEPLOY_NAME = "002_UpgradeMainnet"; - bool public constant override proposalExecuted = false; + bool public constant override proposalExecuted = true; address newImpl; DeployManager internal deployManager; @@ -33,7 +33,7 @@ contract UpgradeMainnetScript is AbstractDeployScript { function _fork() internal override { // Upgrade the proxy - Proxy proxy = Proxy(deployManager.getDeployment("OETH_ARM")); + Proxy proxy = Proxy(payable(deployManager.getDeployment("OETH_ARM"))); vm.prank(Mainnet.TIMELOCK); proxy.upgradeTo(newImpl); diff --git a/script/deploy/mainnet/003_UpgradeLidoARMScript.sol b/script/deploy/mainnet/003_UpgradeLidoARMScript.sol new file mode 100644 index 0000000..3e390e7 --- /dev/null +++ b/script/deploy/mainnet/003_UpgradeLidoARMScript.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.23; + +import "forge-std/console.sol"; +import {Vm} from "forge-std/Vm.sol"; + +import {IERC20, IWETH, LegacyAMM} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {Proxy} from "contracts/Proxy.sol"; +import {ZapperLidoARM} from "contracts/ZapperLidoARM.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; +import {GovProposal, GovSixHelper} from "contracts/utils/GovSixHelper.sol"; +import {AbstractDeployScript} from "../AbstractDeployScript.sol"; + +contract UpgradeLidoARMMainnetScript is AbstractDeployScript { + using GovSixHelper for GovProposal; + + GovProposal public govProposal; + + string public constant override DEPLOY_NAME = "003_UpgradeLidoARMScript"; + bool public constant override proposalExecuted = false; + + Proxy lidoARMProxy; + Proxy capManProxy; + LidoARM lidoARMImpl; + LidoARM lidoARM; + CapManager capManager; + ZapperLidoARM zapper; + + function _execute() internal override { + console.log("Deploy:", DEPLOY_NAME); + console.log("------------"); + + // 1. Record the proxy address used for AMM v1 + _recordDeploy("LIDO_ARM", Mainnet.LIDO_ARM); + lidoARMProxy = Proxy(payable(Mainnet.LIDO_ARM)); + + // 2. Deploy proxy for the CapManager + capManProxy = new Proxy(); + _recordDeploy("LIDO_ARM_CAP_MAN", address(capManProxy)); + + // 3. Deploy CapManager implementation + CapManager capManagerImpl = new CapManager(address(lidoARMProxy)); + _recordDeploy("LIDO_ARM_CAP_IMPL", address(capManagerImpl)); + + // 4. Initialize Proxy with CapManager implementation and set the owner to the deployer for now + bytes memory data = abi.encodeWithSignature("initialize(address)", Mainnet.ARM_RELAYER); + capManProxy.initialize(address(capManagerImpl), deployer, data); + capManager = CapManager(address(capManProxy)); + + // 5. Set total assets cap + capManager.setTotalAssetsCap(740 ether); + + // 6. Transfer ownership of CapManager to the mainnet 5/8 multisig + capManProxy.setOwner(Mainnet.GOV_MULTISIG); + + // 7. Deploy Lido implementation + uint256 claimDelay = tenderlyTestnet ? 1 minutes : 10 minutes; + lidoARMImpl = new LidoARM(Mainnet.STETH, Mainnet.WETH, Mainnet.LIDO_WITHDRAWAL, claimDelay); + _recordDeploy("LIDO_ARM_IMPL", address(lidoARMImpl)); + + // 8. Deploy the Zapper + zapper = new ZapperLidoARM(Mainnet.WETH, Mainnet.LIDO_ARM); + zapper.setOwner(Mainnet.STRATEGIST); + _recordDeploy("LIDO_ARM_ZAPPER", address(zapper)); + + console.log("Finished deploying", DEPLOY_NAME); + } + + function _buildGovernanceProposal() internal override {} + + function _fork() internal override { + if (tenderlyTestnet) { + console.log("Broadcasting fork script to Tenderly as: %s", Mainnet.ARM_MULTISIG); + vm.startBroadcast(Mainnet.ARM_MULTISIG); + } else { + vm.startPrank(Mainnet.ARM_MULTISIG); + } + + if (lidoARMProxy == Proxy(payable(address(0)))) { + revert("Lido ARM proxy not found"); + } + + // remove all liquidity from the old AMM v1 contract + uint256 wethLegacyBalance = IERC20(Mainnet.WETH).balanceOf(Mainnet.LIDO_ARM); + if (wethLegacyBalance > 0) { + console.log("About to withdraw WETH from legacy Lido ARM"); + LegacyAMM(Mainnet.LIDO_ARM).transferToken(Mainnet.WETH, Mainnet.ARM_MULTISIG, wethLegacyBalance); + } + uint256 stethLegacyBalance = IERC20(Mainnet.STETH).balanceOf(Mainnet.LIDO_ARM); + if (stethLegacyBalance > 0) { + console.log("About to withdraw stETH from legacy Lido ARM"); + LegacyAMM(Mainnet.LIDO_ARM).transferToken(Mainnet.STETH, Mainnet.ARM_MULTISIG, stethLegacyBalance); + } + // need to also remove anything in the Lido withdrawal queue + + // Initialize Lido ARM proxy and implementation contract + bytes memory data = abi.encodeWithSignature( + "initialize(string,string,address,uint256,address,address)", + "Lido ARM", + "ARM-WETH-stETH", + Mainnet.ARM_RELAYER, + 2000, // 20% performance fee + Mainnet.ARM_BUYBACK, + address(capManProxy) + ); + console.log("LidoARM initialize data:"); + console.logBytes(data); + + // Get some WETH which has already been done on mainnet + // uint256 tinyMintAmount = 1e12; + // vm.deal(Mainnet.ARM_MULTISIG, tinyMintAmount); + // IWETH(Mainnet.WETH).deposit{value: tinyMintAmount}(); + + // Approve the Lido ARM proxy to spend WETH + IERC20(Mainnet.WETH).approve(address(lidoARMProxy), type(uint256).max); + + // upgrade and initialize the Lido ARM + console.log("About to upgrade the ARM contract"); + lidoARMProxy.upgradeToAndCall(address(lidoARMImpl), data); + lidoARM = LidoARM(payable(Mainnet.LIDO_ARM)); + + // Set the price that buy and sell prices can not cross + console.log("About to set the cross price on the ARM contract"); + LidoARM(payable(Mainnet.LIDO_ARM)).setCrossPrice(0.9998e36); + + // Set the buy price with a 2.5 basis point discount. + // The sell price has a 1 basis point discount. + console.log("About to set prices on the ARM contract"); + LidoARM(payable(Mainnet.LIDO_ARM)).setPrices(0.99975e36, 0.9999e36); + + // transfer ownership of the Lido ARM proxy to the mainnet 5/8 multisig + console.log("About to set ARM owner to", Mainnet.GOV_MULTISIG); + lidoARMProxy.setOwner(Mainnet.GOV_MULTISIG); + + // Deposit 10 WETH to the Lido ARM + console.log("About to deposit 10 WETH into the ARM contract", Mainnet.GOV_MULTISIG); + lidoARM.deposit(10 ether); + + console.log("Finished running initializing Lido ARM as ARM_MULTISIG"); + + if (tenderlyTestnet) { + vm.stopBroadcast(); + } else { + vm.stopPrank(); + } + } +} diff --git a/src/abis/CurveStEthPool.json b/src/abis/CurveStEthPool.json new file mode 100644 index 0000000..a6b5566 --- /dev/null +++ b/src/abis/CurveStEthPool.json @@ -0,0 +1,484 @@ +[ + { + "name": "TokenExchange", + "inputs": [ + { "type": "address", "name": "buyer", "indexed": true }, + { "type": "int128", "name": "sold_id", "indexed": false }, + { "type": "uint256", "name": "tokens_sold", "indexed": false }, + { "type": "int128", "name": "bought_id", "indexed": false }, + { "type": "uint256", "name": "tokens_bought", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "TokenExchangeUnderlying", + "inputs": [ + { "type": "address", "name": "buyer", "indexed": true }, + { "type": "int128", "name": "sold_id", "indexed": false }, + { "type": "uint256", "name": "tokens_sold", "indexed": false }, + { "type": "int128", "name": "bought_id", "indexed": false }, + { "type": "uint256", "name": "tokens_bought", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "AddLiquidity", + "inputs": [ + { "type": "address", "name": "provider", "indexed": true }, + { "type": "uint256[2]", "name": "token_amounts", "indexed": false }, + { "type": "uint256[2]", "name": "fees", "indexed": false }, + { "type": "uint256", "name": "invariant", "indexed": false }, + { "type": "uint256", "name": "token_supply", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "RemoveLiquidity", + "inputs": [ + { "type": "address", "name": "provider", "indexed": true }, + { "type": "uint256[2]", "name": "token_amounts", "indexed": false }, + { "type": "uint256[2]", "name": "fees", "indexed": false }, + { "type": "uint256", "name": "token_supply", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "RemoveLiquidityOne", + "inputs": [ + { "type": "address", "name": "provider", "indexed": true }, + { "type": "uint256", "name": "token_amount", "indexed": false }, + { "type": "uint256", "name": "coin_amount", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "RemoveLiquidityImbalance", + "inputs": [ + { "type": "address", "name": "provider", "indexed": true }, + { "type": "uint256[2]", "name": "token_amounts", "indexed": false }, + { "type": "uint256[2]", "name": "fees", "indexed": false }, + { "type": "uint256", "name": "invariant", "indexed": false }, + { "type": "uint256", "name": "token_supply", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "CommitNewAdmin", + "inputs": [ + { "type": "uint256", "name": "deadline", "indexed": true }, + { "type": "address", "name": "admin", "indexed": true } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "NewAdmin", + "inputs": [{ "type": "address", "name": "admin", "indexed": true }], + "anonymous": false, + "type": "event" + }, + { + "name": "CommitNewFee", + "inputs": [ + { "type": "uint256", "name": "deadline", "indexed": true }, + { "type": "uint256", "name": "fee", "indexed": false }, + { "type": "uint256", "name": "admin_fee", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "NewFee", + "inputs": [ + { "type": "uint256", "name": "fee", "indexed": false }, + { "type": "uint256", "name": "admin_fee", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "RampA", + "inputs": [ + { "type": "uint256", "name": "old_A", "indexed": false }, + { "type": "uint256", "name": "new_A", "indexed": false }, + { "type": "uint256", "name": "initial_time", "indexed": false }, + { "type": "uint256", "name": "future_time", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "name": "StopRampA", + "inputs": [ + { "type": "uint256", "name": "A", "indexed": false }, + { "type": "uint256", "name": "t", "indexed": false } + ], + "anonymous": false, + "type": "event" + }, + { + "outputs": [], + "inputs": [ + { "type": "address", "name": "_owner" }, + { "type": "address[2]", "name": "_coins" }, + { "type": "address", "name": "_pool_token" }, + { "type": "uint256", "name": "_A" }, + { "type": "uint256", "name": "_fee" }, + { "type": "uint256", "name": "_admin_fee" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "name": "A", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 5289 + }, + { + "name": "A_precise", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 5251 + }, + { + "name": "balances", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [{ "type": "uint256", "name": "i" }], + "stateMutability": "view", + "type": "function", + "gas": 5076 + }, + { + "name": "get_virtual_price", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 1114301 + }, + { + "name": "calc_token_amount", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [ + { "type": "uint256[2]", "name": "amounts" }, + { "type": "bool", "name": "is_deposit" } + ], + "stateMutability": "view", + "type": "function", + "gas": 2218181 + }, + { + "name": "add_liquidity", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [ + { "type": "uint256[2]", "name": "amounts" }, + { "type": "uint256", "name": "min_mint_amount" } + ], + "stateMutability": "payable", + "type": "function", + "gas": 3484118 + }, + { + "name": "get_dy", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [ + { "type": "int128", "name": "i" }, + { "type": "int128", "name": "j" }, + { "type": "uint256", "name": "dx" } + ], + "stateMutability": "view", + "type": "function", + "gas": 2654541 + }, + { + "name": "exchange", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [ + { "type": "int128", "name": "i" }, + { "type": "int128", "name": "j" }, + { "type": "uint256", "name": "dx" }, + { "type": "uint256", "name": "min_dy" } + ], + "stateMutability": "payable", + "type": "function", + "gas": 2810134 + }, + { + "name": "remove_liquidity", + "outputs": [{ "type": "uint256[2]", "name": "" }], + "inputs": [ + { "type": "uint256", "name": "_amount" }, + { "type": "uint256[2]", "name": "_min_amounts" } + ], + "stateMutability": "nonpayable", + "type": "function", + "gas": 160545 + }, + { + "name": "remove_liquidity_imbalance", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [ + { "type": "uint256[2]", "name": "_amounts" }, + { "type": "uint256", "name": "_max_burn_amount" } + ], + "stateMutability": "nonpayable", + "type": "function", + "gas": 3519382 + }, + { + "name": "calc_withdraw_one_coin", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [ + { "type": "uint256", "name": "_token_amount" }, + { "type": "int128", "name": "i" } + ], + "stateMutability": "view", + "type": "function", + "gas": 1435 + }, + { + "name": "remove_liquidity_one_coin", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [ + { "type": "uint256", "name": "_token_amount" }, + { "type": "int128", "name": "i" }, + { "type": "uint256", "name": "_min_amount" } + ], + "stateMutability": "nonpayable", + "type": "function", + "gas": 4113806 + }, + { + "name": "ramp_A", + "outputs": [], + "inputs": [ + { "type": "uint256", "name": "_future_A" }, + { "type": "uint256", "name": "_future_time" } + ], + "stateMutability": "nonpayable", + "type": "function", + "gas": 151834 + }, + { + "name": "stop_ramp_A", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 148595 + }, + { + "name": "commit_new_fee", + "outputs": [], + "inputs": [ + { "type": "uint256", "name": "new_fee" }, + { "type": "uint256", "name": "new_admin_fee" } + ], + "stateMutability": "nonpayable", + "type": "function", + "gas": 110431 + }, + { + "name": "apply_new_fee", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 153115 + }, + { + "name": "revert_new_parameters", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 21865 + }, + { + "name": "commit_transfer_ownership", + "outputs": [], + "inputs": [{ "type": "address", "name": "_owner" }], + "stateMutability": "nonpayable", + "type": "function", + "gas": 74603 + }, + { + "name": "apply_transfer_ownership", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 116583 + }, + { + "name": "revert_transfer_ownership", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 21955 + }, + { + "name": "withdraw_admin_fees", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 137597 + }, + { + "name": "donate_admin_fees", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 42144 + }, + { + "name": "kill_me", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 37938 + }, + { + "name": "unkill_me", + "outputs": [], + "inputs": [], + "stateMutability": "nonpayable", + "type": "function", + "gas": 22075 + }, + { + "name": "coins", + "outputs": [{ "type": "address", "name": "" }], + "inputs": [{ "type": "uint256", "name": "arg0" }], + "stateMutability": "view", + "type": "function", + "gas": 2160 + }, + { + "name": "admin_balances", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [{ "type": "uint256", "name": "arg0" }], + "stateMutability": "view", + "type": "function", + "gas": 2190 + }, + { + "name": "fee", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2111 + }, + { + "name": "admin_fee", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2141 + }, + { + "name": "owner", + "outputs": [{ "type": "address", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2171 + }, + { + "name": "lp_token", + "outputs": [{ "type": "address", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2201 + }, + { + "name": "initial_A", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2231 + }, + { + "name": "future_A", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2261 + }, + { + "name": "initial_A_time", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2291 + }, + { + "name": "future_A_time", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2321 + }, + { + "name": "admin_actions_deadline", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2351 + }, + { + "name": "transfer_ownership_deadline", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2381 + }, + { + "name": "future_fee", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2411 + }, + { + "name": "future_admin_fee", + "outputs": [{ "type": "uint256", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2441 + }, + { + "name": "future_owner", + "outputs": [{ "type": "address", "name": "" }], + "inputs": [], + "stateMutability": "view", + "type": "function", + "gas": 2471 + } +] diff --git a/src/abis/UniswapV3Quoter.json b/src/abis/UniswapV3Quoter.json new file mode 100644 index 0000000..e9adacb --- /dev/null +++ b/src/abis/UniswapV3Quoter.json @@ -0,0 +1,97 @@ +[ + { + "inputs": [ + { "internalType": "address", "name": "_factory", "type": "address" }, + { "internalType": "address", "name": "_WETH9", "type": "address" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "WETH9", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "factory", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes", "name": "path", "type": "bytes" }, + { "internalType": "uint256", "name": "amountIn", "type": "uint256" } + ], + "name": "quoteExactInput", + "outputs": [ + { "internalType": "uint256", "name": "amountOut", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "tokenIn", "type": "address" }, + { "internalType": "address", "name": "tokenOut", "type": "address" }, + { "internalType": "uint24", "name": "fee", "type": "uint24" }, + { "internalType": "uint256", "name": "amountIn", "type": "uint256" }, + { + "internalType": "uint160", + "name": "sqrtPriceLimitX96", + "type": "uint160" + } + ], + "name": "quoteExactInputSingle", + "outputs": [ + { "internalType": "uint256", "name": "amountOut", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes", "name": "path", "type": "bytes" }, + { "internalType": "uint256", "name": "amountOut", "type": "uint256" } + ], + "name": "quoteExactOutput", + "outputs": [ + { "internalType": "uint256", "name": "amountIn", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "tokenIn", "type": "address" }, + { "internalType": "address", "name": "tokenOut", "type": "address" }, + { "internalType": "uint24", "name": "fee", "type": "uint24" }, + { "internalType": "uint256", "name": "amountOut", "type": "uint256" }, + { + "internalType": "uint160", + "name": "sqrtPriceLimitX96", + "type": "uint160" + } + ], + "name": "quoteExactOutputSingle", + "outputs": [ + { "internalType": "uint256", "name": "amountIn", "type": "uint256" } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "int256", "name": "amount0Delta", "type": "int256" }, + { "internalType": "int256", "name": "amount1Delta", "type": "int256" }, + { "internalType": "bytes", "name": "path", "type": "bytes" } + ], + "name": "uniswapV3SwapCallback", + "outputs": [], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/abis/wstETH.json b/src/abis/wstETH.json new file mode 100644 index 0000000..fede488 --- /dev/null +++ b/src/abis/wstETH.json @@ -0,0 +1,252 @@ +[ + { + "inputs": [ + { "internalType": "contract IStETH", "name": "_stETH", "type": "address" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "owner", "type": "address" }, + { "internalType": "address", "name": "spender", "type": "address" } + ], + "name": "allowance", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "spender", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" } + ], + "name": "approve", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "account", "type": "address" } + ], + "name": "balanceOf", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "spender", "type": "address" }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_wstETHAmount", "type": "uint256" } + ], + "name": "getStETHByWstETH", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_stETHAmount", "type": "uint256" } + ], + "name": "getWstETHByStETH", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "spender", "type": "address" }, + { "internalType": "uint256", "name": "addedValue", "type": "uint256" } + ], + "name": "increaseAllowance", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "owner", "type": "address" } + ], + "name": "nonces", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "owner", "type": "address" }, + { "internalType": "address", "name": "spender", "type": "address" }, + { "internalType": "uint256", "name": "value", "type": "uint256" }, + { "internalType": "uint256", "name": "deadline", "type": "uint256" }, + { "internalType": "uint8", "name": "v", "type": "uint8" }, + { "internalType": "bytes32", "name": "r", "type": "bytes32" }, + { "internalType": "bytes32", "name": "s", "type": "bytes32" } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "stETH", + "outputs": [ + { "internalType": "contract IStETH", "name": "", "type": "address" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "stEthPerToken", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "tokensPerStEth", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" } + ], + "name": "transfer", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "sender", "type": "address" }, + { "internalType": "address", "name": "recipient", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" } + ], + "name": "transferFrom", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_wstETHAmount", "type": "uint256" } + ], + "name": "unwrap", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_stETHAmount", "type": "uint256" } + ], + "name": "wrap", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { "stateMutability": "payable", "type": "receive" } +] diff --git a/src/contracts/AbstractARM.sol b/src/contracts/AbstractARM.sol new file mode 100644 index 0000000..1398410 --- /dev/null +++ b/src/contracts/AbstractARM.sol @@ -0,0 +1,721 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import {OwnableOperable} from "./OwnableOperable.sol"; +import {IERC20, ICapManager} from "./Interfaces.sol"; + +abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable { + //////////////////////////////////////////////////// + /// Constants + //////////////////////////////////////////////////// + + /// @notice Maximum amount the Owner can set the cross price below 1 scaled to 36 decimals. + /// 20e32 is a 0.2% deviation, or 20 basis points. + uint256 public constant MAX_CROSS_PRICE_DEVIATION = 20e32; + /// @notice Scale of the prices. + uint256 public constant PRICE_SCALE = 1e36; + /// @dev The amount of shares that are minted to a dead address on initialization + uint256 internal constant MIN_TOTAL_SUPPLY = 1e12; + /// @dev The address with no known private key that the initial shares are minted to + address internal constant DEAD_ACCOUNT = 0x000000000000000000000000000000000000dEaD; + /// @notice The scale of the performance fee + /// 10,000 = 100% performance fee + uint256 public constant FEE_SCALE = 10000; + + //////////////////////////////////////////////////// + /// Immutable Variables + //////////////////////////////////////////////////// + + /// @notice The address of the asset that is used to add and remove liquidity. eg WETH + /// This is also the quote asset when the prices are set. + /// eg the stETH/WETH price has a base asset of stETH and quote asset of WETH. + address public immutable liquidityAsset; + /// @notice The asset being purchased by the ARM and put in the withdrawal queue. eg stETH + address public immutable baseAsset; + /// @notice The swap input token that is transferred to this contract. + /// From a User perspective, this is the token being sold. + /// token0 is also compatible with the Uniswap V2 Router interface. + IERC20 public immutable token0; + /// @notice The swap output token that is transferred from this contract. + /// From a User perspective, this is the token being bought. + /// token1 is also compatible with the Uniswap V2 Router interface. + IERC20 public immutable token1; + /// @notice The delay before a withdrawal request can be claimed in seconds. eg 600 is 10 minutes. + uint256 public immutable claimDelay; + + //////////////////////////////////////////////////// + /// Storage Variables + //////////////////////////////////////////////////// + + /** + * @notice For one `token0` from a Trader, how many `token1` does the pool send. + * For example, if `token0` is WETH and `token1` is stETH then + * `traderate0` is the WETH/stETH price. + * From a Trader's perspective, this is the buy price. + * From the ARM's perspective, this is the sell price. + * Rate is to 36 decimals (1e36). + * To convert to a stETH/WETH price, use `PRICE_SCALE * PRICE_SCALE / traderate0`. + */ + uint256 public traderate0; + /** + * @notice For one `token1` from a Trader, how many `token0` does the pool send. + * For example, if `token0` is WETH and `token1` is stETH then + * `traderate1` is the stETH/WETH price. + * From a Trader's perspective, this is the sell price. + * From a ARM's perspective, this is the buy price. + * Rate is to 36 decimals (1e36). + */ + uint256 public traderate1; + /// @notice The price that buy and sell prices can not cross scaled to 36 decimals. + /// This is also the price the base assets, eg stETH, in the ARM contract are priced at in `totalAssets`. + uint256 public crossPrice; + + /// @notice Cumulative total of all withdrawal requests including the ones that have already been claimed. + uint128 public withdrawsQueued; + /// @notice Total of all the withdrawal requests that have been claimed. + uint128 public withdrawsClaimed; + /// @notice Index of the next withdrawal request starting at 0. + uint256 public nextWithdrawalIndex; + + struct WithdrawalRequest { + address withdrawer; + bool claimed; + // When the withdrawal can be claimed + uint40 claimTimestamp; + // Amount of liquidity assets to withdraw. eg WETH + uint128 assets; + // Cumulative total of all withdrawal requests including this one when the redeem request was made. + uint128 queued; + } + + /// @notice Mapping of withdrawal request indices to the user withdrawal request data. + mapping(uint256 requestId => WithdrawalRequest) public withdrawalRequests; + + /// @notice Performance fee that is collected by the feeCollector measured in basis points (1/100th of a percent). + /// 10,000 = 100% performance fee + /// 2,000 = 20% performance fee + /// 500 = 5% performance fee + uint16 public fee; + /// @notice The available assets the last time the performance fees were collected and adjusted + /// for liquidity assets (WETH) deposited and redeemed. + /// This can be negative if there were asset gains and then all the liquidity providers redeemed. + int128 public lastAvailableAssets; + /// @notice The account or contract that can collect the performance fee. + address public feeCollector; + /// @notice The address of the CapManager contract used to manage the ARM's liquidity provider and total assets caps. + address public capManager; + + uint256[41] private _gap; + + //////////////////////////////////////////////////// + /// Events + //////////////////////////////////////////////////// + + event TraderateChanged(uint256 traderate0, uint256 traderate1); + event CrossPriceUpdated(uint256 crossPrice); + event Deposit(address indexed owner, uint256 assets, uint256 shares); + event RedeemRequested( + address indexed withdrawer, uint256 indexed requestId, uint256 assets, uint256 queued, uint256 claimTimestamp + ); + event RedeemClaimed(address indexed withdrawer, uint256 indexed requestId, uint256 assets); + event FeeCollected(address indexed feeCollector, uint256 fee); + event FeeUpdated(uint256 fee); + event FeeCollectorUpdated(address indexed newFeeCollector); + event CapManagerUpdated(address indexed capManager); + + constructor(address _token0, address _token1, address _liquidityAsset, uint256 _claimDelay) { + require(IERC20(_token0).decimals() == 18); + require(IERC20(_token1).decimals() == 18); + + token0 = IERC20(_token0); + token1 = IERC20(_token1); + + claimDelay = _claimDelay; + + _setOwner(address(0)); // Revoke owner for implementation contract at deployment + + require(_liquidityAsset == address(token0) || _liquidityAsset == address(token1), "invalid liquidity asset"); + liquidityAsset = _liquidityAsset; + // The base asset, eg stETH, is not the liquidity asset, eg WETH + baseAsset = _liquidityAsset == address(token0) ? address(token1) : address(token0); + } + + /// @notice Initialize the contract. + /// The deployer that calls initialize has to approve the this ARM's proxy contract to transfer 1e12 WETH. + /// @param _operator The address of the account that can request and claim Lido withdrawals. + /// @param _name The name of the liquidity provider (LP) token. + /// @param _symbol The symbol of the liquidity provider (LP) token. + /// @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 + /// 500 = 5% performance fee + /// @param _feeCollector The account that can collect the performance fee + /// @param _capManager The address of the CapManager contract + function _initARM( + address _operator, + string calldata _name, + string calldata _symbol, + uint256 _fee, + address _feeCollector, + address _capManager + ) internal { + _initOwnableOperable(_operator); + + __ERC20_init(_name, _symbol); + + // Transfer a small bit of liquidity from the initializer to this contract + IERC20(liquidityAsset).transferFrom(msg.sender, address(this), MIN_TOTAL_SUPPLY); + + // mint a small amount of shares to a dead account so the total supply can never be zero + // This avoids donation attacks when there are no assets in the ARM contract + _mint(DEAD_ACCOUNT, MIN_TOTAL_SUPPLY); + + // Set the sell price to its highest value. 1.0 + traderate0 = PRICE_SCALE; + // Set the buy price to its lowest value. 0.998 + traderate1 = PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION; + emit TraderateChanged(traderate0, traderate1); + + // Initialize the last available assets to the current available assets + // This ensures no performance fee is accrued when the performance fee is calculated when the fee is set + lastAvailableAssets = SafeCast.toInt128(SafeCast.toInt256(_availableAssets())); + _setFee(_fee); + _setFeeCollector(_feeCollector); + + capManager = _capManager; + emit CapManagerUpdated(_capManager); + + crossPrice = PRICE_SCALE; + emit CrossPriceUpdated(PRICE_SCALE); + } + + //////////////////////////////////////////////////// + /// Swap Functions + //////////////////////////////////////////////////// + + /** + * @notice Swaps an exact amount of input tokens for as many output tokens as possible. + * msg.sender should have already given the ARM contract an allowance of + * at least amountIn on the input token. + * + * @param inToken Input token. + * @param outToken Output token. + * @param amountIn The amount of input tokens to send. + * @param amountOutMin The minimum amount of output tokens that must be received for the transaction not to revert. + * @param to Recipient of the output tokens. + */ + function swapExactTokensForTokens( + IERC20 inToken, + IERC20 outToken, + uint256 amountIn, + uint256 amountOutMin, + address to + ) external virtual { + uint256 amountOut = _swapExactTokensForTokens(inToken, outToken, amountIn, to); + require(amountOut >= amountOutMin, "ARM: Insufficient output amount"); + } + + /** + * @notice Uniswap V2 Router compatible interface. Swaps an exact amount of + * input tokens for as many output tokens as possible. + * msg.sender should have already given the ARM contract an allowance of + * at least amountIn on the input token. + * + * @param amountIn The amount of input tokens to send. + * @param amountOutMin The minimum amount of output tokens that must be received for the transaction not to revert. + * @param path The input and output token addresses. + * @param to Recipient of the output tokens. + * @param deadline Unix timestamp after which the transaction will revert. + * @return amounts The input and output token amounts. + */ + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + address[] calldata path, + address to, + uint256 deadline + ) external virtual returns (uint256[] memory amounts) { + require(path.length == 2, "ARM: Invalid path length"); + _inDeadline(deadline); + + IERC20 inToken = IERC20(path[0]); + IERC20 outToken = IERC20(path[1]); + + uint256 amountOut = _swapExactTokensForTokens(inToken, outToken, amountIn, to); + + require(amountOut >= amountOutMin, "ARM: Insufficient output amount"); + + amounts = new uint256[](2); + amounts[0] = amountIn; + amounts[1] = amountOut; + } + + /** + * @notice Receive an exact amount of output tokens for as few input tokens as possible. + * msg.sender should have already given the router an allowance of + * at least amountInMax on the input token. + * + * @param inToken Input token. + * @param outToken Output token. + * @param amountOut The amount of output tokens to receive. + * @param amountInMax The maximum amount of input tokens that can be required before the transaction reverts. + * @param to Recipient of the output tokens. + */ + function swapTokensForExactTokens( + IERC20 inToken, + IERC20 outToken, + uint256 amountOut, + uint256 amountInMax, + address to + ) external virtual { + uint256 amountIn = _swapTokensForExactTokens(inToken, outToken, amountOut, to); + + require(amountIn <= amountInMax, "ARM: Excess input amount"); + } + + /** + * @notice Uniswap V2 Router compatible interface. Receive an exact amount of + * output tokens for as few input tokens as possible. + * msg.sender should have already given the router an allowance of + * at least amountInMax on the input token. + * + * @param amountOut The amount of output tokens to receive. + * @param amountInMax The maximum amount of input tokens that can be required before the transaction reverts. + * @param path The input and output token addresses. + * @param to Recipient of the output tokens. + * @param deadline Unix timestamp after which the transaction will revert. + * @return amounts The input and output token amounts. + */ + function swapTokensForExactTokens( + uint256 amountOut, + uint256 amountInMax, + address[] calldata path, + address to, + uint256 deadline + ) external virtual returns (uint256[] memory amounts) { + require(path.length == 2, "ARM: Invalid path length"); + _inDeadline(deadline); + + IERC20 inToken = IERC20(path[0]); + IERC20 outToken = IERC20(path[1]); + + uint256 amountIn = _swapTokensForExactTokens(inToken, outToken, amountOut, to); + + require(amountIn <= amountInMax, "ARM: Excess input amount"); + + amounts = new uint256[](2); + amounts[0] = amountIn; + amounts[1] = amountOut; + } + + function _inDeadline(uint256 deadline) internal view { + require(deadline >= block.timestamp, "ARM: Deadline expired"); + } + + /// @dev Ensure any liquidity assets reserved for the withdrawal queue are not used + /// in swaps that send liquidity assets out of the ARM + function _transferAsset(address asset, address to, uint256 amount) internal virtual { + if (asset == liquidityAsset) _requireLiquidityAvailable(amount); + + IERC20(asset).transfer(to, amount); + } + + /// @dev Hook to transfer assets into the ARM contract + function _transferAssetFrom(address asset, address from, address to, uint256 amount) internal virtual { + IERC20(asset).transferFrom(from, to, amount); + } + + function _swapExactTokensForTokens(IERC20 inToken, IERC20 outToken, uint256 amountIn, address to) + internal + virtual + returns (uint256 amountOut) + { + uint256 price; + if (inToken == token0) { + require(outToken == token1, "ARM: Invalid out token"); + price = traderate0; + } else if (inToken == token1) { + require(outToken == token0, "ARM: Invalid out token"); + price = traderate1; + } else { + revert("ARM: Invalid in token"); + } + amountOut = amountIn * price / PRICE_SCALE; + + // Transfer the input tokens from the caller to this ARM contract + _transferAssetFrom(address(inToken), msg.sender, address(this), amountIn); + + // Transfer the output tokens to the recipient + _transferAsset(address(outToken), to, amountOut); + } + + function _swapTokensForExactTokens(IERC20 inToken, IERC20 outToken, uint256 amountOut, address to) + internal + virtual + returns (uint256 amountIn) + { + uint256 price; + if (inToken == token0) { + require(outToken == token1, "ARM: Invalid out token"); + price = traderate0; + } else if (inToken == token1) { + require(outToken == token0, "ARM: Invalid out token"); + price = traderate1; + } else { + revert("ARM: Invalid in token"); + } + // always round in our favor + // +1 for truncation when dividing integers + // +2 to cover stETH transfers being up to 2 wei short of the requested transfer amount + amountIn = ((amountOut * PRICE_SCALE) / price) + 3; + + // Transfer the input tokens from the caller to this ARM contract + _transferAssetFrom(address(inToken), msg.sender, address(this), amountIn); + + // Transfer the output tokens to the recipient + _transferAsset(address(outToken), to, amountOut); + } + + /** + * @notice Set exchange rates from an operator account from the ARM's perspective. + * If token 0 is WETH and token 1 is stETH, then both prices will be set using the stETH/WETH price. + * @param buyT1 The price the ARM buys Token 1 (stETH) from the Trader, denominated in Token 0 (WETH), scaled to 36 decimals. + * From the Trader's perspective, this is the sell price. + * @param sellT1 The price the ARM sells Token 1 (stETH) to the Trader, denominated in Token 0 (WETH), scaled to 36 decimals. + * From the Trader's perspective, this is the buy price. + */ + function setPrices(uint256 buyT1, uint256 sellT1) external onlyOperatorOrOwner { + // Ensure buy price is always below past sell prices + require(sellT1 >= crossPrice, "ARM: sell price too low"); + require(buyT1 < crossPrice, "ARM: buy price too high"); + + traderate0 = PRICE_SCALE * PRICE_SCALE / sellT1; // quote (t0) -> base (t1); eg WETH -> stETH + traderate1 = buyT1; // base (t1) -> quote (t0). eg stETH -> WETH + + emit TraderateChanged(traderate0, traderate1); + } + + /** + * @notice set the price that buy and sell prices can not cross. + * That is, the buy prices must be below the cross price + * and the sell prices must be above the cross price. + * If the cross price is being lowered, there can not be a significant amount of base assets in the ARM. eg stETH. + * This prevents the ARM making a loss when the base asset is sold at a lower price than it was bought + * before the cross price was lowered. + * The base assets should be sent to the withdrawal queue before the cross price can be lowered. + * The cross price can be increased with assets in the ARM. + * @param newCrossPrice The new cross price scaled to 36 decimals. + */ + function setCrossPrice(uint256 newCrossPrice) external onlyOwner { + require(newCrossPrice >= PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION, "ARM: cross price too low"); + require(newCrossPrice <= PRICE_SCALE, "ARM: cross price too high"); + // The exiting sell price must be greater than or equal to the new cross price + require(PRICE_SCALE * PRICE_SCALE / traderate0 >= newCrossPrice, "ARM: sell price too low"); + // The existing buy price must be less than the new cross price + require(traderate1 < newCrossPrice, "ARM: buy price too high"); + + // If the cross price is being lowered, there can not be a significant amount of base assets in the ARM. eg stETH. + // This prevents the ARM making a loss when the base asset is sold at a lower price than it was bought + // before the cross price was lowered. + if (newCrossPrice < crossPrice) { + // Check there is not a significant amount of base assets in the ARM + require(IERC20(baseAsset).balanceOf(address(this)) < MIN_TOTAL_SUPPLY, "ARM: too many base assets"); + } + + // Save the new cross price to storage + crossPrice = newCrossPrice; + + emit CrossPriceUpdated(newCrossPrice); + } + + //////////////////////////////////////////////////// + /// Liquidity Provider Functions + //////////////////////////////////////////////////// + + /// @notice Preview the amount of shares that would be minted for a given amount of assets + /// @param assets The amount of liquidity assets to deposit + /// @return shares The amount of shares that would be minted + function previewDeposit(uint256 assets) external view returns (uint256 shares) { + shares = convertToShares(assets); + } + + /// @notice deposit liquidity assets in exchange for liquidity provider (LP) shares. + /// The caller needs to have approved the contract to transfer the assets. + /// @param assets The amount of liquidity assets to deposit + /// @return shares The amount of shares that were minted + function deposit(uint256 assets) external returns (uint256 shares) { + shares = _deposit(assets, msg.sender); + } + + /// @notice deposit liquidity assets in exchange for liquidity provider (LP) shares. + /// Funds will be transferred from msg.sender. + /// @param assets The amount of liquidity assets to deposit + /// @param receiver The address that will receive shares. + /// @return shares The amount of shares that were minted + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + shares = _deposit(assets, receiver); + } + + /// @dev Internal logic for depositing liquidity assets in exchange for liquidity provider (LP) shares. + function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) { + // Calculate the amount of shares to mint after the performance fees have been accrued + // which reduces the available assets, and before new assets are deposited. + shares = convertToShares(assets); + + // Add the deposited assets to the last available assets + lastAvailableAssets += SafeCast.toInt128(SafeCast.toInt256(assets)); + + // Transfer the liquidity asset from the sender to this contract + IERC20(liquidityAsset).transferFrom(msg.sender, address(this), assets); + + // mint shares + _mint(receiver, shares); + + // Check the liquidity provider caps after the new assets have been deposited + if (capManager != address(0)) { + ICapManager(capManager).postDepositHook(receiver, assets); + } + + emit Deposit(receiver, assets, shares); + } + + /// @notice Preview the amount of assets that would be received for burning a given amount of shares + /// @param shares The amount of shares to burn + /// @return assets The amount of liquidity assets that would be received + function previewRedeem(uint256 shares) external view returns (uint256 assets) { + assets = convertToAssets(shares); + } + + /// @notice Request to redeem liquidity provider shares for liquidity assets + /// @param shares The amount of shares the redeemer wants to burn for liquidity assets + /// @return requestId The index of the withdrawal request + /// @return assets The amount of liquidity assets that will be claimable by the redeemer + function requestRedeem(uint256 shares) external returns (uint256 requestId, uint256 assets) { + // Calculate the amount of assets to transfer to the redeemer + assets = convertToAssets(shares); + + requestId = nextWithdrawalIndex; + // Store the next withdrawal request + nextWithdrawalIndex = requestId + 1; + + uint128 queued = SafeCast.toUint128(withdrawsQueued + assets); + // Store the updated queued amount which reserves liquidity assets (WETH) in the withdrawal queue + withdrawsQueued = queued; + + uint40 claimTimestamp = uint40(block.timestamp + claimDelay); + + // Store requests + withdrawalRequests[requestId] = WithdrawalRequest({ + withdrawer: msg.sender, + claimed: false, + claimTimestamp: claimTimestamp, + assets: SafeCast.toUint128(assets), + queued: queued + }); + + // burn redeemer's shares + _burn(msg.sender, shares); + + // Remove the redeemed assets from the last available assets + lastAvailableAssets -= SafeCast.toInt128(SafeCast.toInt256(assets)); + + emit RedeemRequested(msg.sender, requestId, assets, queued, claimTimestamp); + } + + /// @notice Claim liquidity assets from a previous withdrawal request after the claim delay has passed + /// @param requestId The index of the withdrawal request + /// @return assets The amount of liquidity assets that were transferred to the redeemer + function claimRedeem(uint256 requestId) external returns (uint256 assets) { + // Load the struct from storage into memory + WithdrawalRequest memory request = withdrawalRequests[requestId]; + + require(request.claimTimestamp <= block.timestamp, "Claim delay not met"); + // Is there enough liquidity to claim this request? + require(request.queued <= claimable(), "Queue pending liquidity"); + require(request.withdrawer == msg.sender, "Not requester"); + require(request.claimed == false, "Already claimed"); + + assets = request.assets; + + // Store the request as claimed + withdrawalRequests[requestId].claimed = true; + // Store the updated claimed amount + withdrawsClaimed += SafeCast.toUint128(assets); + + // transfer the liquidity asset to the withdrawer + IERC20(liquidityAsset).transfer(msg.sender, assets); + + emit RedeemClaimed(msg.sender, requestId, assets); + } + + /// @notice Used to work out if an ARM's withdrawal request can be claimed. + /// If the withdrawal request's `queued` amount is less than the returned `claimable` amount, then it can be claimed. + function claimable() public view returns (uint256) { + return withdrawsClaimed + IERC20(liquidityAsset).balanceOf(address(this)); + } + + /// @dev Checks if there is enough liquidity asset (WETH) in the ARM is not reserved for the withdrawal queue. + // That is, the amount of liquidity assets (WETH) that is available to be swapped or collected as fees. + // If no outstanding withdrawals, no check will be done of the amount against the balance of the liquidity assets in the ARM. + // This is a gas optimization for swaps. + // The ARM can swap out liquidity assets (WETH) that has been accrued from the performance fee for the fee collector. + // There is no liquidity guarantee for the fee collector. If there is not enough liquidity assets (WETH) in + // the ARM to collect the accrued fees, then the fee collector will have to wait until there is enough liquidity assets. + function _requireLiquidityAvailable(uint256 amount) internal view { + // The amount of liquidity assets (WETH) that is still to be claimed in the withdrawal queue + uint256 outstandingWithdrawals = withdrawsQueued - withdrawsClaimed; + + // Save gas on an external balanceOf call if there are no outstanding withdrawals + if (outstandingWithdrawals == 0) return; + + // If there is not enough liquidity assets in the ARM to cover the outstanding withdrawals and the amount + require( + amount + outstandingWithdrawals <= IERC20(liquidityAsset).balanceOf(address(this)), + "ARM: Insufficient liquidity" + ); + } + + /// @notice The total amount of assets in the ARM and external withdrawal queue, + /// less the liquidity assets reserved for the ARM's withdrawal queue and accrued fees. + function totalAssets() public view virtual returns (uint256) { + (uint256 fees, uint256 newAvailableAssets) = _feesAccrued(); + + // total assets should only go up from the initial deposit amount that is burnt + // but in case of something unforeseen, return MIN_TOTAL_SUPPLY if fees is + // greater than or equal the available assets + if (fees >= newAvailableAssets) return MIN_TOTAL_SUPPLY; + + // Remove the performance fee from the available assets + return newAvailableAssets - fees; + } + + /// @dev Calculate the available assets which is the assets in the ARM, external withdrawal queue, + /// less liquidity assets reserved for the ARM's withdrawal queue. + /// This does not exclude any accrued performance fees. + function _availableAssets() internal view returns (uint256) { + // Liquidity assets, eg WETH, are priced at 1.0 + // Base assets, eg stETH, in the withdrawal queue are also priced at 1.0 + // Base assets, eg stETH, in the ARM are priced at the cross price which is a discounted price + uint256 assets = IERC20(liquidityAsset).balanceOf(address(this)) + _externalWithdrawQueue() + + IERC20(baseAsset).balanceOf(address(this)) * crossPrice / PRICE_SCALE; + + // The amount of liquidity assets (WETH) that is still to be claimed in the withdrawal queue + uint256 outstandingWithdrawals = withdrawsQueued - withdrawsClaimed; + + // If the ARM becomes insolvent enough that the available assets in the ARM and external withdrawal queue + // is less than the outstanding withdrawals and accrued fees. + if (assets < outstandingWithdrawals) { + return 0; + } + + // Need to remove the liquidity assets that have been reserved for the withdrawal queue + return assets - outstandingWithdrawals; + } + + /// @dev Hook for calculating the amount of assets in an external withdrawal queue like Lido or OETH + /// This is not the ARM's withdrawal queue + function _externalWithdrawQueue() internal view virtual returns (uint256 assets); + + /// @notice Calculates the amount of shares for a given amount of liquidity assets + /// @dev Total assets can't be zero. The lowest it can be is MIN_TOTAL_SUPPLY + function convertToShares(uint256 assets) public view returns (uint256 shares) { + shares = assets * totalSupply() / totalAssets(); + } + + /// @notice Calculates the amount of liquidity assets for a given amount of shares + /// @dev Total supply can't be zero. The lowest it can be is MIN_TOTAL_SUPPLY + function convertToAssets(uint256 shares) public view returns (uint256 assets) { + assets = (shares * totalAssets()) / totalSupply(); + } + + /// @notice Set the CapManager contract address. + /// Set to a zero address to disable the controller. + function setCapManager(address _capManager) external onlyOwner { + capManager = _capManager; + + emit CapManagerUpdated(_capManager); + } + + //////////////////////////////////////////////////// + /// Performance Fee Functions + //////////////////////////////////////////////////// + + /// @notice Owner sets the performance fee on increased assets + /// @param _fee The performance fee measured in basis points (1/100th of a percent) + /// 10,000 = 100% performance fee + /// 500 = 5% performance fee + /// The max allowed performance fee is 50% (5000) + function setFee(uint256 _fee) external onlyOwner { + _setFee(_fee); + } + + /// @notice Owner sets the account/contract that receives the performance fee + function setFeeCollector(address _feeCollector) external onlyOwner { + _setFeeCollector(_feeCollector); + } + + function _setFee(uint256 _fee) internal { + require(_fee <= FEE_SCALE / 2, "ARM: fee too high"); + + // Collect any performance fees up to this point using the old fee + collectFees(); + + fee = SafeCast.toUint16(_fee); + + emit FeeUpdated(_fee); + } + + function _setFeeCollector(address _feeCollector) internal { + require(_feeCollector != address(0), "ARM: invalid fee collector"); + + feeCollector = _feeCollector; + + emit FeeCollectorUpdated(_feeCollector); + } + + /// @notice Transfer accrued performance fees to the fee collector + /// This requires enough liquidity assets (WETH) in the ARM that are not reserved + /// for the withdrawal queue to cover the accrued fees. + function collectFees() public returns (uint256 fees) { + uint256 newAvailableAssets; + // Accrue any performance fees up to this point + (fees, newAvailableAssets) = _feesAccrued(); + + if (fees == 0) return 0; + + // Check there is enough liquidity assets (WETH) that are not reserved for the withdrawal queue + // to cover the fee being collected. + _requireLiquidityAvailable(fees); + // _requireLiquidityAvailable() is optimized for swaps so will not revert if there are no outstanding withdrawals. + // We need to check there is enough liquidity assets to cover the fees being collect from this ARM contract. + // We could try the transfer and let it revert if there are not enough assets, but there is no error message with + // a failed WETH transfer so we spend the extra gas to check and give a meaningful error message. + require(fees <= IERC20(liquidityAsset).balanceOf(address(this)), "ARM: insufficient liquidity"); + + // Save the new available assets back to storage less the collected fees. + lastAvailableAssets = SafeCast.toInt128(SafeCast.toInt256(newAvailableAssets) - SafeCast.toInt256(fees)); + + IERC20(liquidityAsset).transfer(feeCollector, fees); + + emit FeeCollected(feeCollector, fees); + } + + /// @notice Calculates the performance fees accrued since the last time fees were collected + function feesAccrued() external view returns (uint256 fees) { + (fees,) = _feesAccrued(); + } + + function _feesAccrued() internal view returns (uint256 fees, uint256 newAvailableAssets) { + newAvailableAssets = _availableAssets(); + + // Calculate the increase in assets since the last time fees were calculated + int256 assetIncrease = SafeCast.toInt256(newAvailableAssets) - lastAvailableAssets; + + // Do not accrued a performance fee if the available assets has decreased + if (assetIncrease <= 0) return (0, newAvailableAssets); + + fees = SafeCast.toUint256(assetIncrease) * fee / FEE_SCALE; + } +} diff --git a/src/contracts/CapManager.sol b/src/contracts/CapManager.sol new file mode 100644 index 0000000..756c74c --- /dev/null +++ b/src/contracts/CapManager.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {OwnableOperable} from "./OwnableOperable.sol"; +import {ILiquidityProviderARM} from "./Interfaces.sol"; + +/** + * @title Manages capital limits of an Automated Redemption Manager (ARM). + * Caps the total assets and individual liquidity provider assets. + * @author Origin Protocol Inc + */ +contract CapManager is Initializable, OwnableOperable { + /// @notice The address of the linked Automated Redemption Manager (ARM). + address public immutable arm; + + /// @notice true if a cap is placed on each liquidity provider's account. + bool public accountCapEnabled; + /// @notice The ARM's maximum allowed total assets. + uint248 public totalAssetsCap; + /// @notice The maximum allowed assets for each liquidity provider. + /// This is effectively a whitelist of liquidity providers as a zero amount prevents any deposits. + mapping(address liquidityProvider => uint256 cap) public liquidityProviderCaps; + + uint256[48] private _gap; + + event LiquidityProviderCap(address indexed liquidityProvider, uint256 cap); + event TotalAssetsCap(uint256 cap); + event AccountCapEnabled(bool enabled); + + constructor(address _arm) { + arm = _arm; + } + + function initialize(address _operator) external initializer { + _initOwnableOperable(_operator); + accountCapEnabled = false; + } + + function postDepositHook(address liquidityProvider, uint256 assets) external { + require(msg.sender == arm, "LPC: Caller is not ARM"); + + // total assets has already been updated with the new assets + require(totalAssetsCap >= ILiquidityProviderARM(arm).totalAssets(), "LPC: Total assets cap exceeded"); + + if (!accountCapEnabled) return; + + uint256 oldCap = liquidityProviderCaps[liquidityProvider]; + require(oldCap >= assets, "LPC: LP cap exceeded"); + + uint256 newCap = oldCap - assets; + + // Save the new LP cap to storage + liquidityProviderCaps[liquidityProvider] = newCap; + + emit LiquidityProviderCap(liquidityProvider, newCap); + } + + function setLiquidityProviderCaps(address[] calldata _liquidityProviders, uint256 cap) + external + onlyOperatorOrOwner + { + for (uint256 i = 0; i < _liquidityProviders.length; i++) { + liquidityProviderCaps[_liquidityProviders[i]] = cap; + + emit LiquidityProviderCap(_liquidityProviders[i], cap); + } + } + + /// @notice Set the ARM's maximum total assets. + /// Setting to zero will prevent any further deposits. + /// The liquidity provider can still withdraw assets. + function setTotalAssetsCap(uint248 _totalAssetsCap) external onlyOperatorOrOwner { + totalAssetsCap = _totalAssetsCap; + + emit TotalAssetsCap(_totalAssetsCap); + } + + /// @notice Enable or disable the account cap. + function setAccountCapEnabled(bool _accountCapEnabled) external onlyOwner { + require(accountCapEnabled != _accountCapEnabled, "LPC: Account cap already set"); + + accountCapEnabled = _accountCapEnabled; + + emit AccountCapEnabled(_accountCapEnabled); + } +} diff --git a/src/contracts/Interfaces.sol b/src/contracts/Interfaces.sol index 0dab405..408f0f6 100644 --- a/src/contracts/Interfaces.sol +++ b/src/contracts/Interfaces.sol @@ -13,12 +13,6 @@ interface IERC20 { event Transfer(address indexed from, address indexed to, uint256 value); } -interface IERC20Metadata is IERC20 { - function name() external view returns (string memory); - function symbol() external view returns (string memory); - function decimals() external view returns (uint8); -} - interface IOethARM { function token0() external returns (address); function token1() external returns (address); @@ -113,6 +107,29 @@ interface IOethARM { function claimWithdrawals(uint256[] calldata requestIds) external; } +interface ILiquidityProviderARM is IERC20 { + function previewDeposit(uint256 assets) external returns (uint256 shares); + function deposit(uint256 assets) external returns (uint256 shares); + function deposit(uint256 assets, address liquidityProvider) external returns (uint256 shares); + + function previewRedeem(uint256 shares) external returns (uint256 assets); + function requestRedeem(uint256 shares) external returns (uint256 requestId, uint256 assets); + function claimRedeem(uint256 requestId) external returns (uint256 assets); + + function totalAssets() external returns (uint256 assets); + function convertToShares(uint256 assets) external returns (uint256 shares); + function convertToAssets(uint256 shares) external returns (uint256 assets); + function lastTotalAssets() external returns (uint256 assets); +} + +interface ICapManager { + function postDepositHook(address liquidityProvider, uint256 assets) external; +} + +interface LegacyAMM { + function transferToken(address tokenOut, address to, uint256 amount) external; +} + interface IOETHVault { function mint(address _asset, uint256 _amount, uint256 _minimumOusdAmount) external; @@ -144,7 +161,7 @@ interface IOETHVault { view returns (address withdrawer, bool claimed, uint40 timestamp, uint128 amount, uint128 queued); - function CLAIM_DELAY() external view returns (uint256); + function claimDelay() external view returns (uint256); } interface IGovernance { @@ -183,3 +200,59 @@ interface IWETH is IERC20 { function deposit() external payable; function withdraw(uint256 wad) external; } + +interface ISTETH is IERC20 { + event Submitted(address indexed sender, uint256 amount, address referral); + + // function() external payable; + function submit(address _referral) external payable returns (uint256); +} + +interface IStETHWithdrawal { + event WithdrawalRequested( + uint256 indexed requestId, + address indexed requestor, + address indexed owner, + uint256 amountOfStETH, + uint256 amountOfShares + ); + event WithdrawalsFinalized( + uint256 indexed from, uint256 indexed to, uint256 amountOfETHLocked, uint256 sharesToBurn, uint256 timestamp + ); + event WithdrawalClaimed( + uint256 indexed requestId, address indexed owner, address indexed receiver, uint256 amountOfETH + ); + + struct WithdrawalRequestStatus { + /// @notice stETH token amount that was locked on withdrawal queue for this request + uint256 amountOfStETH; + /// @notice amount of stETH shares locked on withdrawal queue for this request + uint256 amountOfShares; + /// @notice address that can claim or transfer this request + address owner; + /// @notice timestamp of when the request was created, in seconds + uint256 timestamp; + /// @notice true, if request is finalized + bool isFinalized; + /// @notice true, if request is claimed. Request is claimable if (isFinalized && !isClaimed) + bool isClaimed; + } + + function transferFrom(address _from, address _to, uint256 _requestId) external; + function ownerOf(uint256 _requestId) external returns (address); + function requestWithdrawals(uint256[] calldata _amounts, address _owner) + external + returns (uint256[] memory requestIds); + function getLastCheckpointIndex() external view returns (uint256); + function findCheckpointHints(uint256[] calldata _requestIds, uint256 _firstIndex, uint256 _lastIndex) + external + view + returns (uint256[] memory hintIds); + function claimWithdrawals(uint256[] calldata _requestIds, uint256[] calldata _hints) external; + function getWithdrawalStatus(uint256[] calldata _requestIds) + external + view + returns (WithdrawalRequestStatus[] memory statuses); + function getWithdrawalRequests(address _owner) external view returns (uint256[] memory requestsIds); + function getLastRequestId() external view returns (uint256); +} diff --git a/src/contracts/LidoARM.sol b/src/contracts/LidoARM.sol new file mode 100644 index 0000000..c541413 --- /dev/null +++ b/src/contracts/LidoARM.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import {AbstractARM} from "./AbstractARM.sol"; +import {IERC20, IStETHWithdrawal, IWETH} from "./Interfaces.sol"; + +/** + * @title Lido (stETH) Automated Redemption Manager (ARM) + * @dev This implementation supports multiple Liquidity Providers (LPs) with single buy and sell prices. + * It also integrates to a CapManager contract that caps the amount of assets a liquidity provider + * can deposit and caps the ARM's total assets. + * A performance fee is also collected on increases in the ARM's total assets. + * @author Origin Protocol Inc + */ +contract LidoARM is Initializable, AbstractARM { + /// @notice The address of the Lido stETH token + IERC20 public immutable steth; + /// @notice The address of the Wrapped ETH (WETH) token + IWETH public immutable weth; + /// @notice The address of the Lido Withdrawal Queue contract + IStETHWithdrawal public immutable lidoWithdrawalQueue; + + /// @notice The amount of stETH in the Lido Withdrawal Queue + uint256 public lidoWithdrawalQueueAmount; + + event RequestLidoWithdrawals(uint256[] amounts, uint256[] requestIds); + event ClaimLidoWithdrawals(uint256[] requestIds); + + /// @param _steth The address of the stETH token + /// @param _weth The address of the WETH token + /// @param _lidoWithdrawalQueue The address of the Lido's withdrawal queue contract + /// @param _claimDelay The delay in seconds before a user can claim a redeem from the request + constructor(address _steth, address _weth, address _lidoWithdrawalQueue, uint256 _claimDelay) + AbstractARM(_weth, _steth, _weth, _claimDelay) + { + steth = IERC20(_steth); + weth = IWETH(_weth); + lidoWithdrawalQueue = IStETHWithdrawal(_lidoWithdrawalQueue); + } + + /// @notice Initialize the storage variables stored in the proxy contract. + /// The deployer that calls initialize has to approve the this ARM's proxy contract to transfer 1e12 WETH. + /// @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 Lido 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); + + // Approve the Lido withdrawal queue contract. Used for redemption requests. + steth.approve(address(lidoWithdrawalQueue), type(uint256).max); + } + + /** + * @notice Request a stETH for ETH withdrawal. + * Reference: https://docs.lido.fi/contracts/withdrawal-queue-erc721/ + * Note: There is a 1k amount limit. Caller should split large withdrawals in chunks of less or equal to 1k each.) + */ + function requestLidoWithdrawals(uint256[] memory amounts) + external + onlyOperatorOrOwner + returns (uint256[] memory requestIds) + { + requestIds = lidoWithdrawalQueue.requestWithdrawals(amounts, address(this)); + + // Sum the total amount of stETH being withdraw + uint256 totalAmountRequested = 0; + for (uint256 i = 0; i < amounts.length; i++) { + totalAmountRequested += amounts[i]; + } + + // Increase the Ether outstanding from the Lido Withdrawal Queue + lidoWithdrawalQueueAmount += totalAmountRequested; + + emit RequestLidoWithdrawals(amounts, requestIds); + } + + /** + * @notice Claim the ETH owed from the redemption requests and convert it to WETH. + * Before calling this method, caller should check on the request NFTs to ensure the withdrawal was processed. + */ + function claimLidoWithdrawals(uint256[] memory requestIds) external { + uint256 etherBefore = address(this).balance; + + // Claim the NFTs for ETH. + uint256 lastIndex = lidoWithdrawalQueue.getLastCheckpointIndex(); + uint256[] memory hintIds = lidoWithdrawalQueue.findCheckpointHints(requestIds, 1, lastIndex); + lidoWithdrawalQueue.claimWithdrawals(requestIds, hintIds); + + uint256 etherAfter = address(this).balance; + + // Reduce the Ether outstanding from the Lido Withdrawal Queue + lidoWithdrawalQueueAmount -= etherAfter - etherBefore; + + // Wrap all the received ETH to WETH. + weth.deposit{value: etherAfter}(); + + emit ClaimLidoWithdrawals(requestIds); + } + + /** + * @dev Calculates the amount of stETH in the Lido Withdrawal Queue. + */ + function _externalWithdrawQueue() internal view override returns (uint256) { + return lidoWithdrawalQueueAmount; + } + + /// @notice This payable method is necessary for receiving ETH claimed from the Lido withdrawal queue. + receive() external payable {} +} diff --git a/src/contracts/OethARM.sol b/src/contracts/OethARM.sol index 470dad6..37d217c 100644 --- a/src/contracts/OethARM.sol +++ b/src/contracts/OethARM.sol @@ -1,23 +1,35 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {AbstractARM} from "./AbstractARM.sol"; import {PeggedARM} from "./PeggedARM.sol"; +import {OwnerLP} from "./OwnerLP.sol"; import {OethLiquidityManager} from "./OethLiquidityManager.sol"; -import {Initializable} from "./utils/Initializable.sol"; -contract OethARM is Initializable, PeggedARM, OethLiquidityManager { +/** + * @title Origin Ether (OETH) Automated Redemption Manager (ARM) + * @author Origin Protocol Inc + */ +contract OethARM is Initializable, OwnerLP, PeggedARM, OethLiquidityManager { /// @param _oeth The address of the OETH token that is being swapped into this contract. /// @param _weth The address of the WETH token that is being swapped out of this contract. /// @param _oethVault The address of the OETH Vault proxy. constructor(address _oeth, address _weth, address _oethVault) - PeggedARM(_oeth, _weth) + AbstractARM(_oeth, _weth, _weth, 10 minutes) + PeggedARM(false) OethLiquidityManager(_oeth, _oethVault) {} /// @notice Initialize the contract. - /// @param _operator The address of the account that can request and claim OETH withdrawals. + /// @param _operator The address of the account that can request and claim OETH withdrawals from the OETH Vault. function initialize(address _operator) external initializer { _setOperator(_operator); _approvals(); } + + function _externalWithdrawQueue() internal view override returns (uint256 assets) { + // TODO track OETH sent to the OETH Vault's withdrawal queue + } } diff --git a/src/contracts/OethLiquidityManager.sol b/src/contracts/OethLiquidityManager.sol index 2f073a4..475625f 100644 --- a/src/contracts/OethLiquidityManager.sol +++ b/src/contracts/OethLiquidityManager.sol @@ -4,6 +4,10 @@ pragma solidity ^0.8.23; import {OwnableOperable} from "./OwnableOperable.sol"; import {IERC20, IOETHVault} from "./Interfaces.sol"; +/** + * @title Manages OETH liquidity against the OETH Vault. + * @author Origin Protocol Inc + */ contract OethLiquidityManager is OwnableOperable { address public immutable oeth; address public immutable oethVault; diff --git a/src/contracts/OwnableOperable.sol b/src/contracts/OwnableOperable.sol index 6dfcd2f..e2e0363 100644 --- a/src/contracts/OwnableOperable.sol +++ b/src/contracts/OwnableOperable.sol @@ -7,10 +7,14 @@ contract OwnableOperable is Ownable { /// @notice The account that can request and claim withdrawals. address public operator; - uint256[50] private _gap; + uint256[49] private _gap; event OperatorChanged(address newAdmin); + function _initOwnableOperable(address _operator) internal { + _setOperator(_operator); + } + /// @notice Set the account that can request and claim withdrawals. /// @param newOperator The address of the new operator. function setOperator(address newOperator) external onlyOwner { diff --git a/src/contracts/OwnerLP.sol b/src/contracts/OwnerLP.sol new file mode 100644 index 0000000..6778172 --- /dev/null +++ b/src/contracts/OwnerLP.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Ownable} from "./Ownable.sol"; +import {IERC20} from "./Interfaces.sol"; + +abstract contract OwnerLP is Ownable { + /** + * @notice Owner can transfer out any ERC20 token. + */ + function transferToken(address token, address to, uint256 amount) external onlyOwner { + IERC20(token).transfer(to, amount); + } +} diff --git a/src/contracts/PeggedARM.sol b/src/contracts/PeggedARM.sol index a8c094b..0d70326 100644 --- a/src/contracts/PeggedARM.sol +++ b/src/contracts/PeggedARM.sol @@ -1,161 +1,49 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import {OwnableOperable} from "./OwnableOperable.sol"; +import {AbstractARM} from "./AbstractARM.sol"; import {IERC20} from "./Interfaces.sol"; -contract PeggedARM is OwnableOperable { - /// @notice The swap input token that is transferred to this contract. - /// From a User perspective, this is the token being sold. - /// token0 is also compatible with the Uniswap V2 Router interface. - IERC20 public immutable token0; - /// @notice The swap output token that is transferred from this contract. - /// From a User perspective, this is the token being bought. - /// token1 is also compatible with the Uniswap V2 Router interface. - IERC20 public immutable token1; +abstract contract PeggedARM is AbstractARM { + /// @notice If true, the ARM contract can swap in both directions between token0 and token1. + bool public immutable bothDirections; - constructor(address _inputToken, address _outputToken1) { - require(IERC20(_inputToken).decimals() == 18); - require(IERC20(_outputToken1).decimals() == 18); - - token0 = IERC20(_inputToken); - token1 = IERC20(_outputToken1); - - _setOwner(address(0)); // Revoke owner for implementation contract at deployment - } - - /** - * @notice Swaps an exact amount of input tokens for as many output tokens as possible. - * msg.sender should have already given the ARM contract an allowance of - * at least amountIn on the input token. - * - * @param inToken Input token. - * @param outToken Output token. - * @param amountIn The amount of input tokens to send. - * @param amountOutMin The minimum amount of output tokens that must be received for the transaction not to revert. - * @param to Recipient of the output tokens. - */ - function swapExactTokensForTokens( - IERC20 inToken, - IERC20 outToken, - uint256 amountIn, - uint256 amountOutMin, - address to - ) external { - require(amountIn >= amountOutMin, "ARM: Insufficient output amount"); - _swap(inToken, outToken, amountIn, to); + constructor(bool _bothDirections) { + bothDirections = _bothDirections; } - /** - * @notice Uniswap V2 Router compatible interface. Swaps an exact amount of - * input tokens for as many output tokens as possible. - * msg.sender should have already given the ARM contract an allowance of - * at least amountIn on the input token. - * - * @param amountIn The amount of input tokens to send. - * @param amountOutMin The minimum amount of output tokens that must be received for the transaction not to revert. - * @param path The input and output token addresses. - * @param to Recipient of the output tokens. - * @param deadline Unix timestamp after which the transaction will revert. - * @return amounts The input and output token amounts. - */ - function swapExactTokensForTokens( - uint256 amountIn, - uint256 amountOutMin, - address[] calldata path, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts) { - require(amountIn >= amountOutMin, "ARM: Insufficient output amount"); - require(path.length == 2, "ARM: Invalid path length"); - _inDeadline(deadline); - - IERC20 inToken = IERC20(path[0]); - IERC20 outToken = IERC20(path[1]); - - _swap(inToken, outToken, amountIn, to); - - // Swaps are 1:1 so the input amount is the output amount - amounts = new uint256[](2); - amounts[0] = amountIn; - amounts[1] = amountIn; + function _swapExactTokensForTokens(IERC20 inToken, IERC20 outToken, uint256 amountIn, address to) + internal + override + returns (uint256 amountOut) + { + return _swap(inToken, outToken, amountIn, to); } - /** - * @notice Receive an exact amount of output tokens for as few input tokens as possible. - * msg.sender should have already given the router an allowance of - * at least amountInMax on the input token. - * - * @param inToken Input token. - * @param outToken Output token. - * @param amountOut The amount of output tokens to receive. - * @param amountInMax The maximum amount of input tokens that can be required before the transaction reverts. - * @param to Recipient of the output tokens. - */ - function swapTokensForExactTokens( - IERC20 inToken, - IERC20 outToken, - uint256 amountOut, - uint256 amountInMax, - address to - ) external { - require(amountOut <= amountInMax, "ARM: Excess input amount"); - _swap(inToken, outToken, amountOut, to); + function _swapTokensForExactTokens(IERC20 inToken, IERC20 outToken, uint256 amountOut, address to) + internal + override + returns (uint256 amountIn) + { + return _swap(inToken, outToken, amountOut, to); } - /** - * @notice Uniswap V2 Router compatible interface. Receive an exact amount of - * output tokens for as few input tokens as possible. - * msg.sender should have already given the router an allowance of - * at least amountInMax on the input token. - * - * @param amountOut The amount of output tokens to receive. - * @param amountInMax The maximum amount of input tokens that can be required before the transaction reverts. - * @param path The input and output token addresses. - * @param to Recipient of the output tokens. - * @param deadline Unix timestamp after which the transaction will revert. - * @return amounts The input and output token amounts. - */ - function swapTokensForExactTokens( - uint256 amountOut, - uint256 amountInMax, - address[] calldata path, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts) { - require(amountOut <= amountInMax, "ARM: Excess input amount"); - require(path.length == 2, "ARM: Invalid path length"); - _inDeadline(deadline); - - IERC20 inToken = IERC20(path[0]); - IERC20 outToken = IERC20(path[1]); - - _swap(inToken, outToken, amountOut, to); - - // Swaps are 1:1 so the input amount is the output amount - amounts = new uint256[](2); - amounts[0] = amountOut; - amounts[1] = amountOut; - } - - function _swap(IERC20 inToken, IERC20 outToken, uint256 amount, address to) internal { - require(inToken == token0 && outToken == token1, "ARM: Invalid swap"); + function _swap(IERC20 inToken, IERC20 outToken, uint256 amount, address to) internal returns (uint256) { + if (bothDirections) { + require( + inToken == token0 && outToken == token1 || inToken == token1 && outToken == token0, "ARM: Invalid swap" + ); + } else { + require(inToken == token0 && outToken == token1, "ARM: Invalid swap"); + } // Transfer the input tokens from the caller to this ARM contract require(inToken.transferFrom(msg.sender, address(this), amount), "failed transfer in"); // Transfer the same amount of output tokens to the recipient require(outToken.transfer(to, amount), "failed transfer out"); - } - - function _inDeadline(uint256 deadline) internal view { - require(deadline >= block.timestamp, "ARM: Deadline expired"); - } - /** - * @notice Owner can transfer out any ERC20 token. - */ - function transferToken(address token, address to, uint256 amount) external onlyOwner { - IERC20(token).transfer(to, amount); + // 1:1 swaps so the exact amount is returned as the calculated amount + return amount; } } diff --git a/src/contracts/Proxy.sol b/src/contracts/Proxy.sol index 7e596f5..bad4745 100644 --- a/src/contracts/Proxy.sol +++ b/src/contracts/Proxy.sol @@ -88,7 +88,7 @@ contract Proxy is Ownable { * @notice Fallback function. * Implemented entirely in `_delegate`. */ - fallback() external { + fallback() external payable { _delegate(_implementation()); } diff --git a/src/contracts/README.md b/src/contracts/README.md index 6682dda..9b7fd2e 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -1,27 +1,51 @@ +## Proxy + +### Hierarchy + +![Proxy Hierarchy](../../docs/ProxyHierarchy.svg) + +## Squashed + +![Proxy Squashed](../../docs/ProxySquashed.svg) + +## Storage + +![Proxy Storage](../../docs/ProxyStorage.svg) + ## OETH ARM ### Hierarchy ![OETH ARM Hierarchy](../../docs/OEthARMHierarchy.svg) -## OETH ARM Squashed +## Squashed ![OETH ARM Squashed](../../docs/OEthARMSquashed.svg) -## OETH ARM Storage +## Storage ![OETH ARM Storage](../../docs/OEthARMStorage.svg) -## Proxy +## Lido ARM ### Hierarchy -![Proxy Hierarchy](../../docs/ProxyHierarchy.svg) +![Lido ARM Hierarchy](../../docs/LidoARMHierarchy.svg) -## Proxy Squashed +## Squashed -![Proxy Squashed](../../docs/ProxySquashed.svg) +![Lido ARM Squashed](../../docs/LidoARMSquashed.svg) -## Proxy Storage + + +## Cap Manager + +### Hierarchy + +![Cap Manager Hierarchy](../../docs/CapManagerHierarchy.svg) + +## Squashed + +![Cap Manager Squashed](../../docs/CapManagerSquashed.svg) diff --git a/src/contracts/ZapperLidoARM.sol b/src/contracts/ZapperLidoARM.sol new file mode 100644 index 0000000..945f46f --- /dev/null +++ b/src/contracts/ZapperLidoARM.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// Contracts +import {Ownable} from "./Ownable.sol"; + +// Interfaces +import {IWETH} from "./Interfaces.sol"; +import {IERC20} from "./Interfaces.sol"; +import {ILiquidityProviderARM} from "./Interfaces.sol"; + +/** + * @title Zapper contract for the Lido (stETH) Automated Redemption Manager (ARM) + * Converts ETH to WETH and deposits it to the Lido ARM to receive ARM LP shares. + * @author Origin Protocol Inc + */ +contract ZapperLidoARM is Ownable { + IWETH public immutable weth; + /// @notice The address of the Lido ARM contract + ILiquidityProviderARM public immutable lidoArm; + + event Zap(address indexed sender, uint256 assets, uint256 shares); + + constructor(address _weth, address _lidoArm) { + weth = IWETH(_weth); + lidoArm = ILiquidityProviderARM(_lidoArm); + + weth.approve(_lidoArm, type(uint256).max); + } + + /// @notice Deposit ETH to LidoARM and receive ARM LP shares + receive() external payable { + deposit(); + } + + /// @notice Deposit ETH to LidoARM and receive shares + /// @return shares The amount of ARM LP shares sent to the depositor + function deposit() public payable returns (uint256 shares) { + // Wrap all ETH to WETH + uint256 ethBalance = address(this).balance; + weth.deposit{value: ethBalance}(); + + // Deposit all WETH to LidoARM + shares = lidoArm.deposit(ethBalance, msg.sender); + + // Emit event + emit Zap(msg.sender, ethBalance, shares); + } + + /// @notice Rescue ERC20 tokens + /// @param token The address of the ERC20 token + function rescueERC20(address token, uint256 amount) external onlyOwner { + IERC20(token).transfer(msg.sender, amount); + } +} diff --git a/src/contracts/utils/Addresses.sol b/src/contracts/utils/Addresses.sol index b8f3388..2ab88ed 100644 --- a/src/contracts/utils/Addresses.sol +++ b/src/contracts/utils/Addresses.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import "forge-std/console.sol"; +import {console} from "forge-std/console.sol"; library Common { address public constant ZERO = address(0); @@ -12,19 +12,30 @@ library Mainnet { address public constant TIMELOCK = 0x35918cDE7233F2dD33fA41ae3Cb6aE0e42E0e69F; address public constant GOVERNOR_FIVE = 0x3cdD07c16614059e66344a7b579DAB4f9516C0b6; address public constant GOVERNOR_SIX = 0x1D3Fbd4d129Ddd2372EA85c5Fa00b2682081c9EC; + address public constant STRATEGIST = 0xF14BBdf064E3F67f51cd9BD646aE3716aD938FDC; + address public constant TREASURY = 0x70fCE97d671E81080CA3ab4cc7A59aAc2E117137; // Multisig and EOAs address public constant INITIAL_DEPLOYER = address(0x1001); address public constant GOV_MULTISIG = 0xbe2AB3d3d8F6a32b96414ebbd865dBD276d3d899; - address public constant RELAYER = 0x4b91827516f79d6F6a1F292eD99671663b09169a; + address public constant ARM_MULTISIG = 0xC8F2cF4742C86295653f893214725813B16f7410; + address public constant OETH_RELAYER = 0x4b91827516f79d6F6a1F292eD99671663b09169a; + address public constant ARM_RELAYER = 0x39878253374355DBcc15C86458F084fb6f2d6DE7; // Tokens address public constant OETH = 0x856c4Efb76C1D1AE02e20CEB03A2A6a08b0b8dC3; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address public constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + address public constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; // Contracts address public constant OETH_VAULT = 0x39254033945AA2E4809Cc2977E7087BEE48bd7Ab; address public constant OETH_ARM = 0x6bac785889A4127dB0e0CeFEE88E0a9F1Aaf3cC7; + address public constant LIDO_ARM = 0x85B78AcA6Deae198fBF201c82DAF6Ca21942acc6; + address public constant ARM_BUYBACK = 0xBa0E6d6ea72cDc0D6f9fCdcc04147c671BA83dB5; + + // Lido + address public constant LIDO_WITHDRAWAL = 0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1; } library Holesky { @@ -59,15 +70,18 @@ contract AddressResolver { resolver[MAINNET]["GOVERNOR"] = Mainnet.TIMELOCK; resolver[MAINNET]["GOVERNANCE"] = Mainnet.GOVERNOR_SIX; resolver[MAINNET]["GOV_MULTISIG"] = Mainnet.GOV_MULTISIG; - resolver[MAINNET]["OPERATOR"] = Mainnet.RELAYER; + resolver[MAINNET]["OPERATOR"] = Mainnet.OETH_RELAYER; // Tokens resolver[MAINNET]["OETH"] = Mainnet.OETH; resolver[MAINNET]["WETH"] = Mainnet.WETH; + resolver[MAINNET]["STETH"] = Mainnet.STETH; + resolver[MAINNET]["WSTETH"] = Mainnet.WSTETH; // Contracts resolver[MAINNET]["OETH_VAULT"] = Mainnet.OETH_VAULT; resolver[MAINNET]["OETH_ARM"] = Mainnet.OETH_ARM; + resolver[MAINNET]["LIDO_ARM"] = Mainnet.LIDO_ARM; // Test accounts resolver[MAINNET]["INITIAL_DEPLOYER"] = address(0x1001); diff --git a/src/contracts/utils/GovSixHelper.sol b/src/contracts/utils/GovSixHelper.sol index 0b964a0..cfa1761 100644 --- a/src/contracts/utils/GovSixHelper.sol +++ b/src/contracts/utils/GovSixHelper.sol @@ -5,7 +5,7 @@ import {AddressResolver} from "./Addresses.sol"; import {IGovernance} from "../Interfaces.sol"; import {Vm} from "forge-std/Vm.sol"; -import "forge-std/console.sol"; +import {console} from "forge-std/console.sol"; struct GovAction { address target; diff --git a/src/contracts/utils/Initializable.sol b/src/contracts/utils/Initializable.sol deleted file mode 100644 index efb2d37..0000000 --- a/src/contracts/utils/Initializable.sol +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; - -/** - * @title Base contract any contracts that need to initialize state after deployment. - * @author Origin Protocol Inc - */ -abstract contract Initializable { - /** - * @dev Indicates that the contract has been initialized. - */ - bool private initialized; - - /** - * @dev Indicates that the contract is in the process of being initialized. - */ - bool private initializing; - - /** - * @dev Modifier to protect an initializer function from being invoked twice. - */ - modifier initializer() { - require(initializing || !initialized, "Initializable: contract is already initialized"); - - bool isTopLevelCall = !initializing; - if (isTopLevelCall) { - initializing = true; - initialized = true; - } - - _; - - if (isTopLevelCall) { - initializing = false; - } - } - - uint256[50] private gap; -} diff --git a/src/js/tasks/lido.js b/src/js/tasks/lido.js new file mode 100644 index 0000000..76b2deb --- /dev/null +++ b/src/js/tasks/lido.js @@ -0,0 +1,309 @@ +const { formatUnits, parseUnits, MaxInt256 } = require("ethers"); + +const addresses = require("../utils/addresses"); +const { + logArmPrices, + log1InchPrices, + logCurvePrices, + logUniswapSpotPrices, +} = require("./markets"); +const { getBlock } = require("../utils/block"); +const { getSigner } = require("../utils/signers"); +const { logTxDetails } = require("../utils/txLogger"); +const { + parseAddress, + parseDeployedAddress, +} = require("../utils/addressParser"); +const { resolveAddress, resolveAsset } = require("../utils/assets"); + +const log = require("../utils/logger")("task:lido"); + +async function collectFees() { + const signer = await getSigner(); + + const lidoArmAddress = await parseDeployedAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", lidoArmAddress); + + log(`About to collect fees from the Lido ARM`); + const tx = await lidoARM.connect(signer).collectFees(); + await logTxDetails(tx, "collectFees"); +} + +async function requestLidoWithdrawals({ amount }) { + const signer = await getSigner(); + + const lidoArmAddress = await parseDeployedAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", lidoArmAddress); + + const amountBI = parseUnits(amount.toString(), 18); + + log(`About to request the withdrawal of ${amount} stETH from Lido`); + const tx = await lidoARM.connect(signer).requestLidoWithdrawals([amountBI]); + await logTxDetails(tx, "requestLidoWithdrawals"); +} + +async function claimLidoWithdrawals({ id }) { + const signer = await getSigner(); + + const lidoArmAddress = await parseDeployedAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", lidoArmAddress); + + log(`About to claim the withdrawal with ${id} from Lido`); + const tx = await lidoARM.connect(signer).claimLidoWithdrawals([id]); + await logTxDetails(tx, "claimLidoWithdrawals"); +} + +async function setZapper() { + const signer = await getSigner(); + + const lidoArmAddress = await parseDeployedAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", lidoArmAddress); + + const zapperAddress = await parseDeployedAddress("LIDO_ARM_ZAPPER"); + + log(`About to set the Zapper contract on the Lido ARM to ${zapperAddress}`); + const tx = await lidoARM.connect(signer).setZap(zapperAddress); + await logTxDetails(tx, "setZap"); +} + +const lidoWithdrawStatus = async ({ id }) => { + const lidoWithdrawalQueueAddress = await parseAddress("LIDO_WITHDRAWAL"); + const stEthWithdrawQueue = await hre.ethers.getContractAt( + "IStETHWithdrawal", + lidoWithdrawalQueueAddress + ); + + const status = await stEthWithdrawQueue.getWithdrawalStatus([id]); + + console.log( + `Withdrawal request ${id} is finalized ${status[0].isFinalized} and claimed ${status[0].isClaimed}` + ); +}; + +const submitLido = async ({ amount }) => { + const signer = await getSigner(); + + const stethAddress = await parseAddress("STETH"); + // const steth = await ethers.getContractAt("ISTETH", stethAddress); + + const etherAmount = parseUnits(amount.toString()); + + log(`About to send ${amount} ETH to Lido's stETH`); + const tx = await signer.sendTransaction({ + to: stethAddress, + value: etherAmount, + }); + // const tx = await steth.connect(signer)({ value: etherAmount }); + await logTxDetails(tx, "submit"); +}; + +const snapLido = async ({ amount, block, curve, oneInch, uniswap, gas }) => { + const blockTag = await getBlock(block); + const commonOptions = { amount, blockTag, pair: "stETH/ETH", gas }; + + const armAddress = await parseAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", armAddress); + const capManagerAddress = await parseDeployedAddress("LIDO_ARM_CAP_MAN"); + const capManager = await ethers.getContractAt( + "CapManager", + capManagerAddress + ); + + const ammPrices = await logArmPrices(commonOptions, lidoARM); + + if (oneInch) { + await log1InchPrices(commonOptions, ammPrices); + } + + if (curve) { + await logCurvePrices( + { + ...commonOptions, + poolName: "Old", + poolAddress: addresses.mainnet.CurveStEthPool, + }, + ammPrices + ); + + await logCurvePrices( + { + ...commonOptions, + poolName: "NextGen", + poolAddress: addresses.mainnet.CurveNgStEthPool, + }, + ammPrices + ); + } + + if (uniswap) { + await logUniswapSpotPrices(commonOptions, ammPrices); + } + + const { totalAssets, totalSupply, liquidityWeth } = await logAssets( + lidoARM, + blockTag + ); + await logWithdrawalQueue(lidoARM, blockTag, liquidityWeth); + await logUser(lidoARM, capManager, blockTag, totalSupply); + + const feesAccrued = await lidoARM.feesAccrued({ blockTag }); + const totalAssetsCap = await capManager.totalAssetsCap({ blockTag }); + const capRemaining = totalAssetsCap - totalAssets; + const capUsedPercent = (totalAssets * 10000n) / totalAssetsCap; + + console.log(`\nCaps`); + console.log( + `${formatUnits(totalAssetsCap, 18)} total assets cap, ${formatUnits( + capUsedPercent, + 2 + )}% used, ${formatUnits(capRemaining, 18)} remaining` + ); + console.log(`${formatUnits(feesAccrued, 18)} in accrued performance fees`); +}; + +const logUser = async (arm, capManager, blockTag, totalSupply) => { + const user = await getSigner(); + console.log(`\nUser ${await user.getAddress()}`); + + const shares = await arm.balanceOf(user.getAddress(), { blockTag }); + const sharesPercentage = (shares * 10000n) / totalSupply; + const userCap = await capManager.liquidityProviderCaps(user.getAddress(), { + blockTag, + }); + + console.log( + `${formatUnits(shares, 18)} shares (${formatUnits(sharesPercentage, 2)}%)` + ); + console.log(`${formatUnits(userCap, 18)} cap remaining`); +}; + +const logWithdrawalQueue = async (arm, blockTag, liquidityWeth) => { + const queue = await arm.withdrawsQueued({ + blockTag, + }); + const claimed = await arm.withdrawsClaimed({ blockTag }); + const outstanding = queue - claimed; + const shortfall = + liquidityWeth < outstanding ? liquidityWeth - outstanding : 0; + + console.log(`\nARM Withdrawal Queue`); + console.log(`${formatUnits(outstanding, 18)} outstanding`); + console.log(`${formatUnits(shortfall, 18)} shortfall`); +}; + +const logAssets = async (arm, blockTag) => { + const weth = await resolveAsset("WETH"); + const liquidityWeth = await weth.balanceOf(arm.getAddress(), { blockTag }); + + const steth = await resolveAsset("STETH"); + const liquiditySteth = await steth.balanceOf(arm.getAddress(), { blockTag }); + const liquidityLidoWithdraws = await arm.lidoWithdrawalQueueAmount({ + blockTag, + }); + + const total = liquidityWeth + liquiditySteth + liquidityLidoWithdraws; + const wethPercent = total == 0 ? 0 : (liquidityWeth * 10000n) / total; + const stethWithdrawsPercent = + total == 0 ? 0 : (liquidityLidoWithdraws * 10000n) / total; + const oethPercent = total == 0 ? 0 : (liquiditySteth * 10000n) / total; + const totalAssets = await arm.totalAssets({ blockTag }); + const totalSupply = await arm.totalSupply({ blockTag }); + const assetPerShare = await arm.convertToAssets(parseUnits("1"), { + blockTag, + }); + + console.log(`\nAssets`); + console.log( + `${formatUnits(liquidityWeth, 18).padEnd(23)} WETH ${formatUnits( + wethPercent, + 2 + )}%` + ); + console.log( + `${formatUnits(liquiditySteth, 18).padEnd(23)} stETH ${formatUnits( + oethPercent, + 2 + )}%` + ); + console.log( + `${formatUnits(liquidityLidoWithdraws, 18).padEnd( + 23 + )} Lido withdraw ${formatUnits(stethWithdrawsPercent, 2)}%` + ); + console.log(`${formatUnits(total, 18).padEnd(23)} total WETH and stETH`); + console.log(`${formatUnits(totalAssets, 18).padEnd(23)} total assets`); + console.log(`${formatUnits(totalSupply, 18).padEnd(23)} total supply`); + console.log(`${formatUnits(assetPerShare, 18).padEnd(23)} asset per share`); + + return { totalAssets, totalSupply, liquidityWeth }; +}; + +const swapLido = async ({ from, to, amount }) => { + if (from && to) { + throw new Error( + `Cannot specify both from and to asset. It has to be one or the other` + ); + } + const signer = await getSigner(); + const signerAddress = await signer.getAddress(); + + const armAddress = await parseAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", armAddress); + + if (from) { + const fromAddress = await resolveAddress(from.toUpperCase()); + + const to = from === "stETH" ? "WETH" : "stETH"; + const toAddress = await resolveAddress(to.toUpperCase()); + + const fromAmount = parseUnits(amount.toString(), 18); + + log(`About to swap ${amount} ${from} to ${to} for ${signerAddress}`); + + const tx = await lidoARM + .connect(signer) + ["swapExactTokensForTokens(address,address,uint256,uint256,address)"]( + fromAddress, + toAddress, + fromAmount, + 0, + signerAddress + ); + + await logTxDetails(tx, "swap exact from"); + } else if (to) { + const from = to === "stETH" ? "WETH" : "stETH"; + const fromAddress = await resolveAddress(from.toUpperCase()); + + const toAddress = await resolveAddress(to.toUpperCase()); + + const toAmount = parseUnits(amount.toString(), 18); + + log(`About to swap ${from} to ${amount} ${to} for ${signerAddress}`); + + const tx = await lidoARM + .connect(signer) + ["swapTokensForExactTokens(address,address,uint256,uint256,address)"]( + fromAddress, + toAddress, + toAmount, + MaxInt256, + signerAddress + ); + + await logTxDetails(tx, "swap exact to"); + } else { + throw new Error(`Must specify either from or to asset`); + } +}; + +module.exports = { + collectFees, + requestLidoWithdrawals, + claimLidoWithdrawals, + lidoWithdrawStatus, + submitLido, + swapLido, + snapLido, + setZapper, +}; diff --git a/src/js/tasks/liquidity.js b/src/js/tasks/liquidity.js index fea79b2..a663f1b 100644 --- a/src/js/tasks/liquidity.js +++ b/src/js/tasks/liquidity.js @@ -1,5 +1,6 @@ const { formatUnits, parseUnits } = require("ethers"); +const { getBlock } = require("../utils/block"); const { parseAddress } = require("../utils/addressParser"); const { resolveAsset } = require("../utils/assets"); const { @@ -100,19 +101,19 @@ const withdrawRequestStatus = async ({ id, oethARM, vault }) => { } }; -const logLiquidity = async () => { +const logLiquidity = async ({ block }) => { + const blockTag = await getBlock(block); console.log(`\nLiquidity`); const oethArmAddress = await parseAddress("OETH_ARM"); - const oethARM = await ethers.getContractAt("OethARM", oethArmAddress); const weth = await resolveAsset("WETH"); - const liquidityWeth = await weth.balanceOf(await oethARM.getAddress()); + const liquidityWeth = await weth.balanceOf(oethArmAddress, { blockTag }); const oeth = await resolveAsset("OETH"); - const liquidityOeth = await oeth.balanceOf(await oethARM.getAddress()); + const liquidityOeth = await oeth.balanceOf(oethArmAddress, { blockTag }); const liquidityOethWithdraws = await outstandingWithdrawalAmount({ - withdrawer: await oethARM.getAddress(), + withdrawer: oethArmAddress, }); const total = liquidityWeth + liquidityOeth + liquidityOethWithdraws; diff --git a/src/js/tasks/liquidityProvider.js b/src/js/tasks/liquidityProvider.js new file mode 100644 index 0000000..c9034e3 --- /dev/null +++ b/src/js/tasks/liquidityProvider.js @@ -0,0 +1,93 @@ +const { parseUnits } = require("ethers"); + +const { getSigner } = require("../utils/signers"); +const { parseDeployedAddress } = require("../utils/addressParser"); +const { logTxDetails } = require("../utils/txLogger"); + +const log = require("../utils/logger")("task:lpCap"); + +async function depositLido({ amount, asset }) { + const signer = await getSigner(); + + const amountBn = parseUnits(amount.toString()); + + if (asset == "WETH") { + const lidoArmAddress = await parseDeployedAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", lidoArmAddress); + + log(`About to deposit ${amount} WETH to the Lido ARM`); + const tx = await lidoARM.connect(signer).deposit(amountBn); + await logTxDetails(tx, "deposit"); + } else if (asset == "ETH") { + const zapperAddress = await parseDeployedAddress("LIDO_ARM_ZAPPER"); + const zapper = await ethers.getContractAt("ZapperLidoARM", zapperAddress); + + log(`About to deposit ${amount} ETH to the Lido ARM via the Zapper`); + const tx = await zapper.connect(signer).deposit({ value: amountBn }); + await logTxDetails(tx, "zap deposit"); + } +} + +async function requestRedeemLido({ amount }) { + const signer = await getSigner(); + + const amountBn = parseUnits(amount.toString()); + + const lidoArmAddress = await parseDeployedAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", lidoArmAddress); + + log(`About to request a redeem of ${amount} Lido ARM LP tokens`); + const tx = await lidoARM.connect(signer).requestRedeem(amountBn); + await logTxDetails(tx, "requestRedeem"); +} + +async function claimRedeemLido({ id }) { + const signer = await getSigner(); + + const lidoArmAddress = await parseDeployedAddress("LIDO_ARM"); + const lidoARM = await ethers.getContractAt("LidoARM", lidoArmAddress); + + log(`About to claim request with id ${id} from the Lido ARM`); + const tx = await lidoARM.connect(signer).claimRedeem(id); + await logTxDetails(tx, "claimRedeem"); +} + +async function setLiquidityProviderCaps({ accounts, cap }) { + const signer = await getSigner(); + + const capBn = parseUnits(cap.toString()); + + const liquidityProviders = accounts.split(","); + + const lpcAddress = await parseDeployedAddress("LIDO_ARM_CAP_MAN"); + const capManager = await ethers.getContractAt("CapManager", lpcAddress); + + log( + `About to set deposit cap of ${cap} WETH for liquidity providers ${liquidityProviders}` + ); + const tx = await capManager + .connect(signer) + .setLiquidityProviderCaps(liquidityProviders, capBn); + await logTxDetails(tx, "setLiquidityProviderCaps"); +} + +async function setTotalAssetsCap({ cap }) { + const signer = await getSigner(); + + const capBn = parseUnits(cap.toString()); + + const lpcAddress = await parseDeployedAddress("LIDO_ARM_CAP_MAN"); + const capManager = await ethers.getContractAt("CapManager", lpcAddress); + + log(`About to set total asset cap of ${cap} WETH`); + const tx = await capManager.connect(signer).setTotalAssetsCap(capBn); + await logTxDetails(tx, "setTotalAssetsCap"); +} + +module.exports = { + depositLido, + requestRedeemLido, + claimRedeemLido, + setLiquidityProviderCaps, + setTotalAssetsCap, +}; diff --git a/src/js/tasks/markets.js b/src/js/tasks/markets.js new file mode 100644 index 0000000..a305b16 --- /dev/null +++ b/src/js/tasks/markets.js @@ -0,0 +1,245 @@ +const { formatUnits, parseUnits } = require("ethers"); + +const addresses = require("../utils/addresses"); +const { get1InchPrices } = require("../utils/1Inch"); +const { getCurvePrices } = require("../utils/curve"); +const { getUniswapV3SpotPrices } = require("../utils/uniswap"); +const { getSigner } = require("../utils/signers"); + +const log = require("../utils/logger")("task:markets"); + +const logArmPrices = async ({ blockTag, gas }, 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 + const OWethStEthRate = await arm.traderate0({ blockTag }); + console.log(`traderate0: ${formatUnits(OWethStEthRate, 36)} WETH/stETH`); + + // convert from WETH/stETH rate with 36 decimals to stETH/WETH rate with 18 decimals + const sellPrice = BigInt(1e54) / BigInt(OWethStEthRate); + + // The rate of 1 stETH for WETH to 36 decimals. ie stETH/WETH + const OStEthWethRate = await arm.traderate1({ blockTag }); + console.log(`traderate1: ${formatUnits(OStEthWethRate, 36)} stETH/WETH`); + // Convert back to 18 decimals + const buyPrice = BigInt(OStEthWethRate) / BigInt(1e18); + + const midPrice = (sellPrice + buyPrice) / 2n; + + const crossPrice = await arm.crossPrice({ blockTag }); + + let buyGasCosts = ""; + let sellGasCosts = ""; + if (gas) { + const signer = await getSigner(); + const amountBI = parseUnits("0.01", 18); + try { + const buyGas = await arm + .connect(signer) + [ + "swapExactTokensForTokens(address,address,uint256,uint256,address)" + ].estimateGas( + addresses.mainnet.WETH, + addresses.mainnet.stETH, + amountBI, + 0, + addresses.dead, + { + blockTag, + } + ); + buyGasCosts = `, ${buyGas.toLocaleString()} gas`; + } catch (e) { + log(`Failed to estimate buy gas for swap: ${e.message}`); + } + try { + const sellGas = await arm + .connect(signer) + [ + "swapExactTokensForTokens(address,address,uint256,uint256,address)" + ].estimateGas( + addresses.mainnet.stETH, + addresses.mainnet.WETH, + amountBI, + 0, + addresses.dead, + { + blockTag, + } + ); + sellGasCosts = `, ${sellGas.toLocaleString()} gas`; + } catch (e) { + log(`Failed to estimate sell gas for swap: ${e.message}`); + } + } + + console.log( + `sell : ${formatUnits(sellPrice, 18).padEnd( + 20 + )} stETH/WETH${sellGasCosts}` + ); + if (crossPrice > sellPrice) { + console.log( + `cross : ${formatUnits(crossPrice, 36).padEnd(20)} stETH/WETH` + ); + console.log(`mid : ${formatUnits(midPrice, 18).padEnd(20)} stETH/WETH`); + } else { + console.log(`mid : ${formatUnits(midPrice, 18).padEnd(20)} stETH/WETH`); + console.log( + `cross : ${formatUnits(crossPrice, 18).padEnd(20)} stETH/WETH` + ); + } + console.log( + `buy : ${formatUnits(buyPrice, 18).padEnd(20)} stETH/WETH${buyGasCosts}` + ); + + const spread = BigInt(sellPrice) - BigInt(buyPrice); + // Origin rates are to 36 decimals + console.log(`spread : ${formatUnits(spread, 14)} bps`); + + return { + buyPrice, + sellPrice, + midPrice, + spread, + }; +}; + +const log1InchPrices = async ({ amount, gas }, ammPrices) => { + const oneInch = await get1InchPrices(amount); + + log(`buy ${formatUnits(oneInch.buyToAmount)} stETH for ${amount} WETH`); + log(`sell ${amount} stETH for ${formatUnits(oneInch.sellToAmount)} WETH`); + + console.log(`\n1Inch prices for swap size ${amount}`); + const buyRateDiff = oneInch.buyPrice - ammPrices.buyPrice; + const buyGasCosts = gas ? `, ${oneInch.buyGas.toLocaleString()} gas` : ""; + console.log( + `buy : ${formatUnits(oneInch.buyPrice, 18).padEnd( + 20 + )} stETH/WETH, diff ${formatUnits(buyRateDiff, 14).padEnd( + 17 + )} bps to ARM${buyGasCosts}` + ); + + const midRateDiff = oneInch.midPrice - ammPrices.midPrice; + console.log( + `mid : ${formatUnits(oneInch.midPrice, 18).padEnd( + 20 + )} stETH/WETH, diff ${formatUnits(midRateDiff, 14).padEnd(17)} bps to ARM` + ); + + const sellRateDiff = oneInch.sellPrice - ammPrices.sellPrice; + const sellGasCosts = gas ? `, ${oneInch.sellGas.toLocaleString()} gas` : ""; + console.log( + `sell : ${formatUnits(oneInch.sellPrice, 18).padEnd( + 20 + )} stETH/WETH, diff ${formatUnits(sellRateDiff, 14).padEnd( + 17 + )} bps to ARM${sellGasCosts}` + ); + console.log(`spread : ${formatUnits(oneInch.spread, 14)} bps`); + + console.log(`buy path for stETH/WETH`); + log1InchProtocols(oneInch.buyQuote); + + console.log(`sell path for stETH/WETH`); + log1InchProtocols(oneInch.sellQuote); + + return oneInch; +}; + +const log1InchProtocols = (sellQuote) => { + // TODO need to better handle + sellQuote.protocols.forEach((p1) => { + p1.forEach((p2) => { + p2.forEach((p3) => { + console.log( + `${p3.part.toString().padEnd(3)}% ${p3.name.padEnd(12)} ${ + p3.fromTokenAddress + } -> ${p3.toTokenAddress}` + ); + }); + }); + }); +}; + +const logCurvePrices = async (options, ammPrices) => { + const { amount, pair, poolName, gas } = options; + + const curve = await getCurvePrices(options); + const buyRateDiff = curve.buyPrice - ammPrices.buyPrice; + const midRateDiff = curve.midPrice - ammPrices.midPrice; + const sellRateDiff = curve.sellPrice - ammPrices.sellPrice; + + log(`buy ${formatUnits(curve.buyToAmount)} stETH for ${amount} WETH`); + log(`sell ${amount} stETH for ${formatUnits(curve.sellToAmount)} WETH`); + + console.log(`\n${poolName} Curve prices for swap size ${amount}`); + const buyGasCosts = gas ? `, ${curve.buyGas.toLocaleString()} gas` : ""; + const sellGasCosts = gas ? `, ${curve.sellGas.toLocaleString()} gas` : ""; + console.log( + `buy : ${formatUnits(curve.buyPrice, 18).padEnd( + 20 + )} ${pair}, diff ${formatUnits(buyRateDiff, 14).padEnd( + 17 + )} bps to ARM${buyGasCosts}` + ); + console.log( + `mid : ${formatUnits(curve.midPrice, 18).padEnd( + 20 + )} ${pair}, diff ${formatUnits(midRateDiff, 14).padEnd(17)} bps to ARM` + ); + console.log( + `sell : ${formatUnits(curve.sellPrice, 18).padEnd( + 20 + )} ${pair}, diff ${formatUnits(sellRateDiff, 1).padEnd( + 17 + )} bps to ARM${sellGasCosts}` + ); + console.log(`spread : ${formatUnits(curve.spread, 14)} bps`); + + return curve; +}; + +const logUniswapSpotPrices = async (options, ammPrices) => { + const { amount, pair, gas } = options; + const uniswap = await getUniswapV3SpotPrices(options); + const buyRateDiff = uniswap.buyPrice - ammPrices.buyPrice; + const midRateDiff = uniswap.midPrice - ammPrices.midPrice; + const sellRateDiff = uniswap.sellPrice - ammPrices.sellPrice; + + log(`buy ${formatUnits(uniswap.buyToAmount)} stETH for ${amount} WETH`); + log(`sell ${amount} stETH for ${formatUnits(uniswap.sellToAmount)} WETH`); + + console.log( + `\nwstETH/ETH 0.01% Uniswap V3 spot prices for swap size ${amount}` + ); + const buyGasCosts = gas ? `, ${uniswap.buyGas.toLocaleString()} gas` : ""; + const sellGasCosts = gas ? `, ${uniswap.sellGas.toLocaleString()} gas` : ""; + console.log( + `buy : ${formatUnits(uniswap.buyPrice, 18).padEnd( + 20 + )} ${pair}, diff ${formatUnits(buyRateDiff, 14)} bps to ARM${buyGasCosts}` + ); + console.log( + `mid : ${formatUnits(uniswap.midPrice, 18).padEnd( + 20 + )} ${pair}, diff ${formatUnits(midRateDiff, 14)} bps to ARM` + ); + console.log( + `sell : ${formatUnits(uniswap.sellPrice, 18).padEnd( + 20 + )} ${pair}, diff ${formatUnits(sellRateDiff, 14)} bps to ARM${sellGasCosts}` + ); + console.log(`spread : ${formatUnits(uniswap.spread, 14)} bps`); + + return uniswap; +}; + +module.exports = { + log1InchPrices, + logArmPrices, + logCurvePrices, + logUniswapSpotPrices, +}; diff --git a/src/js/tasks/tasks.js b/src/js/tasks/tasks.js index 122b575..15b302d 100644 --- a/src/js/tasks/tasks.js +++ b/src/js/tasks/tasks.js @@ -3,6 +3,16 @@ const { subtask, task, types } = require("hardhat/config"); const { parseAddress } = require("../utils/addressParser"); const { setAutotaskVars } = require("./autotask"); const { setActionVars } = require("./defender"); +const { + submitLido, + snapLido, + swapLido, + collectFees, + requestLidoWithdrawals, + claimLidoWithdrawals, + lidoWithdrawStatus, + setZapper, +} = require("./lido"); const { autoRequestWithdraw, autoClaimWithdraw, @@ -11,6 +21,13 @@ const { logLiquidity, withdrawRequestStatus, } = require("./liquidity"); +const { + depositLido, + requestRedeemLido, + claimRedeemLido, + setLiquidityProviderCaps, + setTotalAssetsCap, +} = require("./liquidityProvider"); const { swap } = require("./swap"); const { tokenAllowance, @@ -33,17 +50,34 @@ const { } = require("./vault"); const { upgradeProxy } = require("./proxy"); -subtask("snap", "Take a snapshot of the ARM").setAction(logLiquidity); +subtask("snap", "Take a snapshot of the OETH ARM") + .addOptionalParam( + "block", + "Block number. (default: latest)", + undefined, + types.int + ) + .setAction(logLiquidity); task("snap").setAction(async (_, __, runSuper) => { return runSuper(); }); subtask( "swap", - "Swap from one asset to another. Can only specify the from or to asset" + "Swap from one asset to another. Can only specify the from or to asset as that will be the exact amount." ) - .addOptionalParam("from", "Symbol of the from asset", "OETH", types.string) - .addOptionalParam("to", "Symbol of the to asset", undefined, types.string) + .addOptionalParam( + "from", + "Symbol of the from asset when swapping from an exact amount", + "OETH", + types.string + ) + .addOptionalParam( + "to", + "Symbol of the to asset when swapping to an exact amount", + undefined, + types.string + ) .addParam( "amount", "Swap quantity in either the from or to asset", @@ -55,6 +89,33 @@ task("swap").setAction(async (_, __, runSuper) => { return runSuper(); }); +subtask( + "swapLido", + "Swap from one asset to another. Can only specify the from or to asset as that will be the exact amount." +) + .addOptionalParam( + "from", + "Symbol of the from asset when swapping from an exact amount", + undefined, + types.string + ) + .addOptionalParam( + "to", + "Symbol of the to asset when swapping to an exact amount", + undefined, + types.string + ) + .addParam( + "amount", + "Swap quantity in either the from or to asset", + undefined, + types.float + ) + .setAction(swapLido); +task("swapLido").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + // Liquidity management subtask("autoRequestWithdraw", "Request withdrawal of WETH from the OETH Vault") @@ -304,6 +365,15 @@ task("withdrawWETH").setAction(async (_, __, runSuper) => { return runSuper(); }); +// Lido tasks + +subtask("submitLido", "Convert ETH to Lido's stETH") + .addParam("amount", "Amount of ETH to convert", undefined, types.float) + .setAction(submitLido); +task("submitLido").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + // Vault tasks. task( @@ -418,6 +488,136 @@ task("redeemAll").setAction(async (_, __, runSuper) => { return runSuper(); }); +// ARM Liquidity Provider Functions + +subtask( + "depositLido", + "Deposit WETH into the Lido ARM as receive ARM LP tokens" +) + .addParam( + "amount", + "Amount of WETH not scaled to 18 decimals", + undefined, + types.float + ) + .addOptionalParam( + "asset", + "Symbol of the asset to deposit. eg ETH or WETH", + "WETH", + types.string + ) + .setAction(depositLido); +task("depositLido").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("requestRedeemLido", "Request redeem from the Lido ARM") + .addParam( + "amount", + "Amount of ARM LP tokens not scaled to 18 decimals", + undefined, + types.float + ) + .setAction(requestRedeemLido); +task("requestRedeemLido").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("claimRedeemLido", "Claim WETH from a previously requested redeem") + .addParam("id", "Request identifier", undefined, types.float) + .setAction(claimRedeemLido); +task("claimRedeemLido").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("setLiquidityProviderCaps", "Set deposit cap for liquidity providers") + .addParam( + "cap", + "Amount of WETH not scaled to 18 decimals", + undefined, + types.float + ) + .addParam( + "accounts", + "Comma separated list of addresses", + undefined, + types.string + ) + .setAction(setLiquidityProviderCaps); +task("setLiquidityProviderCaps").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("setTotalAssetsCap", "Set total assets cap") + .addParam( + "cap", + "Amount of WETH not scaled to 18 decimals", + undefined, + types.float + ) + .setAction(setTotalAssetsCap); +task("setTotalAssetsCap").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +// Lido + +subtask( + "requestLidoWithdrawals", + "Collect the performance fees from the Lido ARM" +) + .addParam("amount", "stETH withdraw amount", undefined, types.float) + .setAction(requestLidoWithdrawals); +task("requestLidoWithdrawals").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("lidoClaimWithdraw", "Claim a requested withdrawal from Lido (stETH)") + .addParam("id", "Request identifier", undefined, types.string) + .setAction(claimLidoWithdrawals); +task("lidoClaimWithdraw").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("lidoWithdrawStatus", "Get the status of a Lido withdrawal request") + .addParam("id", "Request identifier", undefined, types.string) + .setAction(lidoWithdrawStatus); +task("lidoWithdrawStatus").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask( + "collectFees", + "Collect the performance fees from the Lido ARM" +).setAction(collectFees); +task("collectFees").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("setZapper", "Set the Zapper contract on the Lido ARM").setAction( + setZapper +); +task("setZapper").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("snapLido", "Take a snapshot of the Lido ARM") + .addOptionalParam( + "block", + "Block number. (default: latest)", + undefined, + types.int + ) + .addOptionalParam("amount", "Swap quantity", 100, types.int) + .addOptionalParam("oneInch", "Include 1Inch prices", true, types.boolean) + .addOptionalParam("curve", "Include Curve prices", true, types.boolean) + .addOptionalParam("uniswap", "Include Uniswap V3 prices", true, types.boolean) + .addOptionalParam("gas", "Include gas costs", true, types.boolean) + .setAction(snapLido); +task("snapLido").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + // Proxies subtask("upgradeProxy", "Upgrade a proxy contract to a new implementation") diff --git a/src/js/utils/1Inch.js b/src/js/utils/1Inch.js new file mode 100644 index 0000000..77ad4d4 --- /dev/null +++ b/src/js/utils/1Inch.js @@ -0,0 +1,122 @@ +const axios = require("axios"); +const { parseUnits } = require("ethers"); + +const addresses = require("../utils/addresses"); +const { sleep } = require("../utils/time"); + +const log = require("./logger")("utils:1inch"); + +const ONEINCH_API_ENDPOINT = "https://api.1inch.dev/swap/v5.2/1/quote"; + +/** + * Gets a swap quote from 1Inch's V5.2 swap API + * @param fromAsset The address of the asset to swap from. + * @param toAsset The address of the asset to swap to. + * @param fromAmount The unit amount of fromAsset to swap. eg 1.1 WETH = 1.1e18 + * See https://docs.1inch.io/docs/aggregation-protocol/api/swagger + */ +const get1InchSwapQuote = async ({ fromAsset, toAsset, fromAmount }) => { + const apiKey = process.env.ONEINCH_API_KEY; + if (!apiKey) { + throw Error( + "ONEINCH_API_KEY environment variable not set. Visit the 1Inch Dev Portal https://portal.1inch.dev/" + ); + } + + const params = { + src: fromAsset, + dst: toAsset, + amount: fromAmount.toString(), + allowPartialFill: true, + disableEstimate: true, + includeProtocols: true, + includeGas: true, + includeTokensInfo: false, + }; + log("swap API params: ", params); + + let retries = 3; + + while (retries > 0) { + try { + const response = await axios.get(ONEINCH_API_ENDPOINT, { + params, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.data.toAmount) { + console.error(response.data); + throw Error("response is missing toAmount"); + } + + log("swap API response data: %j", response.data); + + return response.data; + } catch (err) { + if (err.response) { + console.error("Response data : ", err.response.data); + console.error("Response status: ", err.response.status); + console.error("Response status: ", err.response.statusText); + } + if (err.response?.status == 429) { + retries = retries - 1; + console.error( + `Failed to get a 1Inch quote. Will try again in 2 seconds with ${retries} retries left` + ); + // Wait for 2s before next try + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + throw Error(`Call to 1Inch swap quote API failed: ${err.message}`); + } + } + + throw Error(`Call to 1Inch swap quote API failed: Rate-limited`); +}; + +const get1InchPrices = async (amount) => { + + const amountBI = parseUnits(amount.toString(), 18); + + const buyQuote = await get1InchSwapQuote({ + fromAsset: addresses.mainnet.WETH, + toAsset: addresses.mainnet.stETH, + fromAmount: amountBI, // WETH amount + }); + // stETH buy amount + const buyToAmount = BigInt(buyQuote.toAmount); + // stETH/ETH rate = ETH amount / stETH amount + const buyPrice = (amountBI * BigInt(1e18)) / buyToAmount; + + await sleep(800); + + const sellQuote = await get1InchSwapQuote({ + fromAsset: addresses.mainnet.stETH, + toAsset: addresses.mainnet.WETH, + fromAmount: amountBI, // stETH amount + }); + // WETH sell amount + const sellToAmount = BigInt(sellQuote.toAmount); + // stETH/WETH rate = WETH amount / stETH amount + const sellPrice = (sellToAmount * BigInt(1e18)) / amountBI; + + const midPrice = (buyPrice + sellPrice) / 2n; + const spread = buyPrice - sellPrice; + + return { + buyQuote, + buyToAmount, + buyPrice, + buyGas: buyQuote.gas, + sellQuote, + sellToAmount, + sellPrice, + sellGas: sellQuote.gas, + midPrice, + spread, + }; +}; + +module.exports = { get1InchSwapQuote, get1InchPrices }; diff --git a/src/js/utils/addressParser.js b/src/js/utils/addressParser.js index 810c6c3..ef80dd3 100644 --- a/src/js/utils/addressParser.js +++ b/src/js/utils/addressParser.js @@ -3,6 +3,33 @@ const { readFileSync } = require("fs"); const log = require("./logger")("utils:addressParser"); +const parseDeployedAddress = async (name) => { + const network = await ethers.provider.getNetwork(); + const chainId = network.chainId; + + const fileName = `./build/deployments-${chainId}.json`; + log(`Parsing deployed contract ${name} from ${fileName}.`); + try { + const data = await readFileSync(fileName, "utf-8"); + + // Parse the JSON data + const deploymentData = JSON.parse(data); + + if (!deploymentData?.contracts[name]) { + throw new Error(`Failed to find deployed address for ${name}.`); + } + + return deploymentData.contracts[name]; + } catch (err) { + throw new Error( + `Failed to parse deployed contract "${name}" from "${fileName}".`, + { + cause: err, + } + ); + } +}; + // Parse an address from the Solidity Addresses file const parseAddress = async (name) => { // parse from Addresses.sol file @@ -57,4 +84,5 @@ const parseAddress = async (name) => { module.exports = { parseAddress, + parseDeployedAddress, }; diff --git a/src/js/utils/addresses.js b/src/js/utils/addresses.js index 5bcae17..0695483 100644 --- a/src/js/utils/addresses.js +++ b/src/js/utils/addresses.js @@ -17,8 +17,18 @@ addresses.mainnet.OETHVaultProxy = "0x39254033945aa2e4809cc2977e7087bee48bd7ab"; // Tokens addresses.mainnet.WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; - +addresses.mainnet.stETH = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"; +addresses.mainnet.wstETH = "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"; addresses.mainnet.OethARM = "0x6bac785889A4127dB0e0CeFEE88E0a9F1Aaf3cC7"; +// AMMs +addresses.mainnet.CurveStEthPool = "0xDC24316b9AE028F1497c275EB9192a3Ea0f67022"; +addresses.mainnet.CurveNgStEthPool = + "0x21e27a5e5513d6e65c4f830167390997aa84843a"; +addresses.mainnet.UniswapV3Quoter = + "0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6"; +addresses.mainnet.UniswapV3stETHWETHPool = + "0x109830a1AAaD605BbF02a9dFA7B0B92EC2FB7dAa"; + module.exports = addresses; diff --git a/src/js/utils/block.js b/src/js/utils/block.js index 433ab07..a334c67 100644 --- a/src/js/utils/block.js +++ b/src/js/utils/block.js @@ -1,11 +1,11 @@ const log = require("../utils/logger")("task:block"); -// Get the block details like number and timestamp +// Get the block number const getBlock = async (block) => { - const blockDetails = await hre.ethers.provider.getBlock(block); - log(`block: ${blockDetails.number}`); + const blockTag = !block ? await hre.ethers.provider.getBlockNumber() : block; + log(`block: ${blockTag}`); - return blockDetails; + return blockTag; }; const logBlock = async (blockTag) => { diff --git a/src/js/utils/curve.js b/src/js/utils/curve.js new file mode 100644 index 0000000..e8f6515 --- /dev/null +++ b/src/js/utils/curve.js @@ -0,0 +1,69 @@ +const { parseUnits } = require("ethers"); + +const curvePoolAbi = require("../../abis/CurveStEthPool.json"); + +const getCurvePrices = async ({ amount, poolAddress, blockTag, gas }) => { + const pool = await ethers.getContractAt(curvePoolAbi, poolAddress); + + const amountBI = parseUnits(amount.toString(), 18); + + // Swap ETH for stETH + const buyToAmount = await pool["get_dy(int128,int128,uint256)"]( + 0, + 1, + amountBI, + { blockTag } + ); + // stETH/ETH rate = ETH amount / stETH amount + const buyPrice = (amountBI * BigInt(1e18)) / buyToAmount; + + // Swap stETH for ETH + const sellToAmount = await pool["get_dy(int128,int128,uint256)"]( + 1, + 0, + amountBI, + { blockTag } + ); + // stETH/WETH rate = WETH amount / stETH amount + const sellPrice = (sellToAmount * BigInt(1e18)) / amountBI; + + const midPrice = (buyPrice + sellPrice) / 2n; + const spread = buyPrice - sellPrice; + + if (!gas) { + return { + buyToAmount, + buyPrice, + sellToAmount, + sellPrice, + midPrice, + spread, + }; + } + + const buyGas = await pool["get_dy(int128,int128,uint256)"].estimateGas( + 0, + 1, + amountBI, + { blockTag } + ); + const sellGas = await pool["get_dy(int128,int128,uint256)"].estimateGas( + 1, + 0, + amountBI, + { blockTag } + ); + + return { + buyToAmount, + buyPrice, + buyGas, + sellToAmount, + sellPrice, + sellGas, + midPrice, + spread, + }; +}; + +module.exports = { getCurvePrices }; diff --git a/src/js/utils/uniswap.js b/src/js/utils/uniswap.js new file mode 100644 index 0000000..28c0f51 --- /dev/null +++ b/src/js/utils/uniswap.js @@ -0,0 +1,108 @@ +const { parseUnits } = require("ethers"); +const { ethers } = require("ethers"); + +const quoterAbi = require("../../abis/UniswapV3Quoter.json"); +const wstEthAbi = require("../../abis/wstETH.json"); +const addresses = require("./addresses"); +const { getSigner } = require("./signers"); + +const log = require("../utils/logger")("task:uniswap"); + +const getUniswapV3SpotPrices = async ({ amount, blockTag, gas }) => { + const signer = await getSigner(); + const quoter = new ethers.Contract( + addresses.mainnet.UniswapV3Quoter, + quoterAbi, + signer + ); + + const wstEth = new ethers.Contract( + addresses.mainnet.wstETH, + wstEthAbi, + signer + ); + + const amountBI = parseUnits(amount.toString(), 18); + + // Swap WETH for stETH + const wstEthAmount = await quoter + .connect(signer) + .quoteExactInputSingle.staticCall( + addresses.mainnet.WETH, + addresses.mainnet.wstETH, + 100, + amountBI, + 0, + { blockTag } + ); + const buyToAmount = await wstEth.getStETHByWstETH(wstEthAmount); + log(`buyToAmount: ${buyToAmount}`); + // stETH/ETH rate = ETH amount / stETH amount + const buyPrice = (amountBI * BigInt(1e18)) / buyToAmount; + + // Swap stETH for WETH + // Convert stETH to wstETH + const wstETHAmount = await wstEth.getWstETHByStETH(amountBI); + log(`wstETHAmount: ${wstETHAmount} ${typeof wstETHAmount}`); + // Convert wstETH to WETH + const sellToAmount = await quoter + .connect(signer) + .quoteExactInputSingle.staticCall( + addresses.mainnet.wstETH, + addresses.mainnet.WETH, + 100, + wstETHAmount, + 0, + { blockTag } + ); + // stETH/WETH rate = WETH amount / stETH amount + const sellPrice = (sellToAmount * BigInt(1e18)) / amountBI; + + const midPrice = (buyPrice + sellPrice) / 2n; + const spread = buyPrice - sellPrice; + + if (!gas) { + return { + buyToAmount, + buyPrice, + sellToAmount, + sellPrice, + midPrice, + spread, + }; + } + + const buyGas = await quoter + .connect(signer) + .quoteExactInputSingle.estimateGas( + addresses.mainnet.WETH, + addresses.mainnet.wstETH, + 100, + amountBI, + 0, + { blockTag } + ); + const sellGas = await quoter + .connect(signer) + .quoteExactInputSingle.estimateGas( + addresses.mainnet.wstETH, + addresses.mainnet.WETH, + 100, + amountBI, + 0, + { blockTag } + ); + + return { + buyToAmount, + buyPrice, + buyGas, + sellToAmount, + sellPrice, + sellGas, + midPrice, + spread, + }; +}; + +module.exports = { getUniswapV3SpotPrices }; diff --git a/test/Base.sol b/test/Base.sol index d0017e1..e120471 100644 --- a/test/Base.sol +++ b/test/Base.sol @@ -7,6 +7,9 @@ import {Test} from "forge-std/Test.sol"; // Contracts import {Proxy} from "contracts/Proxy.sol"; import {OethARM} from "contracts/OethARM.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {ZapperLidoARM} from "contracts/ZapperLidoARM.sol"; // Interfaces import {IERC20} from "contracts/Interfaces.sol"; @@ -29,24 +32,45 @@ abstract contract Base_Test_ is Test { /// --- CONTRACTS ////////////////////////////////////////////////////// Proxy public proxy; + Proxy public lpcProxy; + Proxy public lidoProxy; OethARM public oethARM; + LidoARM public lidoARM; + CapManager public capManager; + ZapperLidoARM public zapperLidoARM; + IERC20 public oeth; IERC20 public weth; + IERC20 public steth; + IERC20 public wsteth; + IERC20 public badToken; IOETHVault public vault; ////////////////////////////////////////////////////// /// --- Governance, multisigs and EOAs ////////////////////////////////////////////////////// address public alice; + address public bob; + address public charlie; + address public dave; + address public eve; + address public frank; + address public george; + address public harry; + address public deployer; address public governor; address public operator; address public oethWhale; + address public feeCollector; + address public lidoWithdraw; ////////////////////////////////////////////////////// /// --- DEFAULT VALUES ////////////////////////////////////////////////////// uint256 public constant DEFAULT_AMOUNT = 1 ether; + uint256 public constant MIN_TOTAL_SUPPLY = 1e12; + uint256 public constant STETH_ERROR_ROUNDING = 2; ////////////////////////////////////////////////////// /// --- SETUP @@ -54,4 +78,43 @@ abstract contract Base_Test_ is Test { function setUp() public virtual { resolver = new AddressResolver(); } + + /// @notice Better if called once all contract have been depoyed. + function labelAll() public virtual { + // Contracts + _labelNotNull(address(proxy), "DEFAULT PROXY"); + _labelNotNull(address(lpcProxy), "LPC PROXY"); + _labelNotNull(address(lidoProxy), "LIDO ARM PROXY"); + _labelNotNull(address(oethARM), "OETH ARM"); + _labelNotNull(address(lidoARM), "LIDO ARM"); + _labelNotNull(address(capManager), "CAP MANAGER"); + + _labelNotNull(address(oeth), "OETH"); + _labelNotNull(address(weth), "WETH"); + _labelNotNull(address(steth), "STETH"); + _labelNotNull(address(wsteth), " WRAPPED STETH"); + _labelNotNull(address(badToken), "BAD TOKEN"); + _labelNotNull(address(vault), "OETH VAULT"); + + // Governance, multisig and EOAs + _labelNotNull(alice, "Alice"); + _labelNotNull(bob, "Bob"); + _labelNotNull(charlie, "Charlie"); + _labelNotNull(dave, "Dave"); + _labelNotNull(eve, "Eve"); + _labelNotNull(frank, "Frank"); + _labelNotNull(george, "George"); + _labelNotNull(harry, "Harry"); + + _labelNotNull(deployer, "Deployer"); + _labelNotNull(governor, "Governor"); + _labelNotNull(operator, "Operator"); + _labelNotNull(oethWhale, "OETH Whale"); + _labelNotNull(feeCollector, "Fee Collector"); + _labelNotNull(lidoWithdraw, "Lido Withdraw"); + } + + function _labelNotNull(address _address, string memory _name) internal { + if (_address != address(0)) vm.label(_address, _name); + } } diff --git a/test/fork/LidoFixedPriceMultiLpARM/ClaimRedeem.t.sol b/test/fork/LidoFixedPriceMultiLpARM/ClaimRedeem.t.sol new file mode 100644 index 0000000..6b9c3d0 --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/ClaimRedeem.t.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; + +contract Fork_Concrete_LidoARM_ClaimRedeem_Test_ is Fork_Shared_Test_ { + bool private ac; + uint256 private delay; + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + + function setUp() public override { + super.setUp(); + + delay = lidoARM.claimDelay(); + + deal(address(weth), address(this), 1_000 ether); + + ac = capManager.accountCapEnabled(); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_ClaimRequest_Because_ClaimDelayNotMet() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + { + skip(delay - 1); + vm.expectRevert("Claim delay not met"); + lidoARM.claimRedeem(0); + } + + function test_RevertWhen_ClaimRequest_Because_QueuePendingLiquidity_NoLiquidity() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + { + // Remove all weth liquidity from ARM + deal(address(weth), address(lidoARM), 0); + + // Time jump claim delay + skip(delay); + + // Expect revert + vm.expectRevert("Queue pending liquidity"); + lidoARM.claimRedeem(0); + } + + function test_RevertWhen_ClaimRequest_Because_QueuePendingLiquidity_NoEnoughLiquidity() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + { + // Remove half of weth liquidity from ARM + uint256 halfAmount = weth.balanceOf(address(lidoARM)) / 2; + deal(address(weth), address(lidoARM), halfAmount); + + // Time jump claim delay + skip(delay); + + // Expect revert + vm.expectRevert("Queue pending liquidity"); + lidoARM.claimRedeem(0); + } + + function test_RevertWhen_ClaimRequest_Because_NotRequester() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + { + // Time jump claim delay + skip(delay); + + // Expect revert + vm.startPrank(vm.randomAddress()); + vm.expectRevert("Not requester"); + lidoARM.claimRedeem(0); + } + + function test_RevertWhen_ClaimRequest_Because_AlreadyClaimed() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + skipTime(delay) + claimRequestOnLidoARM(address(this), 0) + { + // Expect revert + vm.expectRevert("Already claimed"); + lidoARM.claimRedeem(0); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + + function test_ClaimRequest_MoreThanEnoughLiquidity_() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + skipTime(delay) + { + // Assertions before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(DEFAULT_AMOUNT, 0, 1); + assertEqUserRequest(0, address(this), false, block.timestamp, DEFAULT_AMOUNT, DEFAULT_AMOUNT); + assertEq(lidoARM.claimable(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), address(this), DEFAULT_AMOUNT); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(address(this), 0, DEFAULT_AMOUNT); + + // Main call + (uint256 assets) = lidoARM.claimRedeem(0); + + // Assertions after + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(DEFAULT_AMOUNT, DEFAULT_AMOUNT, 1); + assertEqUserRequest(0, address(this), true, block.timestamp, DEFAULT_AMOUNT, DEFAULT_AMOUNT); + assertEq(assets, DEFAULT_AMOUNT); + assertEq(lidoARM.claimable(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + } + + function test_ClaimRequest_JustEnoughLiquidity_() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + skipTime(delay) + { + // Assertions before + // Same situation as above + + // Swap MIN_TOTAL_SUPPLY from WETH in STETH + deal(address(weth), address(lidoARM), DEFAULT_AMOUNT); + deal(address(steth), address(lidoARM), MIN_TOTAL_SUPPLY); + + // Handle lido rounding issue to ensure that balance is exactly MIN_TOTAL_SUPPLY + if (steth.balanceOf(address(lidoARM)) == MIN_TOTAL_SUPPLY - 1) { + deal(address(steth), address(lidoARM), 0); + deal(address(steth), address(lidoARM), MIN_TOTAL_SUPPLY + 1); + } + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), address(this), DEFAULT_AMOUNT); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(address(this), 0, DEFAULT_AMOUNT); + + // Main call + (uint256 assets) = lidoARM.claimRedeem(0); + + // Assertions after + assertApproxEqAbs(steth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY, 2); + assertEq(weth.balanceOf(address(lidoARM)), 0); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(DEFAULT_AMOUNT, DEFAULT_AMOUNT, 1); + assertEqUserRequest(0, address(this), true, block.timestamp, DEFAULT_AMOUNT, DEFAULT_AMOUNT); + assertEq(assets, DEFAULT_AMOUNT); + } + + function test_ClaimRequest_SecondClaim() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT / 2) + skipTime(delay) + claimRequestOnLidoARM(address(this), 0) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT / 2) + { + // Assertions before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT / 2); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(DEFAULT_AMOUNT, DEFAULT_AMOUNT / 2, 2); + assertEqUserRequest(0, address(this), true, block.timestamp, DEFAULT_AMOUNT / 2, DEFAULT_AMOUNT / 2); + assertEqUserRequest(1, address(this), false, block.timestamp + delay, DEFAULT_AMOUNT / 2, DEFAULT_AMOUNT); + assertEq(lidoARM.claimable(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), address(this), DEFAULT_AMOUNT / 2); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(address(this), 1, DEFAULT_AMOUNT / 2); + + // Main call + skip(delay); + (uint256 assets) = lidoARM.claimRedeem(1); + + // Assertions after + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(DEFAULT_AMOUNT, DEFAULT_AMOUNT, 2); + assertEqUserRequest(0, address(this), true, block.timestamp - delay, DEFAULT_AMOUNT / 2, DEFAULT_AMOUNT / 2); + assertEqUserRequest(1, address(this), true, block.timestamp, DEFAULT_AMOUNT / 2, DEFAULT_AMOUNT); + assertEq(assets, DEFAULT_AMOUNT / 2); + assertEq(lidoARM.claimable(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/ClaimStETHWithdrawalForWETH.t.sol b/test/fork/LidoFixedPriceMultiLpARM/ClaimStETHWithdrawalForWETH.t.sol new file mode 100644 index 0000000..9384768 --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/ClaimStETHWithdrawalForWETH.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20, IStETHWithdrawal} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +contract Fork_Concrete_LidoARM_RequestLidoWithdrawals_Test_ is Fork_Shared_Test_ { + uint256[] amounts0; + uint256[] amounts1; + uint256[] amounts2; + + IStETHWithdrawal public stETHWithdrawal = IStETHWithdrawal(Mainnet.LIDO_WITHDRAWAL); + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + + function setUp() public override { + super.setUp(); + + deal(address(steth), address(lidoARM), 10_000 ether); + + amounts0 = new uint256[](0); + + amounts1 = new uint256[](1); + amounts1[0] = DEFAULT_AMOUNT; + + amounts2 = new uint256[](2); + amounts2[0] = DEFAULT_AMOUNT; + amounts2[1] = DEFAULT_AMOUNT; + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + function test_ClaimLidoWithdrawals_EmptyList() + public + asOperator + requestLidoWithdrawalsOnLidoARM(new uint256[](0)) + { + assertEq(address(lidoARM).balance, 0); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + + uint256[] memory emptyList = new uint256[](0); + + // Expected events + vm.expectEmit({emitter: address(lidoARM)}); + emit LidoARM.ClaimLidoWithdrawals(emptyList); + + // Main call + lidoARM.claimLidoWithdrawals(new uint256[](0)); + + assertEq(address(lidoARM).balance, 0); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + } + + function test_ClaimLidoWithdrawals_SingleRequest() + public + asOperator + requestLidoWithdrawalsOnLidoARM(amounts1) + mockFunctionClaimWithdrawOnLidoARM(DEFAULT_AMOUNT) + { + // Assertions before + uint256 balanceBefore = weth.balanceOf(address(lidoARM)); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), DEFAULT_AMOUNT); + + stETHWithdrawal.getLastRequestId(); + uint256[] memory requests = new uint256[](1); + requests[0] = stETHWithdrawal.getLastRequestId(); + + // Expected events + vm.expectEmit({emitter: address(lidoARM)}); + emit LidoARM.ClaimLidoWithdrawals(requests); + + // Main call + lidoARM.claimLidoWithdrawals(requests); + + // Assertions after + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(weth.balanceOf(address(lidoARM)), balanceBefore + DEFAULT_AMOUNT); + } + + function test_ClaimLidoWithdrawals_MultiRequest() + public + asOperator + requestLidoWithdrawalsOnLidoARM(amounts2) + mockCallLidoFindCheckpointHints + mockFunctionClaimWithdrawOnLidoARM(amounts2[0] + amounts2[1]) + { + // Assertions before + uint256 balanceBefore = weth.balanceOf(address(lidoARM)); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), amounts2[0] + amounts2[1]); + + stETHWithdrawal.getLastRequestId(); + uint256[] memory requests = new uint256[](2); + requests[0] = stETHWithdrawal.getLastRequestId() - 1; + requests[1] = stETHWithdrawal.getLastRequestId(); + + // Expected events + vm.expectEmit({emitter: address(lidoARM)}); + emit LidoARM.ClaimLidoWithdrawals(requests); + + // Main call + lidoARM.claimLidoWithdrawals(requests); + + // Assertions after + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(weth.balanceOf(address(lidoARM)), balanceBefore + amounts2[0] + amounts2[1]); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/CollectFees.t.sol b/test/fork/LidoFixedPriceMultiLpARM/CollectFees.t.sol new file mode 100644 index 0000000..dd23f07 --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/CollectFees.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; + +contract Fork_Concrete_LidoARM_CollectFees_Test_ is Fork_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + /// @notice This test is expected to revert as almost all the liquidity is in stETH + function test_RevertWhen_CollectFees_Because_InsufficientLiquidity() + public + simulateAssetGainInLidoARM(DEFAULT_AMOUNT, address(steth), true) + { + vm.expectRevert("ARM: insufficient liquidity"); + lidoARM.collectFees(); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + function test_CollectFees_Once() public simulateAssetGainInLidoARM(DEFAULT_AMOUNT, address(weth), true) { + address feeCollector = lidoARM.feeCollector(); + uint256 fee = DEFAULT_AMOUNT * 20 / 100; + + // Expected Events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), feeCollector, fee); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.FeeCollected(feeCollector, fee); + + // Main call + uint256 claimedFee = lidoARM.collectFees(); + + // Assertions after + assertEq(claimedFee, fee); + assertEq(lidoARM.feesAccrued(), 0); + } + + function test_CollectFees_Twice() + public + simulateAssetGainInLidoARM(DEFAULT_AMOUNT, address(weth), true) + collectFeesOnLidoARM + simulateAssetGainInLidoARM(DEFAULT_AMOUNT, address(weth), true) + { + // Main call + uint256 claimedFee = lidoARM.collectFees(); + + // Assertions after + assertEq(claimedFee, DEFAULT_AMOUNT * 20 / 100); // This test should pass! + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/Constructor.t.sol b/test/fork/LidoFixedPriceMultiLpARM/Constructor.t.sol new file mode 100644 index 0000000..03462ef --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/Constructor.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +contract Fork_Concrete_LidoARM_Constructor_Test is Fork_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + function test_Initial_State() public view { + assertEq(lidoARM.name(), "Lido ARM"); + assertEq(lidoARM.symbol(), "ARM-ST"); + assertEq(lidoARM.owner(), address(this)); + assertEq(lidoARM.operator(), operator); + assertEq(lidoARM.feeCollector(), feeCollector); + assertEq(lidoARM.fee(), 2000); + assertEq(lidoARM.lastAvailableAssets(), int256(1e12)); + assertEq(lidoARM.feesAccrued(), 0); + // the 20% performance fee is removed on initialization + assertEq(lidoARM.totalAssets(), 1e12); + assertEq(lidoARM.totalSupply(), 1e12); + assertEq(weth.balanceOf(address(lidoARM)), 1e12); + assertEq(capManager.totalAssetsCap(), 100 ether); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/Deposit.t.sol b/test/fork/LidoFixedPriceMultiLpARM/Deposit.t.sol new file mode 100644 index 0000000..320e71c --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/Deposit.t.sol @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {IStETHWithdrawal} from "contracts/Interfaces.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +contract Fork_Concrete_LidoARM_Deposit_Test_ is Fork_Shared_Test_ { + bool private ac; + uint256[] private amounts1 = new uint256[](1); + IStETHWithdrawal private stETHWithdrawal = IStETHWithdrawal(Mainnet.LIDO_WITHDRAWAL); + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + + function setUp() public override { + super.setUp(); + + deal(address(weth), address(this), 1_000 ether); + + // Alice + deal(address(weth), alice, 1_000 ether); + vm.prank(alice); + weth.approve(address(lidoARM), type(uint256).max); + + // Amounts arrays + amounts1[0] = DEFAULT_AMOUNT; + + ac = capManager.accountCapEnabled(); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_Deposit_Because_LiquidityProviderCapExceeded_WithCapNull() + public + enableCaps + setLiquidityProviderCap(address(this), 0) + { + vm.expectRevert("LPC: LP cap exceeded"); + lidoARM.deposit(DEFAULT_AMOUNT); + } + + function test_RevertWhen_Deposit_Because_LiquidityProviderCapExceeded_WithCapNotNull() + public + enableCaps + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + vm.expectRevert("LPC: LP cap exceeded"); + lidoARM.deposit(DEFAULT_AMOUNT + 1); + } + + function test_RevertWhen_Deposit_Because_LiquidityProviderCapExceeded_WithCapReached() + public + enableCaps + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + // Initial deposit + lidoARM.deposit(DEFAULT_AMOUNT / 2); + + // Cap is now 0.5 ether + vm.expectRevert("LPC: LP cap exceeded"); + lidoARM.deposit((DEFAULT_AMOUNT / 2) + 1); + } + + function test_RevertWhen_Deposit_Because_TotalAssetsCapExceeded_WithCapNull() + public + setTotalAssetsCap(0) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT + 1) + { + vm.expectRevert("LPC: Total assets cap exceeded"); + lidoARM.deposit(DEFAULT_AMOUNT); + } + + function test_RevertWhen_Deposit_Because_TotalAssetsCapExceeded_WithCapNotNull() + public + setTotalAssetsCap(DEFAULT_AMOUNT) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + vm.expectRevert("LPC: Total assets cap exceeded"); + lidoARM.deposit(DEFAULT_AMOUNT - MIN_TOTAL_SUPPLY + 1); + } + + function test_RevertWhen_Deposit_Because_TotalAssetsCapExceeded_WithCapReached() + public + setTotalAssetsCap(DEFAULT_AMOUNT) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + lidoARM.deposit(DEFAULT_AMOUNT / 2); + vm.expectRevert("LPC: Total assets cap exceeded"); + lidoARM.deposit((DEFAULT_AMOUNT / 2) - MIN_TOTAL_SUPPLY + 1); // This should revert! + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + + /// @notice Depositing into the ARM, first deposit of first user. + /// @dev No fees accrued, no withdrawals queued, and no performance fees generated + function test_Deposit_NoFeesAccrued_EmptyWithdrawQueue_FirstDeposit_NoPerfs() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + uint256 amount = DEFAULT_AMOUNT; + // Assertions Before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + if (ac) assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); // Ensure no shares before + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY, "total supply before"); // Minted to dead on deploy + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY, "total assets before"); + assertEq(capManager.liquidityProviderCaps(address(this)), amount, "lp cap before"); + assertEqQueueMetadata(0, 0, 0); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), address(this), amount); // shares == amount here + if (ac) { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(address(this), 0); + } + + // Main call + uint256 shares = lidoARM.deposit(amount); + + // Assertions After + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + amount); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + amount)); + assertEq(lidoARM.balanceOf(address(this)), shares); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + amount, "total supply after"); + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + amount, "total assets after"); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0, "lp cap after"); // All the caps are used + assertEqQueueMetadata(0, 0, 0); + assertEq(shares, amount); // No perfs, so 1 ether * totalSupply (1e12) / totalAssets (1e12) = 1 ether + } + + /// @notice Depositing into the ARM, second deposit of first user. + /// @dev No fees accrued, no withdrawals queued, and no performance fees generated + function test_Deposit_NoFeesAccrued_EmptyWithdrawQueue_SecondDepositSameUser_NoPerfs() + public + setTotalAssetsCap(DEFAULT_AMOUNT * 2 + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT * 2) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + uint256 amount = DEFAULT_AMOUNT; + + // Assertions Before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + amount); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + amount)); + assertEq(lidoARM.balanceOf(address(this)), amount); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + amount); // Minted to dead on deploy + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + amount); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), amount); + assertEqQueueMetadata(0, 0, 0); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), address(this), amount); // shares == amount here + if (ac) { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(address(this), 0); + } + + // Main call + uint256 shares = lidoARM.deposit(amount); + + // Assertions After + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + amount * 2); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + amount * 2)); + assertEq(lidoARM.balanceOf(address(this)), shares * 2); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + amount * 2); + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + amount * 2); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); // All the caps are used + assertEqQueueMetadata(0, 0, 0); + assertEq(shares, amount); // No perfs, so 1 ether * totalSupply (1e18 + 1e12) / totalAssets (1e18 + 1e12) = 1 ether + } + + /// @notice Depositing into the ARM, first deposit of second user. + /// @dev No fees accrued, no withdrawals queued, and no performance fees generated + function test_Deposit_NoFeesAccrued_EmptyWithdrawQueue_SecondDepositDiffUser_NoPerfs() + public + setTotalAssetsCap(DEFAULT_AMOUNT * 2 + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + setLiquidityProviderCap(alice, DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + uint256 amount = DEFAULT_AMOUNT; + + // Assertions Before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + amount); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + amount)); + assertEq(lidoARM.balanceOf(alice), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + amount); // Minted to dead on deploy + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + amount); + if (ac) assertEq(capManager.liquidityProviderCaps(alice), amount); + assertEqQueueMetadata(0, 0, 0); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), alice, amount); // shares == amount here + if (ac) { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(alice, 0); + } + + vm.prank(alice); + // Main call + uint256 shares = lidoARM.deposit(amount); + + // Assertions After + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + amount * 2); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + amount * 2)); + assertEq(lidoARM.balanceOf(alice), shares); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + amount * 2); + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + amount * 2); + if (ac) assertEq(capManager.liquidityProviderCaps(alice), 0); // All the caps are used + assertEqQueueMetadata(0, 0, 0); + assertEq(shares, amount); // No perfs, so 1 ether * totalSupply (1e18 + 1e12) / totalAssets (1e18 + 1e12) = 1 ether + } + + /// @notice Depositing into the ARM, first deposit of user with performance fees. + /// @dev No fees accrued yet, no withdrawals queued, and performance fee taken + function test_Deposit_NoFeesAccrued_EmptyWithdrawQueue_FirstDeposit_WithPerfs() + public + setTotalAssetsCap(type(uint256).max) // No need to restrict it for this test. + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT * 20) + { + // simulate asset gain + uint256 balanceBefore = weth.balanceOf(address(lidoARM)); + uint256 assetGain = DEFAULT_AMOUNT; + deal(address(weth), address(lidoARM), balanceBefore + assetGain); + + // 20% of the asset gain goes to the performance fees + uint256 expectedFeesAccrued = assetGain * 20 / 100; + uint256 expectedTotalAssetsBeforeDeposit = balanceBefore + assetGain * 80 / 100; + + // Assertions Before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + assetGain); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0, "Outstanding ether before"); + assertEq(lidoARM.feesAccrued(), expectedFeesAccrued, "fee accrued before"); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY), "last available assets before"); + assertEq(lidoARM.balanceOf(address(this)), 0, "user shares before"); // Ensure no shares before + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY, "Total supply before"); // Minted to dead on deploy + // 80% of the asset gain goes to the total assets + assertEq(lidoARM.totalAssets(), expectedTotalAssetsBeforeDeposit, "Total assets before"); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), DEFAULT_AMOUNT * 20, "lp cap before"); + assertEqQueueMetadata(0, 0, 0); + + uint256 depositedAssets = DEFAULT_AMOUNT * 20; + uint256 expectedShares = depositedAssets * MIN_TOTAL_SUPPLY / expectedTotalAssetsBeforeDeposit; + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), depositedAssets); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), address(this), expectedShares); + if (ac) { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(address(this), 0); + } + + // deposit assets + uint256 shares = lidoARM.deposit(depositedAssets); + + assertEq(shares, expectedShares, "minted shares"); + // No perfs, so 1 ether * totalSupply (1e12) / totalAssets (1e12) = 1 ether + + // Assertions After + assertEq(steth.balanceOf(address(lidoARM)), 0, "stETH balance after"); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + assetGain + depositedAssets, "WETH balance after"); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0, "Outstanding ether after"); + assertEq(lidoARM.feesAccrued(), expectedFeesAccrued, "fees accrued after"); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + depositedAssets), "last total assets after"); + assertEq(lidoARM.balanceOf(address(this)), expectedShares, "user shares after"); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + expectedShares, "total supply after"); + assertEq(lidoARM.totalAssets(), expectedTotalAssetsBeforeDeposit + depositedAssets, "Total assets after"); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0, "lp cap after"); // All the caps are used + assertEqQueueMetadata(0, 0, 0); + } + + /// @notice Depositing into the ARM reserves WETH for the withdrawal queue. + /// @dev No fees accrued, withdrawal queue shortfall, and no performance fees generated + function test_Deposit_NoFeesAccrued_WithdrawalRequestsOutstanding_SecondDepositDiffUser_NoPerfs() + public + setTotalAssetsCap(DEFAULT_AMOUNT * 3 + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + setLiquidityProviderCap(alice, DEFAULT_AMOUNT * 5) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // set stETH/WETH buy price to 1 + lidoARM.setCrossPrice(1e36); + lidoARM.setPrices(1e36 - 1, 1e36); + + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT, "total assets before swap"); + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT), "last available before swap"); + + // User Swap stETH for 3/4 of WETH in the ARM + deal(address(steth), address(this), DEFAULT_AMOUNT); + lidoARM.swapTokensForExactTokens(steth, weth, 3 * DEFAULT_AMOUNT / 4, DEFAULT_AMOUNT, address(this)); + assertApproxEqAbs( + lidoARM.totalAssets(), + MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT + 2, + STETH_ERROR_ROUNDING, + "total assets after swap" + ); + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT), "last available after swap"); + + // First user requests a full withdrawal + uint256 firstUserShares = lidoARM.balanceOf(address(this)); + (, uint256 assetsRedeem) = lidoARM.requestRedeem(firstUserShares); + + // Assertions Before + uint256 stethBalanceBefore = 3 * DEFAULT_AMOUNT / 4; + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), + stethBalanceBefore, + STETH_ERROR_ROUNDING, + "stETH ARM balance before deposit" + ); + uint256 wethBalanceBefore = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT - 3 * DEFAULT_AMOUNT / 4; + assertEq(weth.balanceOf(address(lidoARM)), wethBalanceBefore, "WETH ARM balance before deposit"); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0, "Outstanding ether before"); + assertEq(lidoARM.feesAccrued(), 0, "Fees accrued before deposit"); + assertApproxEqAbs( + lidoARM.lastAvailableAssets(), + int256(MIN_TOTAL_SUPPLY), + STETH_ERROR_ROUNDING, + "last available assets before" + ); + assertEq(lidoARM.balanceOf(alice), 0, "alice shares before deposit"); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY, "total supply before deposit"); + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + 1, "total assets before deposit"); + if (ac) assertEq(capManager.liquidityProviderCaps(alice), DEFAULT_AMOUNT * 5, "lp cap before deposit"); + assertEqQueueMetadata(assetsRedeem, 0, 1); + assertApproxEqAbs(assetsRedeem, DEFAULT_AMOUNT, STETH_ERROR_ROUNDING, "assets redeem before deposit"); + + uint256 amount = DEFAULT_AMOUNT * 2; + + // Expected values + uint256 expectedShares = amount * MIN_TOTAL_SUPPLY / (MIN_TOTAL_SUPPLY + 1); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), alice, expectedShares); + if (ac) { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(alice, DEFAULT_AMOUNT * 3); + } + + vm.prank(alice); + // Main call + uint256 shares = lidoARM.deposit(amount); + + // Assertions After + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), stethBalanceBefore, STETH_ERROR_ROUNDING, "stETH ARM balance after" + ); + assertEq(weth.balanceOf(address(lidoARM)), wethBalanceBefore + amount, "WETH ARM balance after deposit"); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0, "Outstanding ether after deposit"); + assertEq(lidoARM.feesAccrued(), 0, "Fees accrued after deposit"); // No perfs so no fees + assertApproxEqAbs( + lidoARM.lastAvailableAssets(), + int256(MIN_TOTAL_SUPPLY + amount), + STETH_ERROR_ROUNDING, + "last available assets after deposit" + ); + assertEq(lidoARM.balanceOf(alice), shares, "alice shares after deposit"); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + expectedShares, "total supply after deposit"); + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + amount + 1, "total assets after deposit"); + if (ac) assertEq(capManager.liquidityProviderCaps(alice), DEFAULT_AMOUNT * 3, "alice cap after deposit"); // All the caps are used + // withdrawal request is now claimable + assertEqQueueMetadata(assetsRedeem, 0, 1); + assertApproxEqAbs(shares, expectedShares, STETH_ERROR_ROUNDING, "shares after deposit"); // No perfs, so 1 ether * totalSupply (1e18 + 1e12) / totalAssets (1e18 + 1e12) = 1 ether + } + + /// @notice Test the following scenario: + /// 1. ARM gain assets in stETH + /// 2. Operator request a withdraw from Lido on steth + /// 3. User deposit liquidity + /// 4. Operator claim the withdrawal on Lido + /// 5. User burn shares + /// 6. Operator collects the performance fees + /// Checking that amount deposited can be retrieved + function test_Deposit_WithOutStandingWithdrawRequest_BeforeDeposit_ClaimedLidoWithdraw_WithAssetGain() + public + deal_(address(steth), address(lidoARM), DEFAULT_AMOUNT) + requestLidoWithdrawalsOnLidoARM(amounts1) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + // Assertions Before + uint256 expectedTotalSupplyBeforeDeposit = MIN_TOTAL_SUPPLY; + uint256 expectTotalAssetsBeforeDeposit = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 80 / 100; + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), DEFAULT_AMOUNT, "stETH in Lido withdrawal queue before deposit"); + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit, "total supply before deposit"); + assertEq(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit, "total assets before deposit"); + assertEq(lidoARM.feesAccrued(), DEFAULT_AMOUNT * 20 / 100, "fees accrued before deposit"); + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY), "last available assets before deposit"); + assertEq(lidoARM.balanceOf(address(this)), 0); // Ensure no shares before + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), DEFAULT_AMOUNT); + assertEqQueueMetadata(0, 0, 0); + + // Expected values = 1249998437501 + // shares = assets * total supply / total assets + uint256 expectShares = DEFAULT_AMOUNT * expectedTotalSupplyBeforeDeposit / expectTotalAssetsBeforeDeposit; + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), DEFAULT_AMOUNT); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), address(this), expectShares); + + uint256 requestId = stETHWithdrawal.getLastRequestId(); + uint256[] memory requests = new uint256[](1); + requests[0] = requestId; + + // Main calls + // 3. User mint shares + uint256 shares = lidoARM.deposit(DEFAULT_AMOUNT); + + assertEq(shares, expectShares, "shares after deposit"); + assertEq(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit + DEFAULT_AMOUNT, "total assets after deposit"); + assertEq(lidoARM.feesAccrued(), DEFAULT_AMOUNT * 20 / 100, "fees accrued after deposit"); + assertEq( + lidoARM.lastAvailableAssets(), + int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT), + "last available assets after deposit" + ); + + // 4. Lido finalization process is simulated + lidoARM.totalAssets(); + _mockFunctionClaimWithdrawOnLidoARM(DEFAULT_AMOUNT); + + // 4. Operator claim withdrawal on lido + lidoARM.totalAssets(); + lidoARM.claimLidoWithdrawals(requests); + + // 5. User burn shares + (, uint256 receivedAssets) = lidoARM.requestRedeem(shares); + + // Assertions after redeem + // This difference comes from the small value of shares, which reduces the precision of the calculation + assertApproxEqRel(receivedAssets, DEFAULT_AMOUNT, 1e6, "received assets from redeem"); // 1e6 -> 0.0000000001%, + assertEq(steth.balanceOf(address(lidoARM)), 0, "ARM stETH balance after redeem"); + assertEq( + weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 2, "ARM WETH balance after redeem" + ); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0, "stETH in Lido withdrawal queue after redeem"); + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit, "total supply after redeem"); + assertApproxEqRel(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit, 1e6, "total assets after redeem"); + assertEq(lidoARM.feesAccrued(), DEFAULT_AMOUNT * 20 / 100, "fees accrued after redeem"); + assertApproxEqAbs( + lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY), 4e6, "last available assets after redeem" + ); + assertEq(lidoARM.balanceOf(address(this)), 0, "User shares after redeem"); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0, "all user cap used"); + assertEqQueueMetadata(receivedAssets, 0, 1); + + // 6. collect fees + lidoARM.collectFees(); + + // Assertions after collect fees + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit, "total supply after collect fees"); + assertApproxEqRel(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit, 1e6, "total assets after collect fees"); + assertEq(lidoARM.feesAccrued(), 0, "fees accrued after collect fees"); + assertApproxEqAbs( + lidoARM.lastAvailableAssets(), + int256(expectTotalAssetsBeforeDeposit), + 4e6, + "last available assets after collect fees" + ); + } + + /// @notice Test the following scenario: + /// 1. User deposit liquidity + /// 2. ARM swap between WETH and stETH (no assets gains) + /// 2. Operator request a withdraw from Lido on steth + /// 4. Operator claim the withdrawal on Lido + /// 5. User burn shares + /// Checking that amount deposited can be retrieved + function test_Deposit_WithOutStandingWithdrawRequest_AfterDeposit_ClaimedLidoWithdraw_WithoutAssetGain() + public + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + // Assertions Before + // Not needed, as one of the previous test already covers this scenario + + // Main calls: + // 1. User mint shares + uint256 shares = lidoARM.deposit(DEFAULT_AMOUNT); + // Simulate a swap from WETH to stETH + deal(address(weth), address(lidoARM), MIN_TOTAL_SUPPLY); + deal(address(steth), address(lidoARM), DEFAULT_AMOUNT); + // 2. Operator request a claim on withdraw + lidoARM.requestLidoWithdrawals(amounts1); + // 3. We simulate the finalization of the process + _mockFunctionClaimWithdrawOnLidoARM(DEFAULT_AMOUNT); + uint256 requestId = stETHWithdrawal.getLastRequestId(); + uint256[] memory requests = new uint256[](1); + requests[0] = requestId; + // 4. Operator claim the withdrawal on lido + lidoARM.claimLidoWithdrawals(requests); + // 5. User burn shares + (, uint256 receivedAssets) = lidoARM.requestRedeem(shares); + + // Assertions After + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); // Ensure no shares after + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); // Minted to dead on deploy + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); // All the caps are used + assertEqQueueMetadata(receivedAssets, 0, 1); + assertEq(receivedAssets, DEFAULT_AMOUNT, "received assets"); + } + + /// @notice Test the following scenario: + /// 1. User deposit liquidity + /// 2. ARM asset gain (on steth) + /// 3. Operator request a withdraw from Lido on steth + /// 4. Operator claim the withdrawal on Lido + /// 5. User burn shares + /// Checking that amount deposited + benefice can be retrieved + function test_Deposit_WithOutStandingWithdrawRequest_AfterDeposit_ClaimedLidoWithdraw_WithAssetGain() + public + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + { + // Assertions Before + // Not needed, as one of the previous test already covers this scenario + + // Main calls: + // 1. User mint shares + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY), "last available assets before deposit"); + + uint256 shares = lidoARM.deposit(DEFAULT_AMOUNT); + + assertEq(lidoARM.feesAccrued(), 0, "fees accrued after deposit"); + assertEq( + lidoARM.lastAvailableAssets(), + int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT), + "last available assets after deposit" + ); + + // 2. Simulate asset gain (on steth) + deal(address(steth), address(lidoARM), DEFAULT_AMOUNT); + assertApproxEqAbs( + lidoARM.feesAccrued(), DEFAULT_AMOUNT * 20 / 100, STETH_ERROR_ROUNDING, "fees accrued before redeem" + ); + + // 3. Operator request a claim on withdraw + lidoARM.requestLidoWithdrawals(amounts1); + + // 3. We simulate the finalization of the process + _mockFunctionClaimWithdrawOnLidoARM(DEFAULT_AMOUNT); + uint256 requestId = stETHWithdrawal.getLastRequestId(); + uint256[] memory requests = new uint256[](1); + requests[0] = requestId; + + // 4. Operator claim the withdrawal on lido + lidoARM.claimLidoWithdrawals(requests); + + // 5. User burn shares + (, uint256 receivedAssets) = lidoARM.requestRedeem(shares); + + uint256 userBenef = (DEFAULT_AMOUNT * 80 / 100) * DEFAULT_AMOUNT / (MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + // Assertions After + assertEq(receivedAssets, DEFAULT_AMOUNT + userBenef, "received assets"); + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 2); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertApproxEqAbs(lidoARM.feesAccrued(), DEFAULT_AMOUNT * 20 / 100, 2, "fees accrued after redeem"); + assertApproxEqAbs( + lidoARM.lastAvailableAssets(), + // initial assets + user deposit - (user deposit + asset gain less fees) + int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT) - int256(DEFAULT_AMOUNT + userBenef), + STETH_ERROR_ROUNDING, + "last available assets after redeem" + ); + assertEq(lidoARM.balanceOf(address(this)), 0, "user shares after"); // Ensure no shares after + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY, "total supply after"); // Minted to dead on deploy + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0, "user cap"); // All the caps are used + assertEqQueueMetadata(receivedAssets, 0, 1); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/RequestRedeem.t.sol b/test/fork/LidoFixedPriceMultiLpARM/RequestRedeem.t.sol new file mode 100644 index 0000000..740b87f --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/RequestRedeem.t.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; + +contract Fork_Concrete_LidoARM_RequestRedeem_Test_ is Fork_Shared_Test_ { + bool private ac; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + + deal(address(weth), address(this), 1_000 ether); + + ac = capManager.accountCapEnabled(); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + /// @notice Test the `requestRedeem` function when there are no profits and the first deposit is made. + function test_RequestRedeem_AfterFirstDeposit_NoPerfs_EmptyWithdrawQueue() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // Assertions Before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT)); + assertEq(lidoARM.balanceOf(address(this)), DEFAULT_AMOUNT); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(0, 0, 0); + + uint256 delay = lidoARM.claimDelay(); + + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(this), address(0), DEFAULT_AMOUNT); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested(address(this), 0, DEFAULT_AMOUNT, DEFAULT_AMOUNT, block.timestamp + delay); + // Main Call + (uint256 requestId, uint256 assets) = lidoARM.requestRedeem(DEFAULT_AMOUNT); + + // Assertions After + assertEq(requestId, 0); // First request + assertEqQueueMetadata(DEFAULT_AMOUNT, 0, 1); // One request in the queue + assertEqUserRequest(0, address(this), false, block.timestamp + delay, DEFAULT_AMOUNT, DEFAULT_AMOUNT); // Requested the full amount + assertEq(assets, DEFAULT_AMOUNT, "Wrong amount of assets"); // As no profits, assets returned are the same as deposited + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY)); + assertEq(lidoARM.balanceOf(address(this)), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + } + + /// @notice Test the `requestRedeem` function when there are no profits and the first deposit is made. + function test_RequestRedeem_AfterFirstDeposit_NoPerfs_NonEmptyWithdrawQueue_SecondRedeem() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT / 4) + { + // Assertions Before + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 3 / 4)); + assertEq(lidoARM.balanceOf(address(this)), DEFAULT_AMOUNT * 3 / 4); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 3 / 4); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); // Down only + assertEqQueueMetadata(DEFAULT_AMOUNT / 4, 0, 1); + + uint256 delay = lidoARM.claimDelay(); + + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(this), address(0), DEFAULT_AMOUNT / 2); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested( + address(this), 1, DEFAULT_AMOUNT / 2, DEFAULT_AMOUNT * 3 / 4, block.timestamp + delay + ); + // Main Call + (uint256 requestId, uint256 assets) = lidoARM.requestRedeem(DEFAULT_AMOUNT / 2); + + // Assertions After + assertEq(requestId, 1); // Second request + assertEqQueueMetadata(DEFAULT_AMOUNT * 3 / 4, 0, 2); // Two requests in the queue + assertEqUserRequest( + 1, address(this), false, block.timestamp + delay, DEFAULT_AMOUNT / 2, DEFAULT_AMOUNT * 3 / 4 + ); + assertEq(assets, DEFAULT_AMOUNT / 2, "Wrong amount of assets"); // As no profits, assets returned are the same as deposited + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0); + assertEq(lidoARM.feesAccrued(), 0); // No perfs so no fees + assertEq(lidoARM.lastAvailableAssets(), int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 1 / 4)); + assertEq(lidoARM.balanceOf(address(this)), DEFAULT_AMOUNT * 1 / 4); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 1 / 4); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); // Down only + } + + /// @notice Test the `requestRedeem` function when there are profits and the first deposit is already made. + function test_RequestRedeem_AfterFirstDeposit_WithPerfs_EmptyWithdrawQueue() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // Assertions Before + // Not needed as the same as in `test_RequestRedeem_AfterFirstDeposit_NoPerfs_EmptyWithdrawQueue` + + // Simulate assets gain in ARM + uint256 assetsBeforeGain = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT; + uint256 assetsGain = DEFAULT_AMOUNT; + uint256 assetsAfterGain = assetsBeforeGain + assetsGain; + deal(address(weth), address(lidoARM), assetsAfterGain); + + // Expected Events + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(this), address(0), DEFAULT_AMOUNT); + + // Main call + (, uint256 actualAssetsFromRedeem) = lidoARM.requestRedeem(DEFAULT_AMOUNT); + + // Calculate expected values + uint256 expectedFeeAccrued = assetsGain * 20 / 100; // 20% fee + uint256 expectedTotalAsset = assetsAfterGain - expectedFeeAccrued; + uint256 expectedAssetsFromRedeem = DEFAULT_AMOUNT * expectedTotalAsset / (MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + + // Assertions After + assertEq(actualAssetsFromRedeem, expectedAssetsFromRedeem, "Assets from redeem"); + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), assetsAfterGain); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0, "stETH in Lido withdrawal queue"); + assertEq(lidoARM.feesAccrued(), expectedFeeAccrued, "fees accrued"); + assertApproxEqAbs( + lidoARM.lastAvailableAssets(), + int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT) - int256(expectedAssetsFromRedeem), + 1, + "last available assets after" + ); // 1 wei of error + assertEq(lidoARM.balanceOf(address(this)), 0); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(expectedAssetsFromRedeem, 0, 1); + assertEqUserRequest( + 0, + address(this), + false, + block.timestamp + lidoARM.claimDelay(), + expectedAssetsFromRedeem, + expectedAssetsFromRedeem + ); + } + + /// @notice Test the `requestRedeem` function when ARM lost a bit of money before the request. + function test_RequestRedeem_AfterFirstDeposit_WhenLosingFunds() + public + setTotalAssetsCap(DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // Assertions Before + // Not needed as the same as in `test_RequestRedeem_AfterFirstDeposit_NoPerfs_EmptyWithdrawQueue` + + // Simulate assets loss in ARM + uint256 assetsBeforeLoss = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT; + uint256 assetsLoss = DEFAULT_AMOUNT / 10; // 0.1 ether of loss + uint256 assetsAfterLoss = assetsBeforeLoss - assetsLoss; + deal(address(weth), address(lidoARM), assetsAfterLoss); + + // Expected Events + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(this), address(0), DEFAULT_AMOUNT); + + // Main call + (, uint256 actualAssetsFromRedeem) = lidoARM.requestRedeem(DEFAULT_AMOUNT); + + uint256 delay = lidoARM.claimDelay(); + // Assertions After + uint256 expectedAssetsFromRedeem = DEFAULT_AMOUNT * assetsAfterLoss / (MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + assertEq(actualAssetsFromRedeem, expectedAssetsFromRedeem, "Assets from redeem"); + assertEq(steth.balanceOf(address(lidoARM)), 0); + assertEq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT - assetsLoss); + assertEq(lidoARM.lidoWithdrawalQueueAmount(), 0, "stETH in Lido withdrawal queue"); + assertEq(lidoARM.feesAccrued(), 0, "fees accrued"); + assertApproxEqAbs( + lidoARM.lastAvailableAssets(), + int256(assetsBeforeLoss - expectedAssetsFromRedeem), + 1, + "last available assets" + ); // 1 wei of error + assertEq(lidoARM.balanceOf(address(this)), 0, "user LP balance"); + assertEq(lidoARM.totalSupply(), MIN_TOTAL_SUPPLY, "total supply"); + assertEq(lidoARM.totalAssets(), assetsAfterLoss - actualAssetsFromRedeem, "total assets"); + if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0); + assertEqQueueMetadata(expectedAssetsFromRedeem, 0, 1); + assertEqUserRequest( + 0, address(this), false, block.timestamp + delay, expectedAssetsFromRedeem, expectedAssetsFromRedeem + ); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/RequestStETHWithdrawalForETH.t.sol b/test/fork/LidoFixedPriceMultiLpARM/RequestStETHWithdrawalForETH.t.sol new file mode 100644 index 0000000..9145a67 --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/RequestStETHWithdrawalForETH.t.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20, IStETHWithdrawal} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + +contract Fork_Concrete_LidoARM_RequestLidoWithdrawals_Test_ is Fork_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + + deal(address(steth), address(lidoARM), 10_000 ether); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_RequestLidoWithdrawals_NotOperator() public asRandomAddress { + vm.expectRevert("ARM: Only operator or owner can call this function."); + lidoARM.requestLidoWithdrawals(new uint256[](0)); + } + + function test_RevertWhen_RequestLidoWithdrawals_Because_BalanceExceeded() public asOperator { + // Remove all stETH from the contract + deal(address(steth), address(lidoARM), 0); + + uint256[] memory amounts = new uint256[](1); + amounts[0] = DEFAULT_AMOUNT; + + vm.expectRevert("BALANCE_EXCEEDED"); + lidoARM.requestLidoWithdrawals(amounts); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + function test_RequestLidoWithdrawals_EmptyList() public asOperator { + uint256[] memory emptyList = new uint256[](0); + + // Expected events + vm.expectEmit({emitter: address(lidoARM)}); + emit LidoARM.RequestLidoWithdrawals(emptyList, emptyList); + + uint256[] memory requestIds = lidoARM.requestLidoWithdrawals(emptyList); + + assertEq(requestIds, emptyList); + } + + function test_RequestLidoWithdrawals_SingleAmount_1ether() public asOperator { + uint256[] memory amounts = new uint256[](1); + amounts[0] = DEFAULT_AMOUNT; + uint256[] memory expectedLidoRequestIds = new uint256[](1); + expectedLidoRequestIds[0] = IStETHWithdrawal(Mainnet.LIDO_WITHDRAWAL).getLastRequestId() + 1; + + // Expected events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), address(lidoARM.lidoWithdrawalQueue()), amounts[0]); + vm.expectEmit({emitter: address(lidoARM)}); + emit LidoARM.RequestLidoWithdrawals(amounts, expectedLidoRequestIds); + + // Main call + uint256[] memory requestIds = lidoARM.requestLidoWithdrawals(amounts); + + assertEq(requestIds, expectedLidoRequestIds); + } + + function test_RequestLidoWithdrawals_SingleAmount_1000ethers() public asOperator { + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1_000 ether; + uint256[] memory expectedLidoRequestIds = new uint256[](1); + expectedLidoRequestIds[0] = IStETHWithdrawal(Mainnet.LIDO_WITHDRAWAL).getLastRequestId() + 1; + + // Expected events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), address(lidoARM.lidoWithdrawalQueue()), amounts[0]); + vm.expectEmit({emitter: address(lidoARM)}); + emit LidoARM.RequestLidoWithdrawals(amounts, expectedLidoRequestIds); + + // Main call + uint256[] memory requestIds = lidoARM.requestLidoWithdrawals(amounts); + + assertEq(requestIds, expectedLidoRequestIds); + } + + function test_RequestLidoWithdrawals_MultipleAmount() public asOperator { + uint256 length = _bound(vm.randomUint(), 2, 10); + uint256[] memory amounts = new uint256[](length); + uint256[] memory expectedLidoRequestIds = new uint256[](length); + uint256 startingLidoRequestId = IStETHWithdrawal(Mainnet.LIDO_WITHDRAWAL).getLastRequestId() + 1; + for (uint256 i = 0; i < amounts.length; i++) { + amounts[i] = _bound(vm.randomUint(), 0, 1_000 ether); + expectedLidoRequestIds[i] = startingLidoRequestId + i; + } + + vm.expectEmit({emitter: address(lidoARM)}); + emit LidoARM.RequestLidoWithdrawals(amounts, expectedLidoRequestIds); + + // Main call + uint256[] memory requestIds = lidoARM.requestLidoWithdrawals(amounts); + + assertEq(requestIds, expectedLidoRequestIds); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/SetCrossPrice.t.sol b/test/fork/LidoFixedPriceMultiLpARM/SetCrossPrice.t.sol new file mode 100644 index 0000000..866e62f --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/SetCrossPrice.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +contract Fork_Concrete_LidoARM_SetCrossPrice_Test_ is Fork_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_SetCrossPrice_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setCrossPrice(0.9998e36); + } + + function test_RevertWhen_SetCrossPrice_Because_Operator() public asOperator { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setCrossPrice(0.9998e36); + } + + function test_RevertWhen_SetCrossPrice_Because_CrossPriceTooLow() public { + vm.expectRevert("ARM: cross price too low"); + lidoARM.setCrossPrice(0); + } + + function test_RevertWhen_SetCrossPrice_Because_CrossPriceTooHigh() public { + uint256 priceScale = 10 ** 36; + vm.expectRevert("ARM: cross price too high"); + lidoARM.setCrossPrice(priceScale + 1); + } + + function test_RevertWhen_SetCrossPrice_Because_BuyPriceTooHigh() public { + lidoARM.setPrices(1e36 - 20e32 + 1, 1000 * 1e33 + 1); + vm.expectRevert("ARM: buy price too high"); + lidoARM.setCrossPrice(1e36 - 20e32); + } + + function test_RevertWhen_SetCrossPrice_Because_SellPriceTooLow() public { + // To make it revert we need to try to make cross price above the sell1. + // But we need to keep cross price below 1e36! + // So first we reduce buy and sell price to minimum values + lidoARM.setPrices(1e36 - 20e32, 1000 * 1e33 + 1); + // This allow us to set a cross price below 1e36 + lidoARM.setCrossPrice(1e36 - 20e32 + 1); + // Then we make both buy and sell price below the 1e36 + lidoARM.setPrices(1e36 - 20e32, 1e36 - 20e32 + 1); + + // Then we try to set cross price above the sell price + vm.expectRevert("ARM: sell price too low"); + lidoARM.setCrossPrice(1e36 - 20e32 + 2); + } + + function test_RevertWhen_SetCrossPrice_Because_TooManyBaseAssets() public { + deal(address(steth), address(lidoARM), MIN_TOTAL_SUPPLY + 1); + vm.expectRevert("ARM: too many base assets"); + lidoARM.setCrossPrice(1e36 - 1); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + function test_SetCrossPrice_No_StETH_Owner() public { + deal(address(steth), address(lidoARM), MIN_TOTAL_SUPPLY - 1); + + // at 1.0 + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.CrossPriceUpdated(1e36); + lidoARM.setCrossPrice(1e36); + + // 20 basis points lower than 1.0 + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.CrossPriceUpdated(0.998e36); + lidoARM.setCrossPrice(0.998e36); + } + + function test_SetCrossPrice_With_StETH_PriceUp_Owner() public { + // 2 basis points lower than 1.0 + lidoARM.setCrossPrice(0.9998e36); + + deal(address(steth), address(lidoARM), MIN_TOTAL_SUPPLY + 1); + + // 1 basis points lower than 1.0 + lidoARM.setCrossPrice(0.9999e36); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/Setters.t.sol b/test/fork/LidoFixedPriceMultiLpARM/Setters.t.sol new file mode 100644 index 0000000..9de35b6 --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/Setters.t.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; + +contract Fork_Concrete_lidoARM_Setters_Test_ is Fork_Shared_Test_ { + address[] testProviders; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + + testProviders = new address[](2); + testProviders[0] = vm.randomAddress(); + testProviders[1] = vm.randomAddress(); + } + + ////////////////////////////////////////////////////// + /// --- PERFORMANCE FEE - REVERTING TEST + ////////////////////////////////////////////////////// + function test_RevertWhen_PerformanceFee_SetFee_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setFee(0); + } + + function test_RevertWhen_PerformanceFee_SetFee_Because_Operator() public asOperator { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setFee(0); + } + + function test_RevertWhen_PerformanceFee_SetFee_Because_FeeIsTooHigh() public asLidoARMOwner { + uint256 max = lidoARM.FEE_SCALE(); + vm.expectRevert("ARM: fee too high"); + lidoARM.setFee(max + 1); + } + + function test_RevertWhen_PerformanceFee_SetFeeCollector_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setFeeCollector(address(0)); + } + + function test_RevertWhen_PerformanceFee_SetFeeCollector_Because_Operator() public asOperator { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setFeeCollector(address(0)); + } + + function test_RevertWhen_PerformanceFee_SetFeeCollector_Because_FeeCollectorIsZero() public asLidoARMOwner { + vm.expectRevert("ARM: invalid fee collector"); + lidoARM.setFeeCollector(address(0)); + } + + ////////////////////////////////////////////////////// + /// --- PERFORMANCE FEE - PASSING TEST + ////////////////////////////////////////////////////// + function test_PerformanceFee_SetFee_() public asLidoARMOwner { + uint256 feeBefore = lidoARM.fee(); + + uint256 newFee = _bound(vm.randomUint(), 0, lidoARM.FEE_SCALE() / 2); + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.FeeUpdated(newFee); + lidoARM.setFee(newFee); + + assertEq(lidoARM.fee(), newFee); + assertNotEq(feeBefore, lidoARM.fee()); + } + + function test_PerformanceFee_SetFeeCollector() public asLidoARMOwner { + address feeCollectorBefore = lidoARM.feeCollector(); + + address newFeeCollector = vm.randomAddress(); + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.FeeCollectorUpdated(newFeeCollector); + lidoARM.setFeeCollector(newFeeCollector); + + assertEq(lidoARM.feeCollector(), newFeeCollector); + assertNotEq(feeCollectorBefore, lidoARM.feeCollector()); + } + + ////////////////////////////////////////////////////// + /// --- Set Prices - REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_SetPrices_Because_PriceRange_Operator() public asOperator { + // buy price 1 basis points higher than 1.0 + vm.expectRevert("ARM: buy price too high"); + lidoARM.setPrices(1.0001 * 1e36, 1.002 * 1e36); + + // sell price 11 basis points lower than 1.0 + vm.expectRevert("ARM: sell price too low"); + lidoARM.setPrices(0.998 * 1e36, 0.9989 * 1e36); + + // Forgot to scale up to 36 decimals + vm.expectRevert("ARM: sell price too low"); + lidoARM.setPrices(1e18, 1e18); + } + + function test_RevertWhen_SetPrices_Because_PriceRange_Owner() public asLidoARMOwner { + // buy price 1 basis points higher than 1.0 + vm.expectRevert("ARM: buy price too high"); + lidoARM.setPrices(1.0001 * 1e36, 1.002 * 1e36); + + // sell price 11 basis points lower than 1.0 + vm.expectRevert("ARM: sell price too low"); + lidoARM.setPrices(0.998 * 1e36, 0.9989 * 1e36); + } + + function test_RevertWhen_SetPrices_Because_NotOwnerOrOperator() public asRandomAddress { + vm.expectRevert("ARM: Only operator or owner can call this function."); + lidoARM.setPrices(0, 0); + } + + function test_RevertWhen_SetPrices_Because_SellPriceCannotCrossOneByMoreThanTenBps() public asOperator { + vm.expectRevert("ARM: sell price too low"); + lidoARM.setPrices(0.998 * 1e36, 0.9989 * 1e36); + } + + function test_RevertWhen_SetPrices_Because_BuyPriceCannotCrossOneByMoreThanTenBps() public asOperator { + vm.expectRevert("ARM: buy price too high"); + lidoARM.setPrices(1.0011 * 1e36, 1.002 * 1e36); + } + + ////////////////////////////////////////////////////// + /// --- Set Prices - PASSING TESTS + ////////////////////////////////////////////////////// + function test_SetPrices_Operator() public asOperator { + // sell price 2 basis points lower than 1.0 + lidoARM.setPrices(9980e32, 99998e32); + // 2% of one basis point spread + lidoARM.setPrices(999999e30, 1000001e30); + + lidoARM.setPrices(992 * 1e33, 1001 * 1e33); + lidoARM.setPrices(99999e31, 1004 * 1e33); + lidoARM.setPrices(992 * 1e33, 2000 * 1e33); + + // Check the traderates + assertEq(lidoARM.traderate0(), 500 * 1e33); + assertEq(lidoARM.traderate1(), 992 * 1e33); + } + + ////////////////////////////////////////////////////// + /// --- Set Cross Price - PASSING TESTS + ////////////////////////////////////////////////////// + + ////////////////////////////////////////////////////// + /// --- OWNABLE - REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_Ownable_SetOwner_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setOwner(address(0)); + } + + function test_RevertWhen_Ownable_SetOwner_Because_Operator() public asOperator { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setOwner(address(0)); + } + + function test_RevertWhen_Ownable_SetOperator_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setOperator(address(0)); + } + + function test_RevertWhen_Ownable_SetOperator_Because_Operator() public asOperator { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setOperator(address(0)); + } + + ////////////////////////////////////////////////////// + /// --- LIQUIIDITY PROVIDER CONTROLLER - REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_CapManager_SetLiquidityProvider_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setCapManager(address(0)); + } + + function test_RevertWhen_CapManager_SetLiquidityProvider_Because_Operator() public asOperator { + vm.expectRevert("ARM: Only owner can call this function."); + lidoARM.setCapManager(address(0)); + } + + ////////////////////////////////////////////////////// + /// --- LIQUIIDITY PROVIDER CONTROLLER - PASSING TESTS + ////////////////////////////////////////////////////// + function test_CapManager_SetLiquidityProvider() public asLidoARMOwner { + address newCapManager = vm.randomAddress(); + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.CapManagerUpdated(newCapManager); + lidoARM.setCapManager(newCapManager); + + assertEq(lidoARM.capManager(), newCapManager); + } + + ////////////////////////////////////////////////////// + /// --- AccountCapEnabled - REVERTING TEST + ////////////////////////////////////////////////////// + function test_RevertWhen_CapManager_SetAccountCapEnabled_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + capManager.setAccountCapEnabled(false); + } + + function test_RevertWhen_CapManager_SetAccountCapEnabled_Because_Operator() public asOperator { + vm.expectRevert("ARM: Only owner can call this function."); + capManager.setAccountCapEnabled(false); + } + + function test_RevertWhen_CapManager_SetAccountCapEnabled_Because_AlreadySet() public enableCaps asLidoARMOwner { + vm.expectRevert("LPC: Account cap already set"); + capManager.setAccountCapEnabled(true); + } + + ////////////////////////////////////////////////////// + /// --- AccountCapEnabled - PASSING TESTS + ////////////////////////////////////////////////////// + function test_CapManager_SetAccountCapEnabled() public enableCaps asLidoARMOwner { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.AccountCapEnabled(false); + capManager.setAccountCapEnabled(false); + + assertEq(capManager.accountCapEnabled(), false); + } + + ////////////////////////////////////////////////////// + /// --- TotalAssetsCap - REVERTING TEST + ////////////////////////////////////////////////////// + function test_RevertWhen_CapManager_SetTotalAssetsCap_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only operator or owner can call this function."); + capManager.setTotalAssetsCap(100 ether); + } + + ////////////////////////////////////////////////////// + /// --- TotalAssetsCap - PASSING TESTS + ////////////////////////////////////////////////////// + function test_CapManager_SetTotalAssetsCap_Owner() public asLidoARMOwner { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.TotalAssetsCap(100 ether); + capManager.setTotalAssetsCap(100 ether); + + assertEq(capManager.totalAssetsCap(), 100 ether); + } + + function test_CapManager_SetTotalAssetsCap_Operator() public asOperator { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.TotalAssetsCap(0); + capManager.setTotalAssetsCap(0); + + assertEq(capManager.totalAssetsCap(), 0); + } + + ////////////////////////////////////////////////////// + /// --- LiquidityProviderCaps - REVERTING TEST + ////////////////////////////////////////////////////// + function test_RevertWhen_CapManager_SetLiquidityProviderCaps_Because_NotOwner() public asRandomAddress { + vm.expectRevert("ARM: Only operator or owner can call this function."); + capManager.setLiquidityProviderCaps(testProviders, 50 ether); + } + + ////////////////////////////////////////////////////// + /// --- LiquidityProviderCaps - PASSING TESTS + ////////////////////////////////////////////////////// + function test_CapManager_SetLiquidityProviderCaps_Owner() public asLidoARMOwner { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(testProviders[0], 50 ether); + emit CapManager.LiquidityProviderCap(testProviders[1], 50 ether); + capManager.setLiquidityProviderCaps(testProviders, 50 ether); + + assertEq(capManager.liquidityProviderCaps(testProviders[0]), 50 ether); + assertEq(capManager.liquidityProviderCaps(testProviders[1]), 50 ether); + } + + function test_CapManager_SetLiquidityProviderCaps_Operator() public asOperator { + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(testProviders[0], 50 ether); + emit CapManager.LiquidityProviderCap(testProviders[1], 50 ether); + capManager.setLiquidityProviderCaps(testProviders, 50 ether); + + assertEq(capManager.liquidityProviderCaps(testProviders[0]), 50 ether); + assertEq(capManager.liquidityProviderCaps(testProviders[1]), 50 ether); + } + + function test_CapManager_SetLiquidityProviderCaps_ToZero() + public + asOperator + setLiquidityProviderCap(testProviders[0], 10 ether) + { + address[] memory providers = new address[](1); + providers[0] = testProviders[0]; + + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(providers[0], 0); + + capManager.setLiquidityProviderCaps(providers, 0); + + assertEq(capManager.liquidityProviderCaps(providers[0]), 0); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/SwapExactTokensForTokens.t.sol b/test/fork/LidoFixedPriceMultiLpARM/SwapExactTokensForTokens.t.sol new file mode 100644 index 0000000..d1db159 --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/SwapExactTokensForTokens.t.sol @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +import {IERC20} from "contracts/Interfaces.sol"; + +contract Fork_Concrete_LidoARM_SwapExactTokensForTokens_Test is Fork_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- CONSTANTS + ////////////////////////////////////////////////////// + uint256 private constant MIN_PRICE0 = 998e33; // 0.998 + uint256 private constant MAX_PRICE0 = 1_000e33 - 1; // just under 1.00 + uint256 private constant MIN_PRICE1 = 1_000e33; // 1.00 + uint256 private constant MAX_PRICE1 = 1_020e33; // 1.02 + uint256 private constant INITIAL_BALANCE = 1_000 ether; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + + deal(address(weth), address(this), INITIAL_BALANCE); + deal(address(steth), address(this), INITIAL_BALANCE); + + deal(address(weth), address(lidoARM), INITIAL_BALANCE); + deal(address(steth), address(lidoARM), INITIAL_BALANCE); + + // We are artificially adding assets so collect the performance fees to reset the fees collected + lidoARM.collectFees(); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_SwapExactTokensForTokens_Because_InvalidTokenOut1() public { + lidoARM.token0(); + vm.expectRevert("ARM: Invalid out token"); + lidoARM.swapExactTokensForTokens( + steth, // inToken + badToken, // outToken + 1, // amountIn + 1, // amountOutMin + address(this) // to + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_InvalidTokenOut0() public { + vm.expectRevert("ARM: Invalid out token"); + lidoARM.swapExactTokensForTokens( + weth, // inToken + badToken, // outToken + 1, // amountIn + 1, // amountOutMin + address(this) // to + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_InvalidTokenIn() public { + vm.expectRevert("ARM: Invalid in token"); + lidoARM.swapExactTokensForTokens( + badToken, // inToken + steth, // outToken + 1, // amountIn + 1, // amountOutMin + address(this) // to + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_BothInvalidTokens() public { + vm.expectRevert("ARM: Invalid in token"); + lidoARM.swapExactTokensForTokens( + badToken, // inToken + badToken, // outToken + 1, // amountIn + 1, // amountOutMin + address(this) // to + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_NotEnoughTokenIn() public { + uint256 initialBalance = weth.balanceOf(address(this)); + + vm.expectRevert(); + lidoARM.swapExactTokensForTokens( + weth, // inToken + steth, // outToken + initialBalance + 1, // amountIn + 0, // amountOutMin + address(this) // to + ); + + initialBalance = steth.balanceOf(address(this)); + vm.expectRevert("BALANCE_EXCEEDED"); // Lido error + lidoARM.swapExactTokensForTokens( + steth, // inToken + weth, // outToken + initialBalance + 3, // amountIn + 0, // amountOutMin + address(this) // to + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_NotEnoughTokenOut() public { + uint256 initialBalance = steth.balanceOf(address(lidoARM)); + deal(address(weth), address(this), initialBalance * 2); + + vm.expectRevert("BALANCE_EXCEEDED"); // Lido error + lidoARM.swapExactTokensForTokens( + weth, // inToken + steth, // outToken + initialBalance * 2, // amountIn + 0, // amountOutMin + address(this) // to + ); + + initialBalance = weth.balanceOf(address(lidoARM)); + deal(address(steth), address(this), initialBalance * 2); + vm.expectRevert(); + lidoARM.swapExactTokensForTokens( + steth, // inToken + weth, // outToken + initialBalance * 2, // amountIn + 0, // amountOutMin + address(this) // to + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_InsufficientOutputAmount() public { + deal(address(steth), address(lidoARM), 100 wei); + + // Test for this function signature: swapExactTokensForTokens(IERC20,IERC20,uint56,uint256,address) + vm.expectRevert("ARM: Insufficient output amount"); + lidoARM.swapExactTokensForTokens( + weth, // inToken + steth, // outToken + 1, // amountIn + 1_000 ether, // amountOutMin + address(this) // to + ); + + // Test for this function signature: swapExactTokensForTokens(uint256,uint256,address[],address,uint256) + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(steth); + vm.expectRevert("ARM: Insufficient output amount"); + lidoARM.swapExactTokensForTokens( + 1, // amountIn + 1_000 ether, // amountOutMin + path, // path + address(this), // to + block.timestamp // deadline + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_InvalidePathLength() public { + vm.expectRevert("ARM: Invalid path length"); + lidoARM.swapExactTokensForTokens( + 1, // amountIn + 1, // amountOutMin + new address[](3), // path + address(this), // to + 0 // deadline + ); + } + + function test_RevertWhen_SwapExactTokensForTokens_Because_DeadlineExpired() public { + vm.expectRevert("ARM: Deadline expired"); + lidoARM.swapExactTokensForTokens( + 1, // amountIn + 1, // amountOutMin + new address[](2), // path + address(this), // to + block.timestamp - 1 // deadline + ); + } + + /// @notice Test the following scenario: + /// 1. Set steth balance of the ARM to 0. + /// 2. Set weth balance of the ARM to MIN_TOTAL_SUPPLY. + /// 3. Deposit DEFAULT_AMOUNT in the ARM. + /// 4. Request redeem of DEFAULT_AMOUNT * 90%. + /// 5. Try to swap DEFAULT_AMOUNT of stETH to WETH. + function test_RevertWhen_SwapExactTokensForTokens_Because_InsufficientLiquidity_DueToRedeemRequest() + public + setTotalAssetsCap(DEFAULT_AMOUNT * 10 + MIN_TOTAL_SUPPLY) + setLiquidityProviderCap(address(this), DEFAULT_AMOUNT) + deal_(address(steth), address(lidoARM), 0) + deal_(address(weth), address(lidoARM), MIN_TOTAL_SUPPLY) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT * 90 / 100) + { + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapExactTokensForTokens( + steth, // inToken + weth, // outToken + DEFAULT_AMOUNT, // amountIn + 0, // amountOutMin + address(this) // to + ); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + function test_SwapExactTokensForTokens_WithDeadLine_Weth_To_Steth() public { + uint256 amountIn = 1 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(steth); + + // State before + uint256 balanceWETHBeforeThis = weth.balanceOf(address(this)); + uint256 balanceSTETHBeforeThis = steth.balanceOf(address(this)); + uint256 balanceWETHBeforeARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHBeforeARM = steth.balanceOf(address(lidoARM)); + + // Get minimum amount of stETH to receive + uint256 traderates0 = lidoARM.traderate0(); + uint256 minAmount = amountIn * traderates0 / 1e36; + + // Expected events: Already checked in fuzz tests + + uint256[] memory outputs = new uint256[](2); + // Main call + outputs = lidoARM.swapExactTokensForTokens( + amountIn, // amountIn + minAmount, // amountOutMin + path, // path + address(this), // to + block.timestamp // deadline + ); + + // State after + uint256 balanceWETHAfterThis = weth.balanceOf(address(this)); + uint256 balanceSTETHAfterThis = steth.balanceOf(address(this)); + uint256 balanceWETHAfterARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHAfterARM = steth.balanceOf(address(lidoARM)); + + // Assertions + assertEq(balanceWETHBeforeThis, balanceWETHAfterThis + amountIn, "user WETH balance"); + assertApproxEqAbs( + balanceSTETHBeforeThis + minAmount, balanceSTETHAfterThis, STETH_ERROR_ROUNDING, "user stETH balance" + ); + assertEq(balanceWETHBeforeARM + amountIn, balanceWETHAfterARM, "ARM WETH balance"); + assertApproxEqAbs( + balanceSTETHBeforeARM, balanceSTETHAfterARM + minAmount, STETH_ERROR_ROUNDING, "ARM stETH balance" + ); + assertEq(outputs[0], amountIn, "amount in"); + assertEq(outputs[1], minAmount, "amount out"); + } + + function test_SwapExactTokensForTokens_WithDeadLine_Steth_To_Weth() public { + uint256 amountIn = 1 ether; + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + + // State before + uint256 balanceWETHBeforeThis = weth.balanceOf(address(this)); + uint256 balanceSTETHBeforeThis = steth.balanceOf(address(this)); + uint256 balanceWETHBeforeARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHBeforeARM = steth.balanceOf(address(lidoARM)); + + // Get minimum amount of WETH to receive + uint256 traderates1 = lidoARM.traderate1(); + uint256 minAmount = amountIn * traderates1 / 1e36; + + // Expected events: Already checked in fuzz tests + + uint256[] memory outputs = new uint256[](2); + // Main call + outputs = lidoARM.swapExactTokensForTokens( + amountIn, // amountIn + minAmount, // amountOutMin + path, // path + address(this), // to + block.timestamp // deadline + ); + + // State after + uint256 balanceWETHAfterThis = weth.balanceOf(address(this)); + uint256 balanceSTETHAfterThis = steth.balanceOf(address(this)); + uint256 balanceWETHAfterARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHAfterARM = steth.balanceOf(address(lidoARM)); + + // Assertions + assertEq(balanceWETHBeforeThis + minAmount, balanceWETHAfterThis); + assertApproxEqAbs(balanceSTETHBeforeThis, balanceSTETHAfterThis + amountIn, STETH_ERROR_ROUNDING); + assertEq(balanceWETHBeforeARM, balanceWETHAfterARM + minAmount); + assertApproxEqAbs(balanceSTETHBeforeARM + amountIn, balanceSTETHAfterARM, STETH_ERROR_ROUNDING); + assertEq(outputs[0], amountIn); + assertEq(outputs[1], minAmount); + } + + /// @notice If the buy and sell prices are very close together and the stETH transferred into + /// the ARM is truncated, then there should be enough rounding protection against losing total assets. + function test_SwapExactTokensForTokens_Steth_Transfer_Truncated() + public + disableCaps + setArmBalances(MIN_TOTAL_SUPPLY, 0) + setPrices(1e36 - 1, 1e36, 1e36) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // The exact amount of stETH to send to the ARM + uint256 amountIn = 3 * DEFAULT_AMOUNT / 4; + // Get minimum amount of WETH to receive + uint256 amountOutMin = amountIn * (1e36 - 1) / 1e36; + + deal(address(steth), address(this), amountIn); + + // State before + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), address(this), amountOutMin); + + // Main call + lidoARM.swapExactTokensForTokens( + steth, // inToken + weth, // outToken + amountIn, + amountOutMin, + address(this) // to + ); + + // Assertions + assertGe(lidoARM.totalAssets(), totalAssetsBefore, "total assets after"); + } + + ////////////////////////////////////////////////////// + /// --- FUZZING TESTS + ////////////////////////////////////////////////////// + /// @notice Fuzz test for swapExactTokensForTokens(IERC20,IERC20,uint256,uint256,address), with WETH to stETH. + /// @param amountIn Amount of WETH to swap into the ARM. Fuzzed between 0 and steth in the ARM. + /// @param stethReserveGrowth Amount of stETH has grown in the ARM. Fuzzed between 0 and 1% of the INITIAL_BALANCE. + /// @param price Price of the stETH in WETH. Fuzzed between 0.98 and 1. + /// @param collectFees Whether to collect the accrued performance fees before the swap. + function test_SwapExactTokensForTokens_Weth_To_Steth( + uint256 amountIn, + uint256 stethReserveGrowth, + uint256 price, + bool collectFees + ) public { + // Use random stETH/WETH sell price between 1 and 1.02, + // the buy price doesn't matter as it is not used in this test. + price = _bound(price, MIN_PRICE1, MAX_PRICE1); + lidoARM.setCrossPrice(1e36); + lidoARM.setPrices(MIN_PRICE0, price); + + // Set random amount of stETH in the ARM + stethReserveGrowth = _bound(stethReserveGrowth, 0, INITIAL_BALANCE / 100); + deal(address(steth), address(lidoARM), INITIAL_BALANCE + stethReserveGrowth); + + if (collectFees) { + // Collect and accrued performance fees before the swap + lidoARM.collectFees(); + } + + // Random amount of WETH to swap into the ARM + // It is ok to take 100% of the balance of stETH of the ARM as the price is below 1. + amountIn = _bound(amountIn, 0, steth.balanceOf(address(lidoARM))); + deal(address(weth), address(this), amountIn); + + // State before + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 balanceWETHBeforeThis = weth.balanceOf(address(this)); + uint256 balanceSTETHBeforeThis = steth.balanceOf(address(this)); + uint256 balanceWETHBeforeARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHBeforeARM = steth.balanceOf(address(lidoARM)); + + // Get minimum amount of stETH to receive + // stETH = WETH / price + uint256 amountOutMin = + amountIn > STETH_ERROR_ROUNDING ? amountIn * 1e36 / price - STETH_ERROR_ROUNDING : amountIn * 1e36 / price; + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), amountIn); + // TODO hard to get the exact amount of stETH transferred as it depends on the rounding + // vm.expectEmit({emitter: address(steth)}); + // emit IERC20.Transfer(address(lidoARM), address(this), amountOutMin); + + // Main call + lidoARM.swapExactTokensForTokens( + weth, // inToken + steth, // outToken + amountIn, // amountIn + amountOutMin, // amountOutMin + address(this) // to + ); + + // Assertions + assertGe(lidoARM.totalAssets(), totalAssetsBefore, "total assets"); + assertEq(weth.balanceOf(address(this)), balanceWETHBeforeThis - amountIn, "user WETH balance"); + assertApproxEqAbs( + steth.balanceOf(address(this)), + balanceSTETHBeforeThis + amountOutMin, + STETH_ERROR_ROUNDING * 2, + "user stETH balance" + ); + assertEq(weth.balanceOf(address(lidoARM)), balanceWETHBeforeARM + amountIn, "ARM WETH balance"); + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), + balanceSTETHBeforeARM - amountOutMin, + STETH_ERROR_ROUNDING * 2, + "ARM stETH balance" + ); + } + + /// @notice Fuzz test for swapExactTokensForTokens(IERC20,IERC20,uint256,uint256,address), with stETH to WETH. + /// @param amountIn Amount of stETH to swap into the ARM. Fuzzed between 0 and WETH in the ARM. + /// @param wethReserveGrowth The amount WETH has grown in the ARM. Fuzzed between 0 and 1% of the INITIAL_BALANCE. + /// @param stethReserveGrowth Amount of stETH has grown in the ARM. Fuzzed between 0 and 1% of the INITIAL_BALANCE. + /// @param price Price of the stETH in WETH. Fuzzed between 1 and 1.02. + /// @param userStethBalance The amount of stETH the user has before the swap. + /// @param collectFees Whether to collect the accrued performance fees before the swap. + function test_SwapExactTokensForTokens_Steth_To_Weth( + uint256 amountIn, + uint256 wethReserveGrowth, + uint256 stethReserveGrowth, + uint256 price, + uint256 userStethBalance, + bool collectFees + ) public { + // Use random stETH/WETH buy price between MIN_PRICE0 and MAX_PRICE0, + // the sell price doesn't matter as it is not used in this test. + price = _bound(price, MIN_PRICE0, MAX_PRICE0); + lidoARM.setPrices(price, MAX_PRICE1); + + // Set random amount of WETH growth in the ARM + wethReserveGrowth = _bound(wethReserveGrowth, 0, INITIAL_BALANCE / 100); + deal(address(weth), address(lidoARM), INITIAL_BALANCE + wethReserveGrowth); + + // Set random amount of stETH growth in the ARM + stethReserveGrowth = _bound(stethReserveGrowth, 0, INITIAL_BALANCE / 100); + deal(address(steth), address(lidoARM), INITIAL_BALANCE + stethReserveGrowth); + + if (collectFees) { + // Collect and accrued performance fees before the swap + lidoARM.collectFees(); + } + + // Random amount of stETH to swap into the ARM + // As the price is below 1, we can take 100% of the balance of WETH of the ARM. + amountIn = _bound(amountIn, 0, weth.balanceOf(address(lidoARM)) * 1e36 / price); + deal(address(steth), address(this), amountIn); + + // Fuzz the user's stETH balance + userStethBalance = _bound(userStethBalance, amountIn, amountIn + 1 ether); + deal(address(steth), address(this), userStethBalance); + + // State before + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 userBalanceWETHBefore = weth.balanceOf(address(this)); + uint256 userBalanceSTETHBefore = steth.balanceOf(address(this)); + uint256 armBalanceWETHBefore = weth.balanceOf(address(lidoARM)); + uint256 armBalanceSTETHBefore = steth.balanceOf(address(lidoARM)); + + // Get minimum amount of WETH swapped out of the ARM + uint256 amountOutMin = amountIn * price / 1e36; + + // Expected events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(this), address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), address(this), amountOutMin); + + // Main call + lidoARM.swapExactTokensForTokens( + steth, // inToken + weth, // outToken + amountIn, + amountOutMin, + address(this) // to + ); + + // Assertions + // TODO change the ARM so it doesn't lose 1 wei of assets on any swaps + assertGe(lidoARM.totalAssets() + 1, totalAssetsBefore, "total assets"); + assertEq(weth.balanceOf(address(this)), userBalanceWETHBefore + amountOutMin, "user WETH balance"); + assertApproxEqAbs( + steth.balanceOf(address(this)), + userBalanceSTETHBefore - amountIn, + STETH_ERROR_ROUNDING * 2, + "user stETH balance" + ); + assertEq(weth.balanceOf(address(lidoARM)), armBalanceWETHBefore - amountOutMin, "ARM WETH balance"); + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), + armBalanceSTETHBefore + amountIn, + STETH_ERROR_ROUNDING * 2, + "ARM stETH balance" + ); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/SwapTokensForExactTokens.t.sol b/test/fork/LidoFixedPriceMultiLpARM/SwapTokensForExactTokens.t.sol new file mode 100644 index 0000000..8a86f17 --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/SwapTokensForExactTokens.t.sol @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +import {IERC20} from "contracts/Interfaces.sol"; + +contract Fork_Concrete_LidoARM_SwapTokensForExactTokens_Test is Fork_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- CONSTANTS + ////////////////////////////////////////////////////// + uint256 private constant MIN_PRICE0 = 980e33; // 0.98 + uint256 private constant MAX_PRICE0 = 1_000e33 - 1; // just under 1.00 + uint256 private constant MIN_PRICE1 = 1_000e33; // 1.00 + uint256 private constant MAX_PRICE1 = 1_020e33; // 1.02 + uint256 private constant INITIAL_BALANCE = 1_000 ether; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + + deal(address(weth), address(this), INITIAL_BALANCE); + deal(address(steth), address(this), INITIAL_BALANCE); + + deal(address(weth), address(lidoARM), INITIAL_BALANCE); + deal(address(steth), address(lidoARM), INITIAL_BALANCE); + + // We are artificially adding assets so collect the performance fees to reset the fees collected + lidoARM.collectFees(); + } + + ////////////////////////////////////////////////////// + /// --- REVERTING TESTS + ////////////////////////////////////////////////////// + function test_RevertWhen_SwapTokensForExactTokens_Because_InvalidTokenOut1() public { + lidoARM.token0(); + vm.expectRevert("ARM: Invalid out token"); + lidoARM.swapTokensForExactTokens( + steth, // inToken + badToken, // outToken + 1, // amountOut + 1, // amountOutMax + address(this) // to + ); + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_InvalidTokenOut0() public { + vm.expectRevert("ARM: Invalid out token"); + lidoARM.swapTokensForExactTokens( + weth, // inToken + badToken, // outToken + 1, // amountOut + 1, // amountOutMax + address(this) // to + ); + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_InvalidTokenIn() public { + vm.expectRevert("ARM: Invalid in token"); + lidoARM.swapTokensForExactTokens( + badToken, // inToken + steth, // outToken + 1, // amountOut + 1, // amountOutMax + address(this) // to + ); + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_BothInvalidTokens() public { + vm.expectRevert("ARM: Invalid in token"); + lidoARM.swapTokensForExactTokens( + badToken, // inToken + badToken, // outToken + 1, // amountOut + 1, // amountOutMax + address(this) // to + ); + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_NotEnoughTokenIn() public { + deal(address(weth), address(this), 0); + + vm.expectRevert(); + lidoARM.swapTokensForExactTokens( + weth, // inToken + steth, // outToken + 1, // amountOut + type(uint256).max, // amountOutMax + address(this) // to + ); + + deal(address(steth), address(this), 0); + vm.expectRevert(); + lidoARM.swapTokensForExactTokens( + steth, // inToken + weth, // outToken + STETH_ERROR_ROUNDING + 1, // amountOut * + type(uint256).max, // amountOutMax + address(this) // to + ); + // Note*: As deal can sometimes leave STETH_ERROR_ROUNDING to `to`, we need to try to transfer more. + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_NotEnoughTokenOut() public { + deal(address(weth), address(this), 0); + + vm.expectRevert(); + lidoARM.swapTokensForExactTokens( + weth, // inToken + steth, // outToken + 1 ether, // amountOut + type(uint256).max, // amountInMax + address(this) // to + ); + + deal(address(steth), address(this), 0); + vm.expectRevert("BALANCE_EXCEEDED"); // Lido error + lidoARM.swapTokensForExactTokens( + steth, // inToken + weth, // outToken + 1 ether, // amountOut + type(uint256).max, // amountInMax + address(this) // to + ); + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_InsufficientOutputAmount() public { + deal(address(steth), address(lidoARM), 100 wei); + + // Test for this function signature: swapTokensForExactTokens(IERC20,IERC20,uint56,uint256,address) + vm.expectRevert("ARM: Excess input amount"); + lidoARM.swapTokensForExactTokens( + weth, // inToken + steth, // outToken + 1, // amountOut + 0, // amountInMax + address(this) // to + ); + + // Test for this function signature: swapTokensForExactTokens(uint256,uint256,address[],address,uint256) + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(steth); + vm.expectRevert("ARM: Excess input amount"); + lidoARM.swapTokensForExactTokens( + 1, // amountOut + 0, // amountInMax + path, // path + address(this), // to + block.timestamp // deadline + ); + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_InvalidePathLength() public { + vm.expectRevert("ARM: Invalid path length"); + lidoARM.swapTokensForExactTokens( + 1, // amountOut + 1, // amountInMax + new address[](3), // path + address(this), // to + 0 // deadline + ); + } + + function test_RevertWhen_SwapTokensForExactTokens_Because_DeadlineExpired() public { + vm.expectRevert("ARM: Deadline expired"); + lidoARM.swapTokensForExactTokens( + 1, // amountOut + 1, // amountInMax + new address[](2), // path + address(this), // to + block.timestamp - 1 // deadline + ); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TESTS + ////////////////////////////////////////////////////// + function test_SwapTokensForExactTokens_WithDeadLine_Weth_To_Steth() public { + uint256 amountOut = 1 ether; + address[] memory path = new address[](2); + path[0] = address(weth); + path[1] = address(steth); + + // State before + uint256 balanceWETHBeforeThis = weth.balanceOf(address(this)); + uint256 balanceSTETHBeforeThis = steth.balanceOf(address(this)); + uint256 balanceWETHBeforeARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHBeforeARM = steth.balanceOf(address(lidoARM)); + + // Get maximum amount of WETH to send to the ARM + uint256 traderates0 = lidoARM.traderate0(); + uint256 amountIn = (amountOut * 1e36 / traderates0) + 3; + + // Expected events: Already checked in fuzz tests + + uint256[] memory outputs = new uint256[](2); + // Main call + outputs = lidoARM.swapTokensForExactTokens( + amountOut, // amountOut + amountIn, // amountInMax + path, // path + address(this), // to + block.timestamp // deadline + ); + + // State after + uint256 balanceWETHAfterThis = weth.balanceOf(address(this)); + uint256 balanceSTETHAfterThis = steth.balanceOf(address(this)); + uint256 balanceWETHAfterARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHAfterARM = steth.balanceOf(address(lidoARM)); + + // Assertions + assertEq(balanceWETHBeforeThis, balanceWETHAfterThis + amountIn, "WETH user balance"); + assertApproxEqAbs( + balanceSTETHBeforeThis + amountOut, balanceSTETHAfterThis, STETH_ERROR_ROUNDING, "STETH user balance" + ); + assertEq(balanceWETHBeforeARM + amountIn, balanceWETHAfterARM, "WETH ARM balance"); + assertApproxEqAbs( + balanceSTETHBeforeARM, balanceSTETHAfterARM + amountOut, STETH_ERROR_ROUNDING, "STETH ARM balance" + ); + assertEq(outputs[0], amountIn, "Amount in"); + assertEq(outputs[1], amountOut, "Amount out"); + } + + function test_SwapTokensForExactTokens_WithDeadLine_Steth_To_Weth() public { + uint256 amountOut = 1 ether; + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + + // State before + uint256 balanceWETHBeforeThis = weth.balanceOf(address(this)); + uint256 balanceSTETHBeforeThis = steth.balanceOf(address(this)); + uint256 balanceWETHBeforeARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHBeforeARM = steth.balanceOf(address(lidoARM)); + + // Get maximum amount of stETH to send to the ARM + uint256 traderates1 = lidoARM.traderate1(); + uint256 amountIn = (amountOut * 1e36 / traderates1) + 3; + + // Expected events: Already checked in fuzz tests + + uint256[] memory outputs = new uint256[](2); + // Main call + outputs = lidoARM.swapTokensForExactTokens( + amountOut, // amountOut + amountIn, // amountInMax + path, // path + address(this), // to + block.timestamp // deadline + ); + + // State after + uint256 balanceWETHAfterThis = weth.balanceOf(address(this)); + uint256 balanceSTETHAfterThis = steth.balanceOf(address(this)); + uint256 balanceWETHAfterARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHAfterARM = steth.balanceOf(address(lidoARM)); + + // Assertions + assertEq(balanceWETHBeforeThis + amountOut, balanceWETHAfterThis, "WETH user balance"); + assertApproxEqAbs( + balanceSTETHBeforeThis, balanceSTETHAfterThis + amountIn, STETH_ERROR_ROUNDING, "STETH user balance" + ); + assertEq(balanceWETHBeforeARM, balanceWETHAfterARM + amountOut, "WETH ARM balance"); + assertApproxEqAbs( + balanceSTETHBeforeARM + amountIn, balanceSTETHAfterARM, STETH_ERROR_ROUNDING, "STETH ARM balance" + ); + assertEq(outputs[0], amountIn, "Amount in"); + assertEq(outputs[1], amountOut, "Amount out"); + } + + /// @notice If the buy and sell prices are very close together and the stETH transferred into + /// the ARM is truncated, then there should be enough rounding protection against losing total assets. + function test_SwapTokensForExactTokens_Steth_Transfer_Truncated() + public + disableCaps + setArmBalances(MIN_TOTAL_SUPPLY, 0) + setPrices(1e36 - 1, 1e36, 1e36) + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // The exact amount of WETH to receive + uint256 amountOut = DEFAULT_AMOUNT; + // The max amount of stETH to send + uint256 amountInMax = amountOut + 3; + deal(address(steth), address(this), amountInMax); // Deal more as AmountIn is greater than AmountOut + + // State before + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), address(this), amountOut); + + // Main call + lidoARM.swapTokensForExactTokens( + steth, // inToken + weth, // outToken + amountOut, // amountOut + amountInMax, // amountInMax + address(this) // to + ); + + // Assertions + assertGe(lidoARM.totalAssets(), totalAssetsBefore, "total assets after"); + } + + ////////////////////////////////////////////////////// + /// --- FUZZING TESTS + ////////////////////////////////////////////////////// + /// @notice Fuzz test for swapTokensForExactTokens(IERC20,IERC20,uint256,uint256,address), with WETH to stETH. + /// @param amountOut Exact amount of stETH to swap out of the ARM. Fuzzed between 0 and stETH in the ARM. + /// @param wethReserveGrowth The amount WETH has grown in the ARM. Fuzzed between 0 and 1% of the INITIAL_BALANCE. + /// @param stethReserveGrowth Amount of stETH has grown in the ARM. Fuzzed between 0 and 1% of the INITIAL_BALANCE. + /// @param price Sell price of the stETH in WETH (stETH/WETH). Fuzzed between 1 and 1.02. + /// @param collectFees Whether to collect the accrued performance fees before the swap. + function test_SwapTokensForExactTokens_Weth_To_Steth( + uint256 amountOut, + uint256 wethReserveGrowth, + uint256 stethReserveGrowth, + uint256 price, + bool collectFees + ) public { + // Use random sell price between 1 and 1.02 for the stETH/WETH price, + // The buy price doesn't matter as it is not used in this test. + price = _bound(price, MIN_PRICE1, MAX_PRICE1); + lidoARM.setPrices(MIN_PRICE0, price); + + // Set random amount of WETH in the ARM + wethReserveGrowth = _bound(wethReserveGrowth, 0, INITIAL_BALANCE / 100); + deal(address(weth), address(lidoARM), INITIAL_BALANCE + wethReserveGrowth); + + // Set random amount of stETH in the ARM + stethReserveGrowth = _bound(stethReserveGrowth, 0, INITIAL_BALANCE / 100); + deal(address(steth), address(lidoARM), INITIAL_BALANCE + stethReserveGrowth); + + if (collectFees) { + // Collect and accrued performance fees before the swap + lidoARM.collectFees(); + } + + // Calculate the amount of stETH to swap out of the ARM + amountOut = _bound(amountOut, 0, steth.balanceOf(address(lidoARM))); + + // Get the maximum amount of WETH to swap into the ARM + // weth = steth * stETH/WETH price + uint256 amountIn = (amountOut * price / 1e36) + 3; + + deal(address(weth), address(this), amountIn); + + // State before + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 balanceWETHBeforeThis = weth.balanceOf(address(this)); + uint256 balanceSTETHBeforeThis = steth.balanceOf(address(this)); + uint256 balanceWETHBeforeARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHBeforeARM = steth.balanceOf(address(lidoARM)); + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), amountIn); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), address(this), amountOut); + + // Main call + lidoARM.swapTokensForExactTokens( + weth, // inToken + steth, // outToken + amountOut, // amountOut + amountIn, // amountInMax + address(this) // to + ); + + // Assertions + assertGe(lidoARM.totalAssets(), totalAssetsBefore, "total assets after"); + assertEq(weth.balanceOf(address(this)), balanceWETHBeforeThis - amountIn, "WETH user balance"); + assertApproxEqAbs( + steth.balanceOf(address(this)), + balanceSTETHBeforeThis + amountOut, + STETH_ERROR_ROUNDING, + "STETH user balance" + ); + assertEq(weth.balanceOf(address(lidoARM)), balanceWETHBeforeARM + amountIn, "WETH ARM balance"); + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), + balanceSTETHBeforeARM - amountOut, + STETH_ERROR_ROUNDING, + "STETH ARM balance" + ); + } + + /// @notice Fuzz test for swapTokensForExactTokens(IERC20,IERC20,uint256,uint256,address), with stETH to WETH. + /// @param amountOut Exact amount of WETH to swap out of the ARM. Fuzzed between 0 and WETH in the ARM. + /// @param wethReserveGrowth The amount WETH has grown in the ARM. Fuzzed between 0 and 1% of the INITIAL_BALANCE. + /// @param stethReserveGrowth Amount of stETH has grown in the ARM. Fuzzed between 0 and 1% of the INITIAL_BALANCE. + /// @param price Buy price of the stETH in WETH (stETH/WETH). Fuzzed between 0.998 and 1.02. + /// @param userStethBalance The amount of stETH the user has before the swap. + /// @param collectFees Whether to collect the accrued performance fees before the swap. + function test_SwapTokensForExactTokens_Steth_To_Weth( + uint256 amountOut, + uint256 wethReserveGrowth, + uint256 stethReserveGrowth, + uint256 price, + uint256 userStethBalance, + bool collectFees + ) public { + lidoARM.collectFees(); + + // Use random stETH/WETH buy price between 0.98 and 1, + // sell price doesn't matter as it is not used in this test. + price = _bound(price, MIN_PRICE0, MAX_PRICE0); + lidoARM.setPrices(price, MAX_PRICE1); + + // Set random amount of WETH growth in the ARM + wethReserveGrowth = _bound(wethReserveGrowth, 0, INITIAL_BALANCE / 100); + deal(address(weth), address(lidoARM), INITIAL_BALANCE + wethReserveGrowth); + + // Set random amount of stETH growth in the ARM + stethReserveGrowth = _bound(stethReserveGrowth, 0, INITIAL_BALANCE / 100); + deal(address(steth), address(lidoARM), INITIAL_BALANCE + stethReserveGrowth); + + if (collectFees) { + // Collect and accrued performance fees before the swap + lidoARM.collectFees(); + } + + // Calculate the amount of WETH to swap out of the ARM + // Can take up to 100% of the WETH in the ARM even if there is some for the performance fee. + amountOut = _bound(amountOut, 0, weth.balanceOf(address(lidoARM))); + // Get the maximum amount of stETH to swap into of the ARM + // stETH = WETH / stETH/WETH price + uint256 amountIn = (amountOut * 1e36 / price) + 3; + + // Fuzz the user's stETH balance + userStethBalance = _bound(userStethBalance, amountIn, amountIn + 1 ether); + deal(address(steth), address(this), userStethBalance); + + // State before + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 balanceWETHBeforeThis = weth.balanceOf(address(this)); + uint256 balanceSTETHBeforeThis = steth.balanceOf(address(this)); + uint256 balanceWETHBeforeARM = weth.balanceOf(address(lidoARM)); + uint256 balanceSTETHBeforeARM = steth.balanceOf(address(lidoARM)); + + // Expected events + // TODO hard to check the exact amount of stETH due to rounding + // vm.expectEmit({emitter: address(steth)}); + // emit IERC20.Transfer(address(this), address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), address(this), amountOut); + + // Main call + lidoARM.swapTokensForExactTokens( + steth, // inToken + weth, // outToken + amountOut, // amountOut + amountIn + 2 * STETH_ERROR_ROUNDING, // amountInMax + address(this) // to + ); + + // Assertions + assertGe(lidoARM.totalAssets(), totalAssetsBefore, "total assets after"); + assertEq(weth.balanceOf(address(this)), balanceWETHBeforeThis + amountOut, "WETH user balance"); + assertApproxEqAbs( + steth.balanceOf(address(this)), + balanceSTETHBeforeThis - amountIn, + STETH_ERROR_ROUNDING, + "STETH user balance" + ); + assertEq(weth.balanceOf(address(lidoARM)), balanceWETHBeforeARM - amountOut, "WETH ARM balance"); + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), + balanceSTETHBeforeARM + amountIn, + STETH_ERROR_ROUNDING, + "STETH ARM balance" + ); + } +} diff --git a/test/fork/LidoFixedPriceMultiLpARM/TotalAssets.t.sol b/test/fork/LidoFixedPriceMultiLpARM/TotalAssets.t.sol new file mode 100644 index 0000000..3bd9f5d --- /dev/null +++ b/test/fork/LidoFixedPriceMultiLpARM/TotalAssets.t.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {stdError} from "forge-std/StdError.sol"; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; + +contract Fork_Concrete_LidoARM_TotalAssets_Test_ is Fork_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + + // Set Cap to max, as not to interfere with the tests + address[] memory providers = new address[](1); + providers[0] = address(this); + capManager.setLiquidityProviderCaps(providers, type(uint256).max); + capManager.setTotalAssetsCap(type(uint248).max); + + deal(address(weth), address(this), 1_000 ether); + weth.approve(address(lidoARM), type(uint256).max); + } + + ////////////////////////////////////////////////////// + /// --- PASSING TEST + ////////////////////////////////////////////////////// + function test_TotalAssets_AfterInitialization() public view { + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY); + } + + function test_TotalAssets_AfterDeposit_NoAssetGainOrLoss() public depositInLidoARM(address(this), DEFAULT_AMOUNT) { + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT); + } + + function test_TotalAssets_AfterDeposit_WithAssetGain_InWETH() + public + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // Simulate asset gain + uint256 assetGain = DEFAULT_AMOUNT / 2; + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + assetGain); + + // Calculate Fees + uint256 fee = assetGain * 20 / 100; // 20% fee + + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT + assetGain - fee); + } + + function test_TotalAssets_AfterDeposit_WithAssetGain_InSTETH() + public + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + assertEq(steth.balanceOf(address(lidoARM)), 0); + // Simulate asset gain + uint256 assetGain = DEFAULT_AMOUNT / 2 + 1; + // We are sure that steth balance is empty, so we can deal directly final amount. + deal(address(steth), address(lidoARM), assetGain); + + // Calculate Fees + uint256 fee = assetGain * 20 / 100; // 20% fee + + assertApproxEqAbs( + lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT + assetGain - fee, STETH_ERROR_ROUNDING + ); + } + + function test_TotalAssets_AfterDeposit_WithAssetLoss_InWETH() + public + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // Simulate asset loss + uint256 assetLoss = DEFAULT_AMOUNT / 2; + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) - assetLoss); + + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT - assetLoss); + } + + function test_TotalAssets_AfterDeposit_WithAssetLoss_InSTETH() + public + depositInLidoARM(address(this), DEFAULT_AMOUNT) + { + // Simulate Swap at 1:1 between WETH and stETH + uint256 swapAmount = DEFAULT_AMOUNT / 2; + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) - swapAmount); + // Then simulate a loss on stETH, do all in the same deal + uint256 assetLoss = swapAmount / 2; + deal(address(steth), address(lidoARM), swapAmount / 2); + + assertApproxEqAbs(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT - assetLoss, STETH_ERROR_ROUNDING); + } + + function test_TotalAssets_After_WithdrawingFromLido() public { + // Simulate a Swap at 1:1 between WETH and stETH using initial liquidity + uint256 swapAmount = MIN_TOTAL_SUPPLY / 2; + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) - swapAmount); + deal(address(steth), address(lidoARM), swapAmount); // Empty stETH balance, so we can deal directly + + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Request a redeem on Lido + uint256[] memory amounts = new uint256[](1); + amounts[0] = swapAmount; + lidoARM.requestLidoWithdrawals(amounts); + + // Check total assets after withdrawal is the same as before + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore, STETH_ERROR_ROUNDING); + } + + function test_TotalAssets_With_FeeAccrued_NotNull() public { + uint256 assetGain = DEFAULT_AMOUNT; + // Simulate asset gain + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + assetGain); + + // User deposit, this will trigger a fee calculation + lidoARM.deposit(DEFAULT_AMOUNT); + + // Assert fee accrued is not null + assertEq(lidoARM.feesAccrued(), assetGain * 20 / 100); + + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT + assetGain - assetGain * 20 / 100); + } + + function test_TotalAssets_When_ARMIsInsolvent() + public + depositInLidoARM(address(this), DEFAULT_AMOUNT) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + { + // Simulate a loss of assets + deal(address(weth), address(lidoARM), DEFAULT_AMOUNT - 1); + + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY); + } + + function test_RevertWhen_TotalAssets_Because_MathError() + public + depositInLidoARM(address(this), DEFAULT_AMOUNT) + simulateAssetGainInLidoARM(DEFAULT_AMOUNT, address(weth), true) + requestRedeemFromLidoARM(address(this), DEFAULT_AMOUNT) + simulateAssetGainInLidoARM(DEFAULT_AMOUNT * 2, address(weth), false) + { + // vm.expectRevert(stdError.arithmeticError); + assertEq(lidoARM.totalAssets(), MIN_TOTAL_SUPPLY); + } +} diff --git a/test/fork/mainnet/Ownable.t.sol b/test/fork/OethARM/Ownable.t.sol similarity index 100% rename from test/fork/mainnet/Ownable.t.sol rename to test/fork/OethARM/Ownable.t.sol diff --git a/test/fork/mainnet/Proxy.t.sol b/test/fork/OethARM/Proxy.t.sol similarity index 100% rename from test/fork/mainnet/Proxy.t.sol rename to test/fork/OethARM/Proxy.t.sol diff --git a/test/fork/mainnet/SwapExactTokensForTokens.t.sol b/test/fork/OethARM/SwapExactTokensForTokens.t.sol similarity index 89% rename from test/fork/mainnet/SwapExactTokensForTokens.t.sol rename to test/fork/OethARM/SwapExactTokensForTokens.t.sol index 04e9aeb..111afa2 100644 --- a/test/fork/mainnet/SwapExactTokensForTokens.t.sol +++ b/test/fork/OethARM/SwapExactTokensForTokens.t.sol @@ -9,12 +9,18 @@ import {IERC20} from "contracts/Interfaces.sol"; /// @notice The purpose of this contract is to test the `swapExactTokensForTokens` function in the `OethARM` contract. contract Fork_Concrete_OethARM_SwapExactTokensForTokens_Test_ is Fork_Shared_Test_ { + address[] path; + ////////////////////////////////////////////////////// /// --- SETUP ////////////////////////////////////////////////////// function setUp() public override { super.setUp(); + path = new address[](2); + path[0] = address(oeth); + path[1] = address(weth); + // Deal tokens deal(address(oeth), address(this), 100 ether); deal(address(weth), address(oethARM), 100 ether); @@ -27,10 +33,9 @@ contract Fork_Concrete_OethARM_SwapExactTokensForTokens_Test_ is Fork_Shared_Tes ////////////////////////////////////////////////////// /// --- REVERTING TESTS ////////////////////////////////////////////////////// - function test_RevertWhen_SwapExactTokensForTokens_Simple_Because_InsufficientOutputAmount() public { vm.expectRevert("ARM: Insufficient output amount"); - oethARM.swapExactTokensForTokens(weth, oeth, 10 ether, 11 ether, address(this)); + oethARM.swapExactTokensForTokens(oeth, weth, 10 ether, 11 ether, address(this)); } function test_RevertWhen_SwapExactTokensForTokens_Simple_Because_InvalidSwap_TokenIn() public { @@ -45,7 +50,7 @@ contract Fork_Concrete_OethARM_SwapExactTokensForTokens_Test_ is Fork_Shared_Tes function test_RevertWhen_SwapExactTokensForTokens_Complex_Because_InsuficientOutputAmount() public { vm.expectRevert("ARM: Insufficient output amount"); - oethARM.swapExactTokensForTokens(10 ether, 11 ether, new address[](2), address(this), 0); + oethARM.swapExactTokensForTokens(10 ether, 11 ether, path, address(this), block.timestamp + 10); } function test_RevertWhen_SwapExactTokensForTokens_Complex_Because_InvalidPathLength() public { @@ -55,23 +60,21 @@ contract Fork_Concrete_OethARM_SwapExactTokensForTokens_Test_ is Fork_Shared_Tes function test_RevertWhen_SwapExactTokensForTokens_Complex_Because_DeadlineExpired() public { vm.expectRevert("ARM: Deadline expired"); - oethARM.swapExactTokensForTokens(10 ether, 10 ether, new address[](2), address(this), 0); + oethARM.swapExactTokensForTokens(10 ether, 10 ether, path, address(this), 0); } function test_RevertWhen_SwapExactTokensForTokens_Complex_Because_InvalidSwap_TokenIn() public { - address[] memory path = new address[](2); path[0] = address(weth); path[1] = address(weth); vm.expectRevert("ARM: Invalid swap"); - oethARM.swapExactTokensForTokens(10 ether, 10 ether, new address[](2), address(this), block.timestamp + 1000); + oethARM.swapExactTokensForTokens(10 ether, 10 ether, path, address(this), block.timestamp + 10); } function test_RevertWhen_SwapExactTokensForTokens_Complex_Because_InvalidSwap_TokenOut() public { - address[] memory path = new address[](2); path[0] = address(oeth); path[1] = address(oeth); vm.expectRevert("ARM: Invalid swap"); - oethARM.swapExactTokensForTokens(10 ether, 10 ether, new address[](2), address(this), block.timestamp + 1000); + oethARM.swapExactTokensForTokens(10 ether, 10 ether, path, address(this), block.timestamp + 10); } ////////////////////////////////////////////////////// @@ -106,7 +109,6 @@ contract Fork_Concrete_OethARM_SwapExactTokensForTokens_Test_ is Fork_Shared_Tes assertEq(weth.balanceOf(address(oethARM)), 100 ether, "OETH balance ARM"); assertEq(weth.balanceOf(address(oethARM)), 100 ether, "WETH balance ARM"); - address[] memory path = new address[](2); path[0] = address(oeth); path[1] = address(weth); diff --git a/test/fork/mainnet/SwapTokensForExactTokens.t.sol b/test/fork/OethARM/SwapTokensForExactTokens.t.sol similarity index 88% rename from test/fork/mainnet/SwapTokensForExactTokens.t.sol rename to test/fork/OethARM/SwapTokensForExactTokens.t.sol index 3345df1..3e01237 100644 --- a/test/fork/mainnet/SwapTokensForExactTokens.t.sol +++ b/test/fork/OethARM/SwapTokensForExactTokens.t.sol @@ -9,12 +9,18 @@ import {IERC20} from "contracts/Interfaces.sol"; /// @notice The purpose of this contract is to test the `swapTokensForExactTokens` function in the `OethARM` contract. contract Fork_Concrete_OethARM_SwapTokensForExactTokens_Test_ is Fork_Shared_Test_ { + address[] path; + ////////////////////////////////////////////////////// /// --- SETUP ////////////////////////////////////////////////////// function setUp() public override { super.setUp(); + path = new address[](2); + path[0] = address(oeth); + path[1] = address(weth); + // Deal tokens deal(address(oeth), address(this), 100 ether); deal(address(weth), address(oethARM), 100 ether); @@ -27,10 +33,9 @@ contract Fork_Concrete_OethARM_SwapTokensForExactTokens_Test_ is Fork_Shared_Tes ////////////////////////////////////////////////////// /// --- REVERTING TESTS ////////////////////////////////////////////////////// - function test_RevertWhen_SwapTokensForExactTokens_Simple_Because_InsufficientOutputAmount() public { vm.expectRevert("ARM: Excess input amount"); - oethARM.swapTokensForExactTokens(weth, oeth, 10 ether, 9 ether, address(this)); + oethARM.swapTokensForExactTokens(oeth, weth, 10 ether, 9 ether, address(this)); } function test_RevertWhen_SwapTokensForExactTokens_Simple_Because_InvalidSwap_TokenIn() public { @@ -45,33 +50,31 @@ contract Fork_Concrete_OethARM_SwapTokensForExactTokens_Test_ is Fork_Shared_Tes function test_RevertWhen_SwapTokensForExactTokens_Complex_Because_InsufficientOutputAmount() public { vm.expectRevert("ARM: Excess input amount"); - oethARM.swapTokensForExactTokens(10 ether, 9 ether, new address[](2), address(this), 0); + oethARM.swapTokensForExactTokens(10 ether, 9 ether, path, address(this), block.timestamp + 10); } function test_RevertWhen_SwapTokensForExactTokens_Complex_Because_InvalidPathLength() public { vm.expectRevert("ARM: Invalid path length"); - oethARM.swapTokensForExactTokens(10 ether, 10 ether, new address[](3), address(this), 0); + oethARM.swapTokensForExactTokens(10 ether, 10 ether, new address[](3), address(this), block.timestamp + 10); } function test_RevertWhen_SwapTokensForExactTokens_Complex_Because_DeadlineExpired() public { vm.expectRevert("ARM: Deadline expired"); - oethARM.swapTokensForExactTokens(10 ether, 10 ether, new address[](2), address(this), 0); + oethARM.swapTokensForExactTokens(10 ether, 10 ether, path, address(this), block.timestamp - 1); } function test_RevertWhen_SwapTokensForExactTokens_Complex_Because_InvalidSwap_TokenIn() public { - address[] memory path = new address[](2); path[0] = address(weth); path[1] = address(weth); vm.expectRevert("ARM: Invalid swap"); - oethARM.swapTokensForExactTokens(10 ether, 10 ether, new address[](2), address(this), block.timestamp + 1000); + oethARM.swapTokensForExactTokens(10 ether, 10 ether, path, address(this), block.timestamp + 10); } function test_RevertWhen_SwapTokensForExactTokens_Complex_Because_InvalidSwap_TokenOut() public { - address[] memory path = new address[](2); path[0] = address(oeth); path[1] = address(oeth); vm.expectRevert("ARM: Invalid swap"); - oethARM.swapTokensForExactTokens(10 ether, 10 ether, new address[](2), address(this), block.timestamp + 1000); + oethARM.swapTokensForExactTokens(10 ether, 10 ether, path, address(this), block.timestamp + 10); } ////////////////////////////////////////////////////// @@ -106,7 +109,6 @@ contract Fork_Concrete_OethARM_SwapTokensForExactTokens_Test_ is Fork_Shared_Tes assertEq(weth.balanceOf(address(oethARM)), 100 ether, "OETH balance ARM"); assertEq(weth.balanceOf(address(oethARM)), 100 ether, "WETH balance ARM"); - address[] memory path = new address[](2); path[0] = address(oeth); path[1] = address(weth); // Expected events diff --git a/test/fork/mainnet/Transfer.t.sol b/test/fork/OethARM/Transfer.t.sol similarity index 100% rename from test/fork/mainnet/Transfer.t.sol rename to test/fork/OethARM/Transfer.t.sol diff --git a/test/fork/mainnet/Withdraw.t.sol b/test/fork/OethARM/Withdraw.t.sol similarity index 92% rename from test/fork/mainnet/Withdraw.t.sol rename to test/fork/OethARM/Withdraw.t.sol index 50c4753..f63728e 100644 --- a/test/fork/mainnet/Withdraw.t.sol +++ b/test/fork/OethARM/Withdraw.t.sol @@ -63,8 +63,8 @@ contract Fork_Concrete_OethARM_Withdraw_Test_ is Fork_Shared_Test_ { (uint256 requestId,) = oethARM.requestWithdrawal(1 ether); // Add more liquidity to facilitate withdrawal - (, uint128 claimable, uint128 claimed,) = vault.withdrawalQueueMetadata(); - deal(address(weth), address(vault), claimable - claimed + 1 ether); + (uint128 queued,, uint128 claimed,) = vault.withdrawalQueueMetadata(); + deal(address(weth), address(vault), queued - claimed + 1 ether); // Add liquidity to the withdrawal queue vault.addWithdrawalQueueLiquidity(); @@ -87,8 +87,8 @@ contract Fork_Concrete_OethARM_Withdraw_Test_ is Fork_Shared_Test_ { oethARM.requestWithdrawal(1 ether); // Add more liquidity to facilitate withdrawal - (, uint128 claimable, uint128 claimed,) = vault.withdrawalQueueMetadata(); - deal(address(weth), address(vault), claimable - claimed + 2 ether); + (uint128 queued,, uint128 claimed,) = vault.withdrawalQueueMetadata(); + deal(address(weth), address(vault), queued - claimed + 2 ether); // Skip withdrawal queue delay skip(10 minutes); diff --git a/test/fork/Zapper/Deposit.t.sol b/test/fork/Zapper/Deposit.t.sol new file mode 100644 index 0000000..f2f1522 --- /dev/null +++ b/test/fork/Zapper/Deposit.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {CapManager} from "src/contracts/CapManager.sol"; +import {ZapperLidoARM} from "contracts/ZapperLidoARM.sol"; + +contract Fork_Concrete_ZapperLidoARM_Deposit_Test_ is Fork_Shared_Test_ { + function setUp() public override { + super.setUp(); + + vm.deal(address(this), DEFAULT_AMOUNT); + } + + function test_Deposit_ViaFunction() public enableCaps { + assertEq(lidoARM.balanceOf(address(this)), 0); + uint256 expectedShares = lidoARM.previewDeposit(DEFAULT_AMOUNT); + uint256 capBefore = capManager.liquidityProviderCaps(address(this)); + + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(address(this), capBefore - DEFAULT_AMOUNT); + vm.expectEmit({emitter: address(zapperLidoARM)}); + emit ZapperLidoARM.Zap(address(this), DEFAULT_AMOUNT, expectedShares); + // Deposit + zapperLidoARM.deposit{value: DEFAULT_AMOUNT}(); + + // Check balance + assertEq(lidoARM.balanceOf(address(this)), DEFAULT_AMOUNT); + assertEq(capManager.liquidityProviderCaps(address(this)), capBefore - DEFAULT_AMOUNT); + } + + function test_Deposit_ViaCall() public enableCaps { + assertEq(lidoARM.balanceOf(address(this)), 0); + uint256 expectedShares = lidoARM.previewDeposit(DEFAULT_AMOUNT); + uint256 capBefore = capManager.liquidityProviderCaps(address(this)); + + vm.expectEmit({emitter: address(capManager)}); + emit CapManager.LiquidityProviderCap(address(this), capBefore - DEFAULT_AMOUNT); + vm.expectEmit({emitter: address(zapperLidoARM)}); + emit ZapperLidoARM.Zap(address(this), DEFAULT_AMOUNT, expectedShares); + // Deposit + (bool success,) = address(zapperLidoARM).call{value: DEFAULT_AMOUNT}(""); + assertTrue(success); + + // Check balance + assertEq(lidoARM.balanceOf(address(this)), DEFAULT_AMOUNT); + assertEq(capManager.liquidityProviderCaps(address(this)), capBefore - DEFAULT_AMOUNT); + } +} diff --git a/test/fork/Zapper/RescueToken.sol b/test/fork/Zapper/RescueToken.sol new file mode 100644 index 0000000..22aac8d --- /dev/null +++ b/test/fork/Zapper/RescueToken.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// Test imports +import {Fork_Shared_Test_} from "test/fork/shared/Shared.sol"; + +// Contracts +import {ZapperLidoARM} from "contracts/ZapperLidoARM.sol"; + +contract Fork_Concrete_ZapperLidoARM_RescueToken_Test_ is Fork_Shared_Test_ { + function test_RevertWhen_RescueToken_CalledByNonOwner() public asRandomAddress { + vm.expectRevert("ARM: Only owner can call this function."); + zapperLidoARM.rescueERC20(address(badToken), DEFAULT_AMOUNT); + } + + function test_RescueToken() public { + deal(address(weth), address(zapperLidoARM), DEFAULT_AMOUNT); + assertEq(weth.balanceOf(address(zapperLidoARM)), DEFAULT_AMOUNT); + assertEq(weth.balanceOf(address(this)), 0); + + // Rescue the tokens + vm.prank(zapperLidoARM.owner()); + zapperLidoARM.rescueERC20(address(weth), DEFAULT_AMOUNT); + + // Check balance + assertEq(weth.balanceOf(address(zapperLidoARM)), 0); + assertEq(weth.balanceOf(address(this)), DEFAULT_AMOUNT); + } +} diff --git a/test/fork/shared/Shared.sol b/test/fork/shared/Shared.sol index c4da811..93a259d 100644 --- a/test/fork/shared/Shared.sol +++ b/test/fork/shared/Shared.sol @@ -10,12 +10,16 @@ import {Modifiers} from "test/fork/utils/Modifiers.sol"; // Contracts import {Proxy} from "contracts/Proxy.sol"; import {OethARM} from "contracts/OethARM.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {ZapperLidoARM} from "contracts/ZapperLidoARM.sol"; // Interfaces import {IERC20} from "contracts/Interfaces.sol"; import {IOETHVault} from "contracts/Interfaces.sol"; // Utils +import {Mainnet} from "contracts/utils/Addresses.sol"; import {AddressResolver} from "contracts/utils/Addresses.sol"; /// @notice This contract should inherit (directly or indirectly) from `Base_Test_`. @@ -74,13 +78,19 @@ abstract contract Fork_Shared_Test_ is Modifiers { require(vm.envExists("PROVIDER_URL"), "PROVIDER_URL not set"); // Create and select a fork. - forkId = vm.createSelectFork(vm.envString("PROVIDER_URL")); + if (vm.envExists("FORK_BLOCK_NUMBER_MAINNET")) { + forkId = vm.createSelectFork("mainnet", vm.envUint("FORK_BLOCK_NUMBER_MAINNET")); + } else { + forkId = vm.createSelectFork("mainnet"); + } } function _generateAddresses() internal { // Users and multisigs alice = makeAddr("alice"); deployer = makeAddr("deployer"); + feeCollector = makeAddr("fee collector"); + operator = resolver.resolve("OPERATOR"); governor = resolver.resolve("GOVERNOR"); oethWhale = resolver.resolve("WHALE_OETH"); @@ -88,13 +98,19 @@ abstract contract Fork_Shared_Test_ is Modifiers { // Contracts. oeth = IERC20(resolver.resolve("OETH")); weth = IERC20(resolver.resolve("WETH")); + steth = IERC20(resolver.resolve("STETH")); + wsteth = IERC20(resolver.resolve("WSTETH")); vault = IOETHVault(resolver.resolve("OETH_VAULT")); + badToken = IERC20(vm.randomAddress()); } function _deployContracts() internal { - // Deploy Proxy. + // --- Deploy all proxies --- proxy = new Proxy(); + lpcProxy = new Proxy(); + lidoProxy = new Proxy(); + // --- Deploy OethARM implementation --- // Deploy OethARM implementation. address implementation = address(new OethARM(address(oeth), address(weth), address(vault))); vm.label(implementation, "OETH ARM IMPLEMENTATION"); @@ -105,14 +121,67 @@ abstract contract Fork_Shared_Test_ is Modifiers { // Set the Proxy as the OethARM. oethARM = OethARM(address(proxy)); + + // --- Deploy CapManager implementation --- + // Deploy CapManager implementation. + CapManager capManagerImpl = new CapManager(address(lidoProxy)); + + // Initialize Proxy with CapManager implementation. + lpcProxy.initialize(address(capManagerImpl), address(this), data); + + // Set the Proxy as the CapManager. + capManager = CapManager(payable(address(lpcProxy))); + + capManager.setTotalAssetsCap(100 ether); + + address[] memory liquidityProviders = new address[](1); + liquidityProviders[0] = address(this); + capManager.setLiquidityProviderCaps(liquidityProviders, 20 ether); + capManager.setTotalAssetsCap(100 ether); + + // --- Deploy LidoARM implementation --- + // Deploy LidoARM implementation. + LidoARM lidoImpl = new LidoARM(address(steth), address(weth), Mainnet.LIDO_WITHDRAWAL, 10 minutes); + + // Deployer will need WETH to initialize the ARM. + deal(address(weth), address(this), 1e12); + weth.approve(address(lidoProxy), type(uint256).max); + steth.approve(address(lidoProxy), type(uint256).max); + + // Initialize Proxy with LidoARM implementation. + data = abi.encodeWithSignature( + "initialize(string,string,address,uint256,address,address)", + "Lido ARM", + "ARM-ST", + operator, + 2000, // 20% performance fee + feeCollector, + address(lpcProxy) + ); + lidoProxy.initialize(address(lidoImpl), address(this), data); + + // Set the Proxy as the LidoARM. + lidoARM = LidoARM(payable(address(lidoProxy))); + + // set prices + lidoARM.setPrices(992 * 1e33, 1001 * 1e33); + + // --- Deploy ZapperLidoARM --- + zapperLidoARM = new ZapperLidoARM(address(weth), address(lidoProxy)); } function _label() internal { vm.label(address(oeth), "OETH"); vm.label(address(weth), "WETH"); + vm.label(address(steth), "stETH"); + vm.label(address(badToken), "BAD TOKEN"); vm.label(address(vault), "OETH VAULT"); vm.label(address(oethARM), "OETH ARM"); vm.label(address(proxy), "OETH ARM PROXY"); + vm.label(address(lidoARM), "LIDO ARM"); + vm.label(address(lidoProxy), "LIDO ARM PROXY"); + vm.label(address(capManager), "LIQUIDITY PROVIDER CONTROLLER"); + vm.label(address(zapperLidoARM), "ZAPPER LIDO ARM"); vm.label(operator, "OPERATOR"); vm.label(oethWhale, "WHALE OETH"); vm.label(governor, "GOVERNOR"); diff --git a/test/fork/utils/Helpers.sol b/test/fork/utils/Helpers.sol index 29989ed..4ef8bee 100644 --- a/test/fork/utils/Helpers.sol +++ b/test/fork/utils/Helpers.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.23; import {Base_Test_} from "test/Base.sol"; abstract contract Helpers is Base_Test_ { - /// @notice Override `deal()` function to handle OETH special case. + /// @notice Override `deal()` function to handle OETH and STETH special case. function deal(address token, address to, uint256 amount) internal override { // Handle OETH special case, as rebasing tokens are not supported by the VM. if (token == address(oeth)) { @@ -15,8 +15,49 @@ abstract contract Helpers is Base_Test_ { // Transfer OETH from WHALE_OETH to the user. vm.prank(oethWhale); oeth.transfer(to, amount); + } else if (token == address(steth)) { + // Check than whale as enough stETH. Whale is wsteth contract. + require(steth.balanceOf(address(wsteth)) >= amount, "Fork_Shared_Test_: Not enough stETH in WHALE_stETH"); + + if (amount == 0) { + vm.startPrank(to); + steth.transfer(address(0x1), steth.balanceOf(to)); + vm.stopPrank(); + } else { + // Transfer stETH from WHALE_stETH to the user. + vm.prank(address(wsteth)); + steth.transfer(to, amount); + } } else { super.deal(token, to, amount); } } + + /// @notice Asserts the equality between value of `withdrawalQueueMetadata()` and the expected values. + function assertEqQueueMetadata(uint256 expectedQueued, uint256 expectedClaimed, uint256 expectedNextIndex) + public + view + { + assertEq(lidoARM.withdrawsQueued(), expectedQueued, "metadata queued"); + assertEq(lidoARM.withdrawsClaimed(), expectedClaimed, "metadata claimed"); + assertEq(lidoARM.nextWithdrawalIndex(), expectedNextIndex, "metadata nextWithdrawalIndex"); + } + + /// @notice Asserts the equality bewteen value of `withdrawalRequests()` and the expected values. + function assertEqUserRequest( + uint256 requestId, + address withdrawer, + bool claimed, + uint256 claimTimestamp, + uint256 assets, + uint256 queued + ) public view { + (address _withdrawer, bool _claimed, uint40 _claimTimestamp, uint128 _assets, uint128 _queued) = + lidoARM.withdrawalRequests(requestId); + assertEq(_withdrawer, withdrawer, "Wrong withdrawer"); + assertEq(_claimed, claimed, "Wrong claimed"); + assertEq(_claimTimestamp, claimTimestamp, "Wrong claimTimestamp"); + assertEq(_assets, assets, "Wrong assets"); + assertEq(_queued, queued, "Wrong queued"); + } } diff --git a/test/fork/utils/MockCall.sol b/test/fork/utils/MockCall.sol index 56bd606..1a6de11 100644 --- a/test/fork/utils/MockCall.sol +++ b/test/fork/utils/MockCall.sol @@ -4,6 +4,8 @@ pragma solidity 0.8.23; // Foundry import {Vm} from "forge-std/Vm.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; + /// @notice This contract should be used to mock calls to other contracts. library MockCall { Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); @@ -11,4 +13,45 @@ library MockCall { function mockCallDripperCollect(address dripper) external { vm.mockCall({callee: dripper, data: abi.encodeWithSignature("collect()"), returnData: abi.encode(true)}); } + + function mockCallLidoFindCheckpointHints() external { + vm.mockCall({ + callee: Mainnet.LIDO_WITHDRAWAL, + data: abi.encodeWithSignature("findCheckpointHints(uint256[],uint256,uint256)"), + returnData: abi.encode(new uint256[](1)) + }); + } + + function mockCallLidoClaimWithdrawals(address target) external { + vm.mockFunction({ + callee: Mainnet.LIDO_WITHDRAWAL, + target: target, + data: abi.encodeWithSignature("claimWithdrawals(uint256[],uint256[])") + }); + } +} + +contract MockLidoWithdraw { + ETHSender public immutable ethSender; + address public immutable lidoARM; + + constructor(address _lidoFixedPriceMulltiLpARM) { + ethSender = new ETHSender(); + lidoARM = _lidoFixedPriceMulltiLpARM; + } + + /// @notice Mock the call to the Lido contract's `claimWithdrawals` function. + /// @dev as it is not possible to transfer ETH from the mocked contract (seems to be an issue with forge) + /// we use the ETHSender contract intermediary to send the ETH to the target contract. + function claimWithdrawals(uint256[] memory, uint256[] memory) external { + ethSender.sendETH(lidoARM); + } +} + +contract ETHSender { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function sendETH(address target) external { + vm.deal(target, address(this).balance); + } } diff --git a/test/fork/utils/Modifiers.sol b/test/fork/utils/Modifiers.sol index acc8825..f55933d 100644 --- a/test/fork/utils/Modifiers.sol +++ b/test/fork/utils/Modifiers.sol @@ -1,12 +1,27 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +// Foundry +import {VmSafe} from "forge-std/Vm.sol"; + // Test imports import {Helpers} from "test/fork/utils/Helpers.sol"; import {MockCall} from "test/fork/utils/MockCall.sol"; +import {MockLidoWithdraw} from "test/fork/utils/MockCall.sol"; +import {ETHSender} from "test/fork/utils/MockCall.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; abstract contract Modifiers is Helpers { - /// @notice Impersonate the owner of the contract. + /// @notice Impersonate Alice. + modifier asAlice() { + vm.startPrank(alice); + _; + vm.stopPrank(); + } + + /// @notice Impersonate the owner of the OethARM contract. modifier asOwner() { vm.startPrank(oethARM.owner()); _; @@ -20,9 +35,234 @@ abstract contract Modifiers is Helpers { vm.stopPrank(); } + /// @notice Impersonate the Operator of LidoARM contract. + modifier asOperator() { + vm.startPrank(lidoARM.operator()); + _; + vm.stopPrank(); + } + + /// @notice Impersonate the owner of LidoARM contract. + modifier asLidoARMOwner() { + vm.startPrank(lidoARM.owner()); + _; + vm.stopPrank(); + } + + /// @notice Impersonate a random address + modifier asRandomAddress() { + vm.startPrank(vm.randomAddress()); + _; + vm.stopPrank(); + } + /// @notice Mock the call to the dripper's `collect` function, bypass it and return `true`. modifier mockCallDripperCollect() { MockCall.mockCallDripperCollect(vault.dripper()); _; } + + /// @notice Set the liquidity provider cap for a given provider on the CapManager contract. + modifier setLiquidityProviderCap(address provider, uint256 cap) { + address[] memory providers = new address[](1); + providers[0] = provider; + + capManager.setLiquidityProviderCaps(providers, cap); + _; + } + + /// @notice disable both the total assets and liquidity provider caps + modifier disableCaps() { + lidoARM.setCapManager(address(0)); + _; + } + + /// @notice Enable the total assets cap on the CapManager contract. + modifier enableCaps() { + require(address(capManager) != address(0), "CapManager not set"); + vm.prank(lidoARM.owner()); + lidoARM.setCapManager(address(capManager)); + + if (!capManager.accountCapEnabled()) { + vm.prank(capManager.owner()); + capManager.setAccountCapEnabled(true); + } + _; + } + + /// @notice Set the stETH/WETH swap prices on the LidoARM contract. + modifier setPrices(uint256 buyPrice, uint256 crossPrice, uint256 sellPrice) { + lidoARM.setCrossPrice(crossPrice); + lidoARM.setPrices(buyPrice, sellPrice); + _; + } + + modifier setArmBalances(uint256 wethBalance, uint256 stethBalance) { + deal(address(weth), address(lidoARM), wethBalance); + deal(address(steth), address(lidoARM), stethBalance); + _; + } + + /// @notice Set the total assets cap on the CapManager contract. + modifier setTotalAssetsCap(uint256 cap) { + capManager.setTotalAssetsCap(uint248(cap)); + _; + } + + /// @notice Modifier for deal function. + modifier deal_(address token, address to, uint256 amount) { + deal(token, to, amount); + _; + } + + /// @notice Deposit WETH into the LidoARM contract. + modifier depositInLidoARM(address user, uint256 amount) { + // Todo: extend this logic to other modifier if needed + (VmSafe.CallerMode mode, address _address, address _origin) = vm.readCallers(); + vm.stopPrank(); + + // Check current balance + uint256 balance = weth.balanceOf(user); + + // Deal amount as "extra" to user + deal(address(weth), user, amount + balance); + vm.startPrank(user); + weth.approve(address(lidoARM), type(uint256).max); + lidoARM.deposit(amount); + vm.stopPrank(); + + if (mode == VmSafe.CallerMode.Prank) { + vm.prank(_address, _origin); + } else if (mode == VmSafe.CallerMode.RecurrentPrank) { + vm.startPrank(_address, _origin); + } + _; + } + + /// @notice Request redeem from LidoARM contract. + modifier requestRedeemFromLidoARM(address user, uint256 amount) { + // Todo: extend this logic to other modifier if needed + (VmSafe.CallerMode mode, address _address, address _origin) = vm.readCallers(); + vm.stopPrank(); + + vm.startPrank(user); + lidoARM.requestRedeem(amount); + vm.stopPrank(); + + if (mode == VmSafe.CallerMode.Prank) { + vm.prank(_address, _origin); + } else if (mode == VmSafe.CallerMode.RecurrentPrank) { + vm.startPrank(_address, _origin); + } + _; + } + + /// @notice Claim redeem from LidoARM contract. + modifier claimRequestOnLidoARM(address user, uint256 requestId) { + // Todo: extend this logic to other modifier if needed + (VmSafe.CallerMode mode, address _address, address _origin) = vm.readCallers(); + vm.stopPrank(); + + vm.startPrank(user); + lidoARM.claimRedeem(requestId); + vm.stopPrank(); + + if (mode == VmSafe.CallerMode.Prank) { + vm.prank(_address, _origin); + } else if (mode == VmSafe.CallerMode.RecurrentPrank) { + vm.startPrank(_address, _origin); + } + _; + } + + /// @notice Simulate asset gain or loss in LidoARM contract. + modifier simulateAssetGainInLidoARM(uint256 assetGain, address token, bool gain) { + // Todo: extend this logic to other modifier if needed + (VmSafe.CallerMode mode, address _address, address _origin) = vm.readCallers(); + vm.stopPrank(); + + if (gain) { + deal(token, address(lidoARM), IERC20(token).balanceOf(address(lidoARM)) + uint256(assetGain)); + } else { + deal(token, address(lidoARM), IERC20(token).balanceOf(address(lidoARM)) - uint256(assetGain)); + } + + if (mode == VmSafe.CallerMode.Prank) { + vm.prank(_address, _origin); + } else if (mode == VmSafe.CallerMode.RecurrentPrank) { + vm.startPrank(_address, _origin); + } + _; + } + + /// @notice Collect fees on LidoARM contract. + modifier collectFeesOnLidoARM() { + lidoARM.collectFees(); + _; + } + + /// @notice Request stETH withdrawal for ETH on LidoARM contract. + modifier requestLidoWithdrawalsOnLidoARM(uint256[] memory amounts) { + // Todo: extend this logic to other modifier if needed + (VmSafe.CallerMode mode, address _address, address _origin) = vm.readCallers(); + vm.stopPrank(); + + vm.prank(lidoARM.owner()); + lidoARM.requestLidoWithdrawals(amounts); + + if (mode == VmSafe.CallerMode.Prank) { + vm.prank(_address, _origin); + } else if (mode == VmSafe.CallerMode.RecurrentPrank) { + vm.startPrank(_address, _origin); + } + _; + } + + /// @notice mock call for `findCheckpointHints`on lido withdraw contracts. + modifier mockCallLidoFindCheckpointHints() { + // Todo: extend this logic to other modifier if needed + (VmSafe.CallerMode mode, address _address, address _origin) = vm.readCallers(); + vm.stopPrank(); + + MockCall.mockCallLidoFindCheckpointHints(); + + if (mode == VmSafe.CallerMode.Prank) { + vm.prank(_address, _origin); + } else if (mode == VmSafe.CallerMode.RecurrentPrank) { + vm.startPrank(_address, _origin); + } + _; + } + + /// @notice mock call for `claimWithdrawals` on lido withdraw contracts. + /// @dev this will send eth directly to the lidoARM contract. + modifier mockFunctionClaimWithdrawOnLidoARM(uint256 amount) { + // Todo: extend this logic to other modifier if needed + (VmSafe.CallerMode mode, address _address, address _origin) = vm.readCallers(); + vm.stopPrank(); + + _mockFunctionClaimWithdrawOnLidoARM(amount); + + if (mode == VmSafe.CallerMode.Prank) { + vm.prank(_address, _origin); + } else if (mode == VmSafe.CallerMode.RecurrentPrank) { + vm.startPrank(_address, _origin); + } + _; + } + + function _mockFunctionClaimWithdrawOnLidoARM(uint256 amount) internal { + // Deploy fake lido withdraw contract + MockLidoWithdraw mocklidoWithdraw = new MockLidoWithdraw(address(lidoARM)); + // Give ETH to the ETH Sender contract + vm.deal(address(mocklidoWithdraw.ethSender()), amount); + // Mock all the call to the fake lido withdraw contract + MockCall.mockCallLidoClaimWithdrawals(address(mocklidoWithdraw)); + } + + /// @notice Skip time by a given delay. + modifier skipTime(uint256 delay) { + skip(delay); + _; + } } diff --git a/test/invariants/BaseInvariants.sol b/test/invariants/BaseInvariants.sol new file mode 100644 index 0000000..ddc377c --- /dev/null +++ b/test/invariants/BaseInvariants.sol @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Test imports +import {Invariant_Shared_Test_} from "./shared/Shared.sol"; + +// Handlers +import {LpHandler} from "./handlers/LpHandler.sol"; +import {LLMHandler} from "./handlers/LLMHandler.sol"; +import {SwapHandler} from "./handlers/SwapHandler.sol"; +import {OwnerHandler} from "./handlers/OwnerHandler.sol"; +import {DonationHandler} from "./handlers/DonationHandler.sol"; + +// Mocks +import {MockSTETH} from "./mocks/MockSTETH.sol"; + +/// @notice Base invariant test contract +/// @dev This contract should be used as a base contract that hold all +/// invariants properties independently from deployment context. +abstract contract Invariant_Base_Test_ is Invariant_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + address[] public lps; // Users that provide liquidity + address[] public swaps; // Users that perform swap + + LpHandler public lpHandler; + LLMHandler public llmHandler; + SwapHandler public swapHandler; + OwnerHandler public ownerHandler; + DonationHandler public donationHandler; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + super.setUp(); + } + + ////////////////////////////////////////////////////// + /// --- INVARIANTS + ////////////////////////////////////////////////////// + /* + * Swap functionnalities (swap) + * Invariant A: weth balance == ∑deposit + ∑wethIn + ∑wethRedeem + ∑wethDonated - ∑withdraw - ∑wethOut - ∑feesCollected + * Invariant B: steth balance >= ∑stethIn + ∑stethDonated - ∑stethOut - ∑stethRedeem + + * Liquidity provider functionnalities (lp) + * Shares: + * Invariant A: ∑shares > 0 due to initial deposit + * Invariant B: totalShares == ∑userShares + deadShares + * Invariant C: previewRedeem(∑shares) == totalAssets + * Invariant D: previewRedeem(shares) == (, uint256 assets) = previewRedeem(shares) Not really invariant, but tested on handler + * Invariant E: previewDeposit(amount) == uint256 shares = previewDeposit(amount) Not really invariant, but tested on handler + * Invariant L: ∀ user, user.weth + previewRedeem(user.shares) >=~ initialBalance , approxGe, to handle rounding error on deposit. + + * Withdraw Queue: + * Invariant F: nextWithdrawalIndex == requestRedeem call count + * Invariant G: withdrawsQueued == ∑requestRedeem.amount + * Invariant H: withdrawsQueued > withdrawsClaimed + * Invariant I: withdrawsQueued == ∑request.assets + * Invariant J: withdrawsClaimed == ∑claimRedeem.amount + * Invariant K: ∀ requestId, request.queued >= request.assets + + * Fees: + * Invariant M: ∑feesCollected == feeCollector.balance + + * Lido Liquidity Manager functionnalities + * Invariant A: lidoWithdrawalQueueAmount == ∑lidoRequestRedeem.assets + * Invariant B: address(arm).balance == 0 + * Invariant C: All slot allow for gap are empty + + * After invariants: + * All user can withdraw their funds + * Log stats + + + */ + + ////////////////////////////////////////////////////// + /// --- SWAP ASSERTIONS + ////////////////////////////////////////////////////// + function assert_swap_invariant_A() public view { + uint256 inflows = lpHandler.sum_of_deposits() + swapHandler.sum_of_weth_in() + + llmHandler.sum_of_redeemed_ether() + donationHandler.sum_of_weth_donated() + MIN_TOTAL_SUPPLY; + uint256 outflows = lpHandler.sum_of_withdraws() + swapHandler.sum_of_weth_out() + ownerHandler.sum_of_fees(); + assertEq(weth.balanceOf(address(lidoARM)), inflows - outflows, "swapHandler.invariant_A"); + } + + function assert_swap_invariant_B() public view { + uint256 inflows = swapHandler.sum_of_steth_in() + donationHandler.sum_of_steth_donated(); + uint256 outflows = swapHandler.sum_of_steth_out() + llmHandler.sum_of_requested_ether(); + uint256 sum_of_errors = MockSTETH(address(steth)).sum_of_errors(); + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), absDiff(inflows, outflows), sum_of_errors, "swapHandler.invariant_B" + ); + } + + ////////////////////////////////////////////////////// + /// --- LIQUIDITY PROVIDER ASSERTIONS + ////////////////////////////////////////////////////// + function assert_lp_invariant_A() public view { + assertGt(lidoARM.totalSupply(), 0, "lpHandler.invariant_A"); + } + + function assert_lp_invariant_B() public view { + uint256 sumOfUserShares; + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + sumOfUserShares += lidoARM.balanceOf(user); + } + assertEq(lidoARM.totalSupply(), _sumOfUserShares(), "lpHandler.invariant_B"); + } + + function assert_lp_invariant_C() public view { + assertEq(lidoARM.previewRedeem(_sumOfUserShares()), lidoARM.totalAssets(), "lpHandler.invariant_C"); + } + + function assert_lp_invariant_D() public view { + // Not really an invariant, but tested on handler + } + + function assert_lp_invariant_E() public view { + // Not really an invariant, but tested on handler + } + + function assert_lp_invariant_F() public view { + assertEq( + lidoARM.nextWithdrawalIndex(), lpHandler.numberOfCalls("lpHandler.requestRedeem"), "lpHandler.invariant_F" + ); + } + + function assert_lp_invariant_G() public view { + assertEq(lidoARM.withdrawsQueued(), lpHandler.sum_of_requests(), "lpHandler.invariant_G"); + } + + function assert_lp_invariant_H() public view { + assertGe(lidoARM.withdrawsQueued(), lidoARM.withdrawsClaimed(), "lpHandler.invariant_H"); + } + + function assert_lp_invariant_I() public view { + uint256 sum; + uint256 nextWithdrawalIndex = lidoARM.nextWithdrawalIndex(); + for (uint256 i; i < nextWithdrawalIndex; i++) { + (,,, uint128 assets,) = lidoARM.withdrawalRequests(i); + sum += assets; + } + + assertEq(lidoARM.withdrawsQueued(), sum, "lpHandler.invariant_I"); + } + + function assert_lp_invariant_J() public view { + assertEq(lidoARM.withdrawsClaimed(), lpHandler.sum_of_withdraws(), "lpHandler.invariant_J"); + } + + function assert_lp_invariant_K() public view { + uint256 nextWithdrawalIndex = lidoARM.nextWithdrawalIndex(); + for (uint256 i; i < nextWithdrawalIndex; i++) { + (,,, uint128 assets, uint128 queued) = lidoARM.withdrawalRequests(i); + assertGe(queued, assets, "lpHandler.invariant_L"); + } + } + + function assert_lp_invariant_L(uint256 initialBalance, uint256 maxError) public { + // As we will manipulate state here, we will snapshot the state and revert it after + uint256 snapshotId = vm.snapshot(); + + // 1. Finalize all claims on Lido + llmHandler.finalizeAllClaims(); + + // 2. Swap all stETH to WETH + _sweepAllStETH(); + + // 3. Finalize all claim redeem on ARM. + lpHandler.finalizeAllClaims(); + + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + uint256 userShares = lidoARM.balanceOf(user); + uint256 assets = lidoARM.previewRedeem(userShares); + uint256 sum = assets + weth.balanceOf(user); + + if (sum < initialBalance) { + // In this situation user have lost a bit of asset, ensure this is not too much + assertApproxEqRel(sum, initialBalance, maxError, "lpHandler.invariant_L_a"); + } else { + // In this case user have gained asset. + assertGe(sum, initialBalance, "lpHandler.invariant_L_b"); + } + } + + vm.revertToAndDelete(snapshotId); + } + + function assert_lp_invariant_M() public view { + address feeCollector = lidoARM.feeCollector(); + assertEq(weth.balanceOf(feeCollector), ownerHandler.sum_of_fees(), "lpHandler.invariant_M"); + } + + ////////////////////////////////////////////////////// + /// --- LIDO LIQUIDITY MANAGER ASSERTIONS + ////////////////////////////////////////////////////// + function assert_llm_invariant_A() public view { + assertEq( + lidoARM.lidoWithdrawalQueueAmount(), + llmHandler.sum_of_requested_ether() - llmHandler.sum_of_redeemed_ether(), + "llmHandler.invariant_A" + ); + } + + function assert_llm_invariant_B() public view { + assertEq(address(lidoARM).balance, 0, "llmHandler.invariant_B"); + } + + function assert_llm_invariant_C() public view { + uint256 slotGap1 = 1; + uint256 slotGap2 = 59; + uint256 gap1Length = 49; + uint256 gap2Length = 41; + + for (uint256 i = slotGap1; i < slotGap1 + gap1Length; i++) { + assertEq(readStorageSlotOnARM(i), 0, "lpHandler.invariant_C.gap1"); + } + + for (uint256 i = slotGap2; i < slotGap2 + gap2Length; i++) { + assertEq(readStorageSlotOnARM(i), 0, "lpHandler.invariant_C.gap2"); + } + } + + ////////////////////////////////////////////////////// + /// --- HELPERS + ////////////////////////////////////////////////////// + /// @notice Sum of users shares, including dead shares + function _sumOfUserShares() internal view returns (uint256) { + uint256 sumOfUserShares; + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + sumOfUserShares += lidoARM.balanceOf(user); + } + return sumOfUserShares + lidoARM.balanceOf(address(0xdEaD)); + } + + /// @notice Swap all stETH to WETH at the current price + function _sweepAllStETH() internal { + uint256 stETHBalance = steth.balanceOf(address(lidoARM)); + deal(address(weth), address(this), 1_000_000_000 ether); + weth.approve(address(lidoARM), type(uint256).max); + lidoARM.swapTokensForExactTokens(weth, steth, stETHBalance, type(uint256).max, address(this)); + assertApproxEqAbs(steth.balanceOf(address(lidoARM)), 0, 1, "SwepAllStETH"); + } + + /// @notice Empties the ARM + /// @dev Finalize all claims on lido, swap all stETH to WETH, finalize all + /// claim redeem on ARM and withdraw all user funds. + function emptiesARM() internal { + // 1. Finalize all claims on Lido + llmHandler.finalizeAllClaims(); + + // 2. Swap all stETH to WETH + _sweepAllStETH(); + + // 3. Finalize all claim redeem on ARM. + lpHandler.finalizeAllClaims(); + + // 4. Withdraw all user funds + lpHandler.withdrawAllUserFunds(); + } + + /// @notice Absolute difference between two numbers + function absDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a - b : b - a; + } + + function readStorageSlotOnARM(uint256 slotNumber) internal view returns (uint256 value) { + value = uint256(vm.load(address(lidoARM), bytes32(slotNumber))); + } + + function logStats() public view { + // Don't trace this function as it's only for logging data. + vm.pauseTracing(); + // Get data + _LPHandler memory lpHandlerStats = _LPHandler({ + deposit: lpHandler.numberOfCalls("lpHandler.deposit"), + deposit_skip: lpHandler.numberOfCalls("lpHandler.deposit.skip"), + requestRedeem: lpHandler.numberOfCalls("lpHandler.requestRedeem"), + requestRedeem_skip: lpHandler.numberOfCalls("lpHandler.requestRedeem.skip"), + claimRedeem: lpHandler.numberOfCalls("lpHandler.claimRedeem"), + claimRedeem_skip: lpHandler.numberOfCalls("lpHandler.claimRedeem.skip") + }); + + _SwapHandler memory swapHandlerStats = _SwapHandler({ + swapExact: swapHandler.numberOfCalls("swapHandler.swapExact"), + swapExact_skip: swapHandler.numberOfCalls("swapHandler.swapExact.skip"), + swapTokens: swapHandler.numberOfCalls("swapHandler.swapTokens"), + swapTokens_skip: swapHandler.numberOfCalls("swapHandler.swapTokens.skip") + }); + + _OwnerHandler memory ownerHandlerStats = _OwnerHandler({ + setPrices: ownerHandler.numberOfCalls("ownerHandler.setPrices"), + setPrices_skip: ownerHandler.numberOfCalls("ownerHandler.setPrices.skip"), + setCrossPrice: ownerHandler.numberOfCalls("ownerHandler.setCrossPrice"), + setCrossPrice_skip: ownerHandler.numberOfCalls("ownerHandler.setCrossPrice.skip"), + collectFees: ownerHandler.numberOfCalls("ownerHandler.collectFees"), + collectFees_skip: ownerHandler.numberOfCalls("ownerHandler.collectFees.skip"), + setFees: ownerHandler.numberOfCalls("ownerHandler.setFees"), + setFees_skip: ownerHandler.numberOfCalls("ownerHandler.setFees.skip") + }); + + _LLMHandler memory llmHandlerStats = _LLMHandler({ + requestStETHWithdraw: llmHandler.numberOfCalls("llmHandler.requestStETHWithdraw"), + claimStETHWithdraw: llmHandler.numberOfCalls("llmHandler.claimStETHWithdraw") + }); + + _DonationHandler memory donationHandlerStats = _DonationHandler({ + donateStETH: donationHandler.numberOfCalls("donationHandler.donateStETH"), + donateWETH: donationHandler.numberOfCalls("donationHandler.donateWETH") + }); + + // Log data + console.log(""); + console.log(""); + console.log(""); + console.log("--- Stats ---"); + + // --- LP Handler --- + console.log(""); + console.log("# LP Handler # "); + console.log("Number of Call: Deposit %d (skipped: %d)", lpHandlerStats.deposit, lpHandlerStats.deposit_skip); + console.log( + "Number of Call: RequestRedeem %d (skipped: %d)", + lpHandlerStats.requestRedeem, + lpHandlerStats.requestRedeem_skip + ); + console.log( + "Number of Call: ClaimRedeem %d (skipped: %d)", lpHandlerStats.claimRedeem, lpHandlerStats.claimRedeem_skip + ); + + // --- Swap Handler --- + console.log(""); + console.log("# Swap Handler #"); + console.log( + "Number of Call: SwapExactTokensForTokens %d (skipped: %d)", + swapHandlerStats.swapExact, + swapHandlerStats.swapExact_skip + ); + console.log( + "Number of Call: SwapTokensForExactTokens %d (skipped: %d)", + swapHandlerStats.swapTokens, + swapHandlerStats.swapTokens_skip + ); + + // --- Owner Handler --- + console.log(""); + console.log("# Owner Handler #"); + console.log( + "Number of Call: SetPrices %d (skipped: %d)", ownerHandlerStats.setPrices, ownerHandlerStats.setPrices_skip + ); + console.log( + "Number of Call: SetCrossPrice %d (skipped: %d)", + ownerHandlerStats.setCrossPrice, + ownerHandlerStats.setCrossPrice_skip + ); + console.log( + "Number of Call: CollectFees %d (skipped: %d)", + ownerHandlerStats.collectFees, + ownerHandlerStats.collectFees_skip + ); + console.log( + "Number of Call: SetFees %d (skipped: %d)", ownerHandlerStats.setFees, ownerHandlerStats.setFees_skip + ); + + // --- LLM Handler --- + console.log(""); + console.log("# LLM Handler #"); + console.log( + "Number of Call: RequestStETHWithdrawalForETH %d (skipped: %d)", llmHandlerStats.requestStETHWithdraw, 0 + ); + console.log( + "Number of Call: ClaimStETHWithdrawalForWETH %d (skipped: %d)", llmHandlerStats.claimStETHWithdraw, 0 + ); + + // --- Donation Handler --- + console.log(""); + console.log("# Donation Handler #"); + console.log("Number of Call: DonateStETH %d (skipped: %d)", donationHandlerStats.donateStETH, 0); + console.log("Number of Call: DonateWETH %d (skipped: %d)", donationHandlerStats.donateWETH, 0); + + // --- Global --- + console.log(""); + console.log("# Global Data #"); + uint256 sumOfCall = donationHandlerStats.donateStETH + donationHandlerStats.donateWETH + + llmHandlerStats.requestStETHWithdraw + llmHandlerStats.claimStETHWithdraw + ownerHandlerStats.setPrices + + ownerHandlerStats.setCrossPrice + ownerHandlerStats.collectFees + ownerHandlerStats.setFees + + swapHandlerStats.swapExact + swapHandlerStats.swapTokens + lpHandlerStats.deposit + + lpHandlerStats.requestRedeem + lpHandlerStats.claimRedeem; + uint256 sumOfCall_skip = ownerHandlerStats.setPrices_skip + ownerHandlerStats.setCrossPrice_skip + + ownerHandlerStats.collectFees_skip + ownerHandlerStats.setFees_skip + swapHandlerStats.swapExact_skip + + swapHandlerStats.swapTokens_skip + lpHandlerStats.deposit_skip + lpHandlerStats.requestRedeem_skip + + lpHandlerStats.claimRedeem_skip; + + uint256 skipPct = (sumOfCall_skip * 10_000) / max(sumOfCall, 1); + console.log("Total call: %d (skipped: %d) -> %2e%", sumOfCall, sumOfCall_skip, skipPct); + console.log(""); + console.log("-------------"); + console.log(""); + console.log(""); + console.log(""); + vm.resumeTracing(); + } + + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } + + struct _LPHandler { + uint256 deposit; + uint256 deposit_skip; + uint256 requestRedeem; + uint256 requestRedeem_skip; + uint256 claimRedeem; + uint256 claimRedeem_skip; + } + + struct _SwapHandler { + uint256 swapExact; + uint256 swapExact_skip; + uint256 swapTokens; + uint256 swapTokens_skip; + } + + struct _OwnerHandler { + uint256 setPrices; + uint256 setPrices_skip; + uint256 setCrossPrice; + uint256 setCrossPrice_skip; + uint256 collectFees; + uint256 collectFees_skip; + uint256 setFees; + uint256 setFees_skip; + } + + struct _LLMHandler { + uint256 requestStETHWithdraw; + uint256 claimStETHWithdraw; + } + + struct _DonationHandler { + uint256 donateStETH; + uint256 donateWETH; + } +} diff --git a/test/invariants/BasicInvariants.sol b/test/invariants/BasicInvariants.sol new file mode 100644 index 0000000..551d5f2 --- /dev/null +++ b/test/invariants/BasicInvariants.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Invariant_Base_Test_} from "./BaseInvariants.sol"; + +// Handlers +import {LpHandler} from "./handlers/LpHandler.sol"; +import {LLMHandler} from "./handlers/LLMHandler.sol"; +import {SwapHandler} from "./handlers/SwapHandler.sol"; +import {OwnerHandler} from "./handlers/OwnerHandler.sol"; +import {DonationHandler} from "./handlers/DonationHandler.sol"; +import {DistributionHandler} from "./handlers/DistributionHandler.sol"; + +/// @notice Basic invariant test contract +/// @dev This contract holds all the configuration needed for the basic invariant tests, +/// like call distribution %, user configuration, max values etc. +/// @dev This is where all the invariant are checked. +contract Invariant_Basic_Test_ is Invariant_Base_Test_ { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + uint256 private constant NUM_LPS = 4; + uint256 private constant NUM_SWAPS = 3; + uint256 private constant MAX_FEES = 5_000; // 50% + uint256 private constant MIN_BUY_T1 = 0.98 * 1e36; // We could have use 0, but this is non-sense + uint256 private constant MAX_SELL_T1 = 1.02 * 1e36; // We could have use type(uint256).max, but this is non-sense + uint256 private constant MAX_WETH_PER_USERS = 10_000 ether; // 10M + uint256 private constant MAX_STETH_PER_USERS = 10_000 ether; // 10M, actual total supply + uint256 private constant MAX_LOSS_IN_PCT = 1e13; // 0.001% + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + super.setUp(); + + // --- Create Users --- + // In this configuration, an user is either a LP or a Swap, but not both. + require(NUM_LPS + NUM_SWAPS <= users.length, "IBT: NOT_ENOUGH_USERS"); + for (uint256 i; i < NUM_LPS; i++) { + address user = users[i]; + require(user != address(0), "IBT: INVALID_USER"); + lps.push(user); + + // Give them a lot of wETH + deal(address(weth), user, MAX_WETH_PER_USERS); + } + for (uint256 i = NUM_LPS; i < NUM_LPS + NUM_SWAPS; i++) { + address user = users[i]; + require(user != address(0), "IBT: INVALID_USER"); + swaps.push(user); + + // Give them a lot of wETH and stETH + deal(address(weth), user, MAX_WETH_PER_USERS); + deal(address(steth), user, MAX_STETH_PER_USERS); + } + + // --- Setup ARM --- + // Max caps on the total asset that can be deposited + vm.prank(capManager.owner()); + capManager.setTotalAssetsCap(type(uint248).max); + + // Set prices, start with almost 1:1 + vm.prank(lidoARM.owner()); + lidoARM.setPrices(1e36 - 1, 1e36); + + // --- Handlers --- + lpHandler = new LpHandler(address(lidoARM), address(weth), lps); + swapHandler = new SwapHandler(address(lidoARM), address(weth), address(steth), swaps); + ownerHandler = + new OwnerHandler(address(lidoARM), address(weth), address(steth), MIN_BUY_T1, MAX_SELL_T1, MAX_FEES); + llmHandler = new LLMHandler(address(lidoARM), address(steth)); + donationHandler = new DonationHandler(address(lidoARM), address(weth), address(steth)); + + lpHandler.setSelectorWeight(lpHandler.deposit.selector, 5_000); // 50% + lpHandler.setSelectorWeight(lpHandler.requestRedeem.selector, 2_500); // 25% + lpHandler.setSelectorWeight(lpHandler.claimRedeem.selector, 2_500); // 25% + swapHandler.setSelectorWeight(swapHandler.swapExactTokensForTokens.selector, 5_000); // 50% + swapHandler.setSelectorWeight(swapHandler.swapTokensForExactTokens.selector, 5_000); // 50% + ownerHandler.setSelectorWeight(ownerHandler.setPrices.selector, 5_000); // 50% + ownerHandler.setSelectorWeight(ownerHandler.setCrossPrice.selector, 2_000); // 20% + ownerHandler.setSelectorWeight(ownerHandler.collectFees.selector, 2_000); // 20% + ownerHandler.setSelectorWeight(ownerHandler.setFees.selector, 1_000); // 10% + llmHandler.setSelectorWeight(llmHandler.requestLidoWithdrawals.selector, 5_000); // 50% + llmHandler.setSelectorWeight(llmHandler.claimLidoWithdrawals.selector, 5_000); // 50% + donationHandler.setSelectorWeight(donationHandler.donateStETH.selector, 5_000); // 50% + donationHandler.setSelectorWeight(donationHandler.donateWETH.selector, 5_000); // 50% + + address[] memory targetContracts = new address[](5); + targetContracts[0] = address(lpHandler); + targetContracts[1] = address(swapHandler); + targetContracts[2] = address(ownerHandler); + targetContracts[3] = address(llmHandler); + targetContracts[4] = address(donationHandler); + + uint256[] memory weightsDistributorHandler = new uint256[](5); + weightsDistributorHandler[0] = 4_000; // 40% + weightsDistributorHandler[1] = 4_000; // 40% + weightsDistributorHandler[2] = 1_000; // 10% + weightsDistributorHandler[3] = 700; // 7% + weightsDistributorHandler[4] = 300; // 3% + + address distributionHandler = address(new DistributionHandler(targetContracts, weightsDistributorHandler)); + + // All call will be done through the distributor, so we set it as the target contract + targetContract(distributionHandler); + } + + ////////////////////////////////////////////////////// + /// --- INVARIANTS + ////////////////////////////////////////////////////// + function invariant_lp() external { + assert_lp_invariant_A(); + assert_lp_invariant_B(); + assert_lp_invariant_C(); + assert_lp_invariant_D(); + assert_lp_invariant_E(); + assert_lp_invariant_F(); + assert_lp_invariant_G(); + assert_lp_invariant_H(); + assert_lp_invariant_I(); + assert_lp_invariant_J(); + assert_lp_invariant_K(); + assert_lp_invariant_L(MAX_WETH_PER_USERS, MAX_LOSS_IN_PCT); + assert_lp_invariant_M(); + } + + function invariant_swap() external view { + assert_swap_invariant_A(); + assert_swap_invariant_B(); + } + + function invariant_llm() external view { + assert_llm_invariant_A(); + assert_llm_invariant_B(); + assert_llm_invariant_C(); + } + + function afterInvariant() external { + logStats(); + emptiesARM(); + } +} diff --git a/test/invariants/handlers/BaseHandler.sol b/test/invariants/handlers/BaseHandler.sol new file mode 100644 index 0000000..8714990 --- /dev/null +++ b/test/invariants/handlers/BaseHandler.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {Vm} from "forge-std/Vm.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; + +/// @notice Base handler contract +/// @dev This contract should be used as a base contract for all handlers +/// as this it holds the sole and exclusive callable function `entryPoint`. +/// @dev Highly inspired from Maple-Core-V2 repo: https://github.com/maple-labs/maple-core-v2 +abstract contract BaseHandler is StdUtils, StdCheats { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + uint256 internal constant WEIGHTS_RANGE = 10_000; + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + uint256 public numCalls; + uint256 public totalWeight; + + bytes4[] public selectors; + + mapping(address => string) public names; + mapping(bytes4 => uint256) public weights; + mapping(bytes32 => uint256) public numberOfCalls; + + constructor() { + // Default names + names[makeAddr("Alice")] = "Alice"; + names[makeAddr("Bob")] = "Bob"; + names[makeAddr("Charlie")] = "Charlie"; + names[makeAddr("Dave")] = "Dave"; + names[makeAddr("Eve")] = "Eve"; + names[makeAddr("Frank")] = "Frank"; + names[makeAddr("George")] = "George"; + names[makeAddr("Harry")] = "Harry"; + } + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function setSelectorWeight(bytes4 funcSelector, uint256 weight_) external { + // Set Selector weight + weights[funcSelector] = weight_; + + // Add selector to the selector list + selectors.push(funcSelector); + + // Increase totalWeight + totalWeight += weight_; + } + + function entryPoint(uint256 seed_) external { + require(totalWeight == WEIGHTS_RANGE, "HB:INVALID_WEIGHTS"); + + numCalls++; + + uint256 range_; + + uint256 value_ = uint256(keccak256(abi.encodePacked(seed_, numCalls))) % WEIGHTS_RANGE + 1; // 1 - 100 + + for (uint256 i = 0; i < selectors.length; i++) { + uint256 weight_ = weights[selectors[i]]; + + range_ += weight_; + if (value_ <= range_ && weight_ != 0) { + (bool success,) = address(this).call(abi.encodeWithSelector(selectors[i], seed_)); + + // TODO: Parse error from low-level call and revert with it + require(success, "HB:CALL_FAILED"); + break; + } + } + } + + ////////////////////////////////////////////////////// + /// --- HELPERS + ////////////////////////////////////////////////////// + function _randomize(uint256 seed, string memory salt) internal pure returns (uint256) { + return uint256(keccak256(abi.encodePacked(seed, salt))); + } + + /// @notice Return the minimum between two uint256 + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + /// @notice Return the maximum between two uint256 + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } +} diff --git a/test/invariants/handlers/DistributionHandler.sol b/test/invariants/handlers/DistributionHandler.sol new file mode 100644 index 0000000..ded559c --- /dev/null +++ b/test/invariants/handlers/DistributionHandler.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contract +import {BaseHandler} from "./BaseHandler.sol"; + +/// @title Distribution Handler contract +/// @dev This contract should be the only callable contract from test and will distribute calls to other contracts +/// @dev Highly inspired from Maple-Core-V2 repo: https://github.com/maple-labs/maple-core-v2 +contract DistributionHandler { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + uint256 internal constant WEIGHTS_RANGE = 10_000; + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + uint256 public numOfCallsTotal; + + address[] public targetContracts; + + uint256[] public weights; + + mapping(address => uint256) public numOfCalls; + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(address[] memory targetContracts_, uint256[] memory weights_) { + // NOTE: Order of arrays must match + require(targetContracts_.length == weights_.length, "DH:INVALID_LENGTHS"); + + uint256 weightsTotal; + + for (uint256 i; i < weights_.length; ++i) { + weightsTotal += weights_[i]; + } + + require(weightsTotal == WEIGHTS_RANGE, "DH:INVALID_WEIGHTS"); + + targetContracts = targetContracts_; + weights = weights_; + } + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function distributorEntryPoint(uint256 seed_) external { + numOfCallsTotal++; + + uint256 range_; + + uint256 value_ = uint256(keccak256(abi.encodePacked(seed_, numOfCallsTotal))) % WEIGHTS_RANGE + 1; // 1 - 100 + + for (uint256 i = 0; i < targetContracts.length; i++) { + uint256 weight_ = weights[i]; + + range_ += weight_; + if (value_ <= range_ && weight_ != 0) { + numOfCalls[targetContracts[i]]++; + BaseHandler(targetContracts[i]).entryPoint(seed_); + break; + } + } + } +} diff --git a/test/invariants/handlers/DonationHandler.sol b/test/invariants/handlers/DonationHandler.sol new file mode 100644 index 0000000..f689412 --- /dev/null +++ b/test/invariants/handlers/DonationHandler.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice DonaitonHandler contract +/// @dev This contract is used to simulate donation of stETH or wETH to the ARM. +contract DonationHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + IERC20 public immutable steth; + LidoARM public immutable arm; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_weth_donated; + uint256 public sum_of_steth_donated; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address _steth) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + steth = IERC20(_steth); + + names[address(weth)] = "WETH"; + names[address(steth)] = "STETH"; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function donateStETH(uint256 _seed) external { + numberOfCalls["donationHandler.donateStETH"]++; + + uint256 amount = _bound(_seed, 1, 1 ether); + console.log("DonationHandler.donateStETH(%18e)", amount); + + deal(address(steth), address(this), amount); + + steth.transfer(address(arm), amount); + + sum_of_steth_donated += amount; + } + + function donateWETH(uint256 _seed) external { + numberOfCalls["donationHandler.donateWETH"]++; + + uint256 amount = _bound(_seed, 1, 1 ether); + console.log("DonationHandler.donateWETH(%18e)", amount); + + deal(address(weth), address(this), amount); + + weth.transfer(address(arm), amount); + + sum_of_weth_donated += amount; + } +} diff --git a/test/invariants/handlers/LLMHandler.sol b/test/invariants/handlers/LLMHandler.sol new file mode 100644 index 0000000..731a332 --- /dev/null +++ b/test/invariants/handlers/LLMHandler.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice LidoLiquidityManager Handler contract +/// @dev This contract is used to handle all functionnalities that are related to the Lido Liquidity Manager. +contract LLMHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable steth; + LidoARM public immutable arm; + address public immutable owner; + uint256 public constant MAX_AMOUNT = 1_000 ether; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + uint256[] public requestIds; + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_requested_ether; + uint256 public sum_of_redeemed_ether; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _steth) { + arm = LidoARM(payable(_arm)); + owner = arm.owner(); + steth = IERC20(_steth); + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function requestLidoWithdrawals(uint256 _seed) external { + numberOfCalls["llmHandler.requestStETHWithdraw"]++; + + // Select a random amount + uint256 totalAmount = _bound(_seed, 0, min(MAX_AMOUNT * 3, steth.balanceOf(address(arm)))); + + // We can only request only 1k amount at a time + uint256 batch = (totalAmount / MAX_AMOUNT) + 1; + uint256[] memory amounts = new uint256[](batch); + uint256 totalAmount_ = totalAmount; + for (uint256 i = 0; i < batch; i++) { + if (totalAmount_ >= MAX_AMOUNT) { + amounts[i] = MAX_AMOUNT; + totalAmount_ -= MAX_AMOUNT; + } else { + amounts[i] = totalAmount_; + totalAmount_ = 0; + } + } + require(totalAmount_ == 0, "LLMHandler: Invalid total amount"); + + console.log("LLMHandler.requestLidoWithdrawals(%18e)", totalAmount); + + // Prank Owner + vm.startPrank(owner); + + // Request stETH withdrawal for ETH + uint256[] memory requestId = arm.requestLidoWithdrawals(amounts); + + // Stop Prank + vm.stopPrank(); + + // Update state + for (uint256 i = 0; i < requestId.length; i++) { + requestIds.push(requestId[i]); + } + + // Update sum of requested ether + sum_of_requested_ether += totalAmount; + } + + function claimLidoWithdrawals(uint256 _seed) external { + numberOfCalls["llmHandler.claimStETHWithdraw"]++; + + // Select multiple requestIds + uint256 len = requestIds.length; + uint256 requestCount = _bound(_seed, 0, len); + uint256[] memory requestIds_ = new uint256[](requestCount); + for (uint256 i = 0; i < requestCount; i++) { + requestIds_[i] = requestIds[i]; + } + + // Remove requestIds from list + uint256[] memory newRequestIds = new uint256[](len - requestCount); + for (uint256 i = requestCount; i < len; i++) { + newRequestIds[i - requestCount] = requestIds[i]; + } + requestIds = newRequestIds; + + // As `claimLidoWithdrawals` doesn't send back the amount, we need to calculate it + uint256 outstandingBefore = arm.lidoWithdrawalQueueAmount(); + + // Prank Owner + vm.startPrank(owner); + + // Claim stETH withdrawal for WETH + arm.claimLidoWithdrawals(requestIds_); + + // Stop Prank + vm.stopPrank(); + + uint256 outstandingAfter = arm.lidoWithdrawalQueueAmount(); + uint256 diff = outstandingBefore - outstandingAfter; + + console.log("LLMHandler.claimLidoWithdrawals(%18e -- count: %d)", diff, requestCount); + + // Update sum of redeemed ether + sum_of_redeemed_ether += diff; + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + /// @notice Claim all the remaining requested withdrawals + function finalizeAllClaims() external { + // As `claimLidoWithdrawals` doesn't send back the amount, we need to calculate it + uint256 outstandingBefore = arm.lidoWithdrawalQueueAmount(); + + // Prank Owner + vm.startPrank(owner); + + // Claim stETH withdrawal for WETH + arm.claimLidoWithdrawals(requestIds); + + // Stop Prank + vm.stopPrank(); + + uint256 outstandingAfter = arm.lidoWithdrawalQueueAmount(); + uint256 diff = outstandingBefore - outstandingAfter; + + // Update sum of redeemed ether + sum_of_redeemed_ether += diff; + } +} diff --git a/test/invariants/handlers/LpHandler.sol b/test/invariants/handlers/LpHandler.sol new file mode 100644 index 0000000..095cfb4 --- /dev/null +++ b/test/invariants/handlers/LpHandler.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice LpHandler contract +/// @dev This contract is used to handle all functionnalities related to providing liquidity in the ARM. +contract LpHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + LidoARM public immutable arm; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + address[] public lps; // Users that provide liquidity + mapping(address user => uint256[] ids) public requests; + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_deposits; + uint256 public sum_of_requests; + uint256 public sum_of_withdraws; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address[] memory _lps) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + + require(_lps.length > 0, "LH: EMPTY_LPS"); + lps = _lps; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + /// @notice Provide liquidity to the ARM with a given amount of WETH + /// @dev This assumes that lps have unlimited capacity to provide liquidity on LPC contracts. + function deposit(uint256 _seed) external { + numberOfCalls["lpHandler.deposit"]++; + + // Get a user + address user = lps[_seed % lps.length]; + + // Amount of WETH to deposit should be between 0 and total WETH balance + uint256 amount = _bound(_seed, 0, weth.balanceOf(user)); + console.log("LpHandler.deposit(%18e), %s", amount, names[user]); + + // Prank user + vm.startPrank(user); + + // Approve WETH to ARM + weth.approve(address(arm), amount); + + // Deposit WETH + uint256 expectedShares = arm.previewDeposit(amount); + uint256 shares = arm.deposit(amount); + + // This is an invariant check. The shares should be equal to the expected shares + require(shares == expectedShares, "LH: DEPOSIT - INVALID_SHARES"); + + // End prank + vm.stopPrank(); + + // Update sum of deposits + sum_of_deposits += amount; + } + + /// @notice Request to redeem a given amount of shares from the ARM + /// @dev This is allowed to redeem 0 shares. + function requestRedeem(uint256 _seed) external { + numberOfCalls["lpHandler.requestRedeem"]++; + + // Try to get a user that have shares, i.e. that have deposited and not redeemed all + // If there is not such user, get a random user and 0redeem + address user; + uint256 len = lps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + for (uint256 i; i < len; i++) { + user = lps[(__seed + i) % len]; + if (arm.balanceOf(user) > 0) break; + } + require(user != address(0), "LH: REDEEM_REQUEST - NO_USER"); // Should not happen, but just in case + + // Amount of shares to redeem should be between 0 and user total shares balance + uint256 shares = _bound(_seed, 0, arm.balanceOf(user)); + console.log("LpHandler.requestRedeem(%18e -- id: %d), %s", shares, arm.nextWithdrawalIndex(), names[user]); + + // Prank user + vm.startPrank(user); + + // Redeem shares + uint256 expectedAmount = arm.previewRedeem(shares); + (uint256 id, uint256 amount) = arm.requestRedeem(shares); + + // This is an invariant check. The amount should be equal to the expected amount + require(amount == expectedAmount, "LH: REDEEM_REQUEST - INVALID_AMOUNT"); + + // End prank + vm.stopPrank(); + + // Add request to user + requests[user].push(id); + + // Update sum of requests + sum_of_requests += amount; + } + + event UserFound(address user, uint256 requestId, uint256 requestIndex); + + /// @notice Claim redeem request for a user on the ARM + /// @dev This call will be skipped if there is no request to claim at all. However, claiming zero is allowed. + /// @dev A jump in time is done to the request deadline, but the time is rewinded back to the current time. + function claimRedeem(uint256 _seed) external { + numberOfCalls["lpHandler.claimRedeem"]++; + + // Get a user that have a request to claim + // If no user have a request, skip this call + address user; + uint256 requestId; // on the ARM + uint256 requestIndex; // local + uint256 requestAmount; + uint256 len = lps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + uint256 withdrawsClaimed = arm.withdrawsClaimed(); + + // 1. Loop to find a user with a request + for (uint256 i; i < len; i++) { + // Take a random user + address user_ = lps[(__seed + i) % len]; + // Check if user have a request + if (requests[user_].length > 0) { + // Cache user requests length + uint256 requestLen = requests[user_].length; + + // 2. Loop to find a request that can be claimed + for (uint256 j; j < requestLen; j++) { + uint256 ___seed = _bound(_seed, 0, type(uint256).max - requestLen); + // Take a random request among user requests + uint256 requestIndex_ = (___seed + j) % requestLen; + + // Get data about the request (in ARM contract) + (,,, uint128 amount_, uint128 queued) = arm.withdrawalRequests(requests[user_][requestIndex_]); + + // 3. Check if the request can be claimed + if (queued < withdrawsClaimed + weth.balanceOf(address(arm))) { + user = user_; + requestId = requests[user_][requestIndex_]; + requestIndex = requestIndex_; + requestAmount = amount_; + emit UserFound(user, requestId, requestIndex); + break; + } + } + } + + // If we found a user with a request, break the loop + if (user != address(0)) break; + } + + // If no user have a request, skip this call + if (user == address(0)) { + console.log("LpHandler.claimRedeem - No user have a request"); + numberOfCalls["lpHandler.claimRedeem.skip"]++; + return; + } + + console.log("LpHandler.claimRedeem(%18e -- id: %d), %s", requestAmount, requestId, names[user]); + + // Timejump to request deadline + skip(arm.claimDelay()); + + // Prank user + vm.startPrank(user); + + // Claim redeem + (uint256 amount) = arm.claimRedeem(requestId); + require(amount == requestAmount, "LH: CLAIM_REDEEM - INVALID_AMOUNT"); + + // End prank + vm.stopPrank(); + + // Jump back to current time, to avoid issues with other tests + rewind(arm.claimDelay()); + + // Remove request + uint256[] storage userRequests = requests[user]; + userRequests[requestIndex] = userRequests[userRequests.length - 1]; + userRequests.pop(); + + // Update sum of withdraws + sum_of_withdraws += amount; + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + /// @notice Finalize all user claim request for all users + function finalizeAllClaims() external { + // Timejump to request deadline + skip(arm.claimDelay()); + + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + + vm.startPrank(user); + uint256[] memory userRequests = requests[user]; + for (uint256 j; j < userRequests.length; j++) { + uint256 amount = arm.claimRedeem(userRequests[j]); + sum_of_withdraws += amount; + } + // Delete all requests + delete requests[user]; + + vm.stopPrank(); + } + + // Jump back to current time, to avoid issues with other tests + rewind(arm.claimDelay()); + } + + /// @notice Withdraw all user funds + /// @dev This function assumes that all pending request on lido have been finalized, + /// all stETH have been swapped to WETH and all claim redeem requests have been finalized. + function withdrawAllUserFunds() external { + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + vm.startPrank(user); + + // Request Claim + (uint256 requestId,) = arm.requestRedeem(arm.balanceOf(user)); + + // Timejump to request deadline + skip(arm.claimDelay()); + + // Claim request + arm.claimRedeem(requestId); + + // Jump back to current time, to avoid issues with other tests + rewind(arm.claimDelay()); + vm.stopPrank(); + } + } + + /// @notice Get all requests for a user + function getRequests(address user) external view returns (uint256[] memory) { + return requests[user]; + } +} diff --git a/test/invariants/handlers/OwnerHandler.sol b/test/invariants/handlers/OwnerHandler.sol new file mode 100644 index 0000000..d392dcd --- /dev/null +++ b/test/invariants/handlers/OwnerHandler.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice OwnerHandler contract +/// @dev This contract is used to handle all functionnalities restricted to the owner of the ARM. +contract OwnerHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + IERC20 public immutable steth; + LidoARM public immutable arm; + address public immutable owner; + uint256 public immutable maxFees; + address public immutable operator; + uint256 public immutable minBuyT1; + uint256 public immutable maxSellT1; + uint256 public immutable priceScale; + uint256 public constant MIN_TOTAL_SUPPLY = 1e12; + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_fees; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address _steth, uint256 _minBuyT1, uint256 _maxSellT1, uint256 _maxFees) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + steth = IERC20(_steth); + maxFees = _maxFees; + minBuyT1 = _minBuyT1; + maxSellT1 = _maxSellT1; + owner = arm.owner(); + operator = arm.operator(); + priceScale = arm.PRICE_SCALE(); + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + /// @notice Set prices for the ARM + function setPrices(uint256 _seed) external { + numberOfCalls["ownerHandler.setPrices"]++; + + // Bound prices + uint256 crossPrice = arm.crossPrice(); + uint256 buyT1 = _bound(_randomize(_seed, "buy"), minBuyT1, crossPrice - 1); + uint256 sellT1 = _bound(_randomize(_seed, "sell"), crossPrice, maxSellT1); + + console.log("OwnerHandler.setPrices(%36e,%36e)", buyT1, sellT1); + + // Prank owner instead of operator to bypass price check + vm.startPrank(owner); + + // Set prices + arm.setPrices(buyT1, sellT1); + + // Stop prank + vm.stopPrank(); + } + + /// @notice Set cross price for the ARM + function setCrossPrice(uint256 _seed) external { + numberOfCalls["ownerHandler.setCrossPrice"]++; + + // Bound prices + uint256 currentPrice = arm.crossPrice(); + // Condition 1: 1e36 - 20e32 <= newCrossPrice <= 1e36 + // Condition 2: buyPrice < newCrossPrice <= sellPrice + // <=> + // max(buyPrice, 1e36 - 20e32) < newCrossPrice <= min(sellPrice, 1e36) + uint256 sellPrice = priceScale * priceScale / arm.traderate0(); + uint256 buyPrice = arm.traderate1(); + uint256 newCrossPrice = + _bound(_seed, max(priceScale - arm.MAX_CROSS_PRICE_DEVIATION(), buyPrice) + 1, min(priceScale, sellPrice)); + + if (newCrossPrice < currentPrice && steth.balanceOf(address(arm)) >= MIN_TOTAL_SUPPLY) { + console.log("OwnerHandler.setCrossPrice() - Skipping price decrease"); + numberOfCalls["ownerHandler.setCrossPrice.skip"]++; + return; + } + + console.log("OwnerHandler.setCrossPrice(%36e)", newCrossPrice); + + // Prank owner instead of operator to bypass price check + vm.startPrank(owner); + + // Set prices + arm.setCrossPrice(newCrossPrice); + + // Stop prank + vm.stopPrank(); + } + + /// @notice Set fees for the ARM + function setFees(uint256 _seed) external { + numberOfCalls["ownerHandler.setFees"]++; + + uint256 feeAccrued = arm.feesAccrued(); + if (!enoughLiquidityAvailable(feeAccrued) || feeAccrued > weth.balanceOf(address(arm))) { + console.log("OwnerHandler.setFees() - Not enough liquidity to collect fees"); + numberOfCalls["ownerHandler.setFees.skip"]++; + return; + } + + uint256 fee = _bound(_seed, 0, maxFees); + console.log("OwnerHandler.setFees(%2e)", fee); + + // Prank owner + vm.startPrank(owner); + + // Set fees + arm.setFee(fee); + + // Stop prank + vm.stopPrank(); + + // Update sum of fees + sum_of_fees += feeAccrued; + } + + /// @notice Collect fees from the ARM + /// @dev skipped if there is not enough liquidity to collect fees + function collectFees(uint256) external { + numberOfCalls["ownerHandler.collectFees"]++; + + uint256 feeAccrued = arm.feesAccrued(); + if (!enoughLiquidityAvailable(feeAccrued) || feeAccrued > weth.balanceOf(address(arm))) { + console.log("OwnerHandler.collectFees() - Not enough liquidity to collect fees"); + numberOfCalls["ownerHandler.collectFees.skip"]++; + return; + } + + console.log("OwnerHandler.collectFees(%18e)", feeAccrued); + + // Collect fees + uint256 fees = arm.collectFees(); + require(feeAccrued == fees, "OwnerHandler.collectFees() - Fees collected do not match fees accrued"); + + // Update sum of fees + sum_of_fees += fees; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function enoughLiquidityAvailable(uint256 amount) public view returns (bool) { + // The amount of liquidity assets (WETH) that is still to be claimed in the withdrawal queue + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + + // Save gas on an external balanceOf call if there are no outstanding withdrawals + if (outstandingWithdrawals == 0) return true; + + return amount + outstandingWithdrawals <= weth.balanceOf(address(arm)); + } +} diff --git a/test/invariants/handlers/SwapHandler.sol b/test/invariants/handlers/SwapHandler.sol new file mode 100644 index 0000000..5987b99 --- /dev/null +++ b/test/invariants/handlers/SwapHandler.sol @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice SwapHandler contract +/// @dev This contract is used to handle all functionnalities related to the swap in the ARM. +contract SwapHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + IERC20 public immutable steth; + LidoARM public immutable arm; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + address[] public swaps; // Users that perform swap + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_weth_in; + uint256 public sum_of_weth_out; + uint256 public sum_of_steth_in; + uint256 public sum_of_steth_out; + + //////////////////////////////////////////////////// + /// --- EVENTS + //////////////////////////////////////////////////// + event GetAmountInMax(uint256 amount); + event GetAmountOutMax(uint256 amount); + event EstimateAmountIn(uint256 amount); + event EstimateAmountOut(uint256 amount); + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address _steth, address[] memory _swaps) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + steth = IERC20(_steth); + + require(_swaps.length > 0, "SH: EMPTY_SWAPS"); + swaps = _swaps; + + names[address(weth)] = "WETH"; + names[address(steth)] = "STETH"; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function swapExactTokensForTokens(uint256 _seed) external { + numberOfCalls["swapHandler.swapExact"]++; + + // Select an input token and build path + IERC20 inputToken = _seed % 2 == 0 ? weth : steth; + IERC20 outputToken = inputToken == weth ? steth : weth; + address[] memory path = new address[](2); + path[0] = address(inputToken); + path[1] = address(outputToken); + + // Select a random user thah have the input token. If no one, it will be skipped after. + address user; + uint256 len = swaps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + for (uint256 i; i < len; i++) { + user = swaps[(__seed + i) % len]; + if (inputToken.balanceOf(user) > 0) break; + } + + // Select a random amount, maximum is the minimum between the balance of the user and the liquidity available + uint256 amountIn = _bound(_seed, 0, min(inputToken.balanceOf(user), getAmountInMax(inputToken))); + uint256 estimatedAmountOut = estimateAmountOut(inputToken, amountIn); + + // Even this is possible in some case, there is not interest to swap 0 amount, so we skip it. + if (amountIn == 0) { + numberOfCalls["swapHandler.swapExact.skip"]++; + console.log("SwapHandler.swapExactTokensForTokens - Swapping 0 amount"); + return; + } + + console.log( + "SwapHandler.swapExactTokensForTokens(%18e), %s, %s", amountIn, names[user], names[address(inputToken)] + ); + + // Prank user + vm.startPrank(user); + + // Approve the ARM to spend the input token + inputToken.approve(address(arm), amountIn); + + // Swap + // Note: this implementation is prefered as it returns the amountIn of output tokens + uint256[] memory amounts = arm.swapExactTokensForTokens({ + amountIn: amountIn, + amountOutMin: estimatedAmountOut, + path: path, + to: address(user), + deadline: block.timestamp + 1 + }); + + // End prank + vm.stopPrank(); + + // Update sum of swaps + if (inputToken == weth) { + sum_of_weth_in += amounts[0]; + sum_of_steth_out += amounts[1]; + } else { + sum_of_steth_in += amounts[0]; + sum_of_weth_out += amounts[1]; + } + + require(amountIn == amounts[0], "SH: SWAP - INVALID_AMOUNT_IN"); + require(estimatedAmountOut == amounts[1], "SH: SWAP - INVALID_AMOUNT_OUT"); + } + + function swapTokensForExactTokens(uint256 _seed) external { + numberOfCalls["swapHandler.swapTokens"]++; + + // Select an input token and build path + IERC20 inputToken = _seed % 2 == 0 ? weth : steth; + IERC20 outputToken = inputToken == weth ? steth : weth; + address[] memory path = new address[](2); + path[0] = address(inputToken); + path[1] = address(outputToken); + + // Select a random user thah have the input token. If no one, it will be skipped after. + address user; + uint256 len = swaps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + for (uint256 i; i < len; i++) { + user = swaps[(__seed + i) % len]; + if (inputToken.balanceOf(user) > 0) break; + } + + // Select a random amount, maximum is the minimum between the balance of the user and the liquidity available + uint256 amountOut = _bound(_seed, 0, min(liquidityAvailable(outputToken), getAmountOutMax(outputToken, user))); + + // Even this is possible in some case, there is not interest to swap 0 amount, so we skip it. + // It could have been interesting to check it, to see what's happen if someone swap 0 and thus send 1 wei to the contract, + // but this will be tested with Donation Handler. So we skip it. + if (amountOut == 0) { + numberOfCalls["swapHandler.swapTokens.skip"]++; + console.log("SwapHandler.swapTokensForExactTokens - Swapping 0 amount"); + return; + } + + uint256 estimatedAmountIn = estimateAmountIn(outputToken, amountOut); + console.log( + "SwapHandler.swapTokensForExactTokens(%18e), %s, %s", + estimatedAmountIn, + names[user], + names[address(inputToken)] + ); + + // Prank user + vm.startPrank(user); + + // Approve the ARM to spend the input token + // Approve max, to avoid calculating the exact amount + inputToken.approve(address(arm), type(uint256).max); + + // Swap + // Note: this implementation is prefered as it returns the amountIn of output tokens + uint256[] memory amounts = arm.swapTokensForExactTokens({ + amountOut: amountOut, + amountInMax: type(uint256).max, + path: path, + to: address(user), + deadline: block.timestamp + 1 + }); + + // End prank + vm.stopPrank(); + + // Update sum of swaps + if (inputToken == weth) { + sum_of_weth_in += amounts[0]; + sum_of_steth_out += amounts[1]; + } else { + sum_of_steth_in += amounts[0]; + sum_of_weth_out += amounts[1]; + } + + require(estimatedAmountIn == amounts[0], "SH: SWAP - INVALID_AMOUNT_IN"); + require(amountOut == amounts[1], "SH: SWAP - INVALID_AMOUNT_OUT"); + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + /// @notice Helpers to calcul the maximum amountIn of token that we can use as input in swapExactTokensForTokens. + /// @dev Depends on the reserve of the output token in ARM and the price of the input token. + function getAmountInMax(IERC20 tokenIn) public returns (uint256) { + IERC20 tokenOut = tokenIn == weth ? steth : weth; + + uint256 reserveOut = liquidityAvailable(tokenOut); + + uint256 amount = (reserveOut * arm.PRICE_SCALE()) / price(tokenIn); + + // Emit event to see it directly in logs + emit GetAmountInMax(amount); + + return amount; + } + + /// @notice Helpers to calcul the maximum amountOut of token that we can use as input in swapTokensForExactTokens. + /// @dev Depends on the reserve of the input token of user and the price of the output token. + function getAmountOutMax(IERC20 tokenOut, address user) public returns (uint256) { + IERC20 tokenIn = tokenOut == weth ? steth : weth; + + uint256 reserveUser = tokenIn.balanceOf(user); + if (reserveUser < 3) return 0; + + uint256 amount = ((reserveUser - 3) * price(tokenIn)) / arm.PRICE_SCALE(); + + // Emit event to see it directly in logs + emit GetAmountOutMax(amount); + + return amount; + } + + /// @notice Helpers to calcul the expected amountIn of tokenIn used in swapTokensForExactTokens. + function estimateAmountIn(IERC20 tokenOut, uint256 amountOut) public returns (uint256) { + IERC20 tokenIn = tokenOut == weth ? steth : weth; + + uint256 amountIn = (amountOut * arm.PRICE_SCALE()) / price(tokenIn) + 3; + + // Emit event to see it directly in logs + emit EstimateAmountIn(amountIn); + + return amountIn; + } + + /// @notice Helpers to calcul the expected amountOut of tokenOut used in swapExactTokensForTokens. + function estimateAmountOut(IERC20 tokenIn, uint256 amountIn) public returns (uint256) { + uint256 amountOut = (amountIn * price(tokenIn)) / arm.PRICE_SCALE(); + + // Emit event to see it directly in logs + emit EstimateAmountOut(amountOut); + + return amountOut; + } + + /// @notice Helpers to calcul the liquidity available for a token, especially for WETH and withdraw queue. + function liquidityAvailable(IERC20 token) public view returns (uint256 liquidity) { + if (token == weth) { + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + uint256 reserve = weth.balanceOf(address(arm)); + if (outstandingWithdrawals > reserve) return 0; + return reserve - outstandingWithdrawals; + } else if (token == steth) { + return steth.balanceOf(address(arm)); + } + } + + /// @notice Helpers to get the price of a token in the ARM. + function price(IERC20 token) public view returns (uint256) { + return token == arm.token0() ? arm.traderate0() : arm.traderate1(); + } +} diff --git a/test/invariants/mocks/MockLidoWithdraw.sol b/test/invariants/mocks/MockLidoWithdraw.sol new file mode 100644 index 0000000..b69cfe2 --- /dev/null +++ b/test/invariants/mocks/MockLidoWithdraw.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {Vm} from "forge-std/Vm.sol"; + +// Solmate +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +contract MockLidoWithdraw { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + ////////////////////////////////////////////////////// + /// --- STRUCTS & ENUMS + ////////////////////////////////////////////////////// + struct Request { + bool claimed; + address owner; + uint256 amount; + } + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + ERC20 public steth; + + uint256 public counter; + + // Request Id -> Request struct + mapping(uint256 => Request) public requests; + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(address _steth) { + steth = ERC20(_steth); + } + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function requestWithdrawals(uint256[] memory amounts, address owner) external returns (uint256[] memory) { + uint256 len = amounts.length; + uint256[] memory userRequests = new uint256[](len); + + for (uint256 i; i < len; i++) { + require(amounts[i] <= 1_000 ether, "Mock LW: Withdraw amount too big"); + + // Due to rounding error issue, we need to check balance before and after. + uint256 balBefore = steth.balanceOf(address(this)); + steth.transferFrom(msg.sender, address(this), amounts[i]); + uint256 amount = steth.balanceOf(address(this)) - balBefore; + + // Update request mapping + requests[counter] = Request({claimed: false, owner: owner, amount: amount}); + userRequests[i] = counter; + // Increase request count + counter++; + } + + return userRequests; + } + + function claimWithdrawals(uint256[] memory requestId, uint256[] memory) external { + uint256 sum; + uint256 len = requestId.length; + for (uint256 i; i < len; i++) { + // Cache id + uint256 id = requestId[i]; + + // Ensure msg.sender is the owner + require(requests[id].owner == msg.sender, "Mock LW: Not owner"); + requests[id].claimed = true; + sum += requests[id].amount; + } + + // Send sum of eth + vm.deal(address(msg.sender), address(msg.sender).balance + sum); + } + + function getLastCheckpointIndex() external returns (uint256) {} + + function findCheckpointHints(uint256[] memory, uint256, uint256) external returns (uint256[] memory) {} +} diff --git a/test/invariants/mocks/MockSTETH.sol b/test/invariants/mocks/MockSTETH.sol new file mode 100644 index 0000000..ca5f3e1 --- /dev/null +++ b/test/invariants/mocks/MockSTETH.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Vm} from "forge-std/Vm.sol"; + +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +contract MockSTETH is ERC20 { + ////////////////////////////////////////////////////// + /// --- CONSTANTS & IMMUTABLES + ////////////////////////////////////////////////////// + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + uint256 public sum_of_errors; + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor() ERC20("Liquid staked Ether 2.0", "stETH", 18) {} + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function transfer(address to, uint256 amount) public override returns (bool) { + return super.transfer(to, brutalizeAmount(amount)); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + return super.transferFrom(from, to, brutalizeAmount(amount)); + } + + function brutalizeAmount(uint256 amount) public returns (uint256) { + // Only brutalize the sender doesn't sent all of their balance + if (balanceOf[msg.sender] != amount && amount > 0) { + // Get a random number between 0 and 1 + uint256 randomUint = vm.randomUint(0, 1); + // If the amount is greater than the random number, subtract the random number from the amount + if (amount > randomUint) { + amount -= randomUint; + sum_of_errors += randomUint; + } + } + return amount; + } +} diff --git a/test/invariants/shared/Shared.sol b/test/invariants/shared/Shared.sol new file mode 100644 index 0000000..53a4463 --- /dev/null +++ b/test/invariants/shared/Shared.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Base_Test_} from "test/Base.sol"; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {WETH} from "@solmate/tokens/WETH.sol"; + +// Mocks +import {MockSTETH} from "../mocks/MockSTETH.sol"; +import {MockLidoWithdraw} from "../mocks/MockLidoWithdraw.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +/// @notice Shared invariant test contract +/// @dev This contract should be used for deploying all contracts and mocks needed for the test. +abstract contract Invariant_Shared_Test_ is Base_Test_ { + address[] public users; + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + + function setUp() public virtual override { + super.setUp(); + + // 1. Setup a realistic test environnement, not needed as not time related. + // _setUpRealisticEnvironnement() + + // 2. Create user + _createUsers(); + + // To increase performance, we will not use fork., mocking contract instead. + // 3. Deploy mocks. + _deployMocks(); + + // 4. Deploy contracts. + _deployContracts(); + + // 5. Label addresses + labelAll(); + } + + function _setUpRealisticEnvironnement() private { + vm.warp(1000); + vm.roll(1000); + } + + function _createUsers() private { + // Users with role + deployer = makeAddr("Deployer"); + governor = makeAddr("Governor"); + operator = makeAddr("Operator"); + feeCollector = makeAddr("Fee Collector"); + + // Random users + alice = makeAddr("Alice"); + bob = makeAddr("Bob"); + charlie = makeAddr("Charlie"); + dave = makeAddr("Dave"); + eve = makeAddr("Eve"); + frank = makeAddr("Frank"); + george = makeAddr("George"); + harry = makeAddr("Harry"); + + // Add users to the list + users.push(alice); + users.push(bob); + users.push(charlie); + users.push(dave); + users.push(eve); + users.push(frank); + users.push(george); + users.push(harry); + } + + ////////////////////////////////////////////////////// + /// --- MOCKS + ////////////////////////////////////////////////////// + function _deployMocks() private { + // WETH + weth = IERC20(address(new WETH())); + + // STETH + steth = IERC20(address(new MockSTETH())); + + // Lido Withdraw + lidoWithdraw = address(new MockLidoWithdraw(address(steth))); + } + + ////////////////////////////////////////////////////// + /// --- CONTRACTS + ////////////////////////////////////////////////////// + function _deployContracts() private { + vm.startPrank(deployer); + + // 1. Deploy all proxies. + _deployProxies(); + + // 2. Deploy Liquidity Provider Controller. + _deployLPC(); + + // 3. Deploy Lido ARM. + _deployLidoARM(); + + vm.stopPrank(); + } + + function _deployProxies() private { + lpcProxy = new Proxy(); + lidoProxy = new Proxy(); + } + + function _deployLPC() private { + // Deploy CapManager implementation. + CapManager lpcImpl = new CapManager(address(lidoProxy)); + + // Initialize Proxy with CapManager implementation. + bytes memory data = abi.encodeWithSignature("initialize(address)", operator); + lpcProxy.initialize(address(lpcImpl), address(this), data); + + // Set the Proxy as the CapManager. + capManager = CapManager(payable(address(lpcProxy))); + } + + function _deployLidoARM() private { + // Deploy LidoARM implementation. + LidoARM lidoImpl = new LidoARM(address(steth), address(weth), lidoWithdraw, 10 minutes); + + // Deployer will need WETH to initialize the ARM. + deal(address(weth), address(deployer), MIN_TOTAL_SUPPLY); + weth.approve(address(lidoProxy), MIN_TOTAL_SUPPLY); + + // Initialize Proxy with LidoARM implementation. + bytes memory data = abi.encodeWithSignature( + "initialize(string,string,address,uint256,address,address)", + "Lido ARM", + "ARM-ST", + operator, + 2000, // 20% performance fee + feeCollector, + address(lpcProxy) + ); + lidoProxy.initialize(address(lidoImpl), address(this), data); + + // Set the Proxy as the LidoARM. + lidoARM = LidoARM(payable(address(lidoProxy))); + } +} diff --git a/test/smoke/LidoARMSmokeTest.t.sol b/test/smoke/LidoARMSmokeTest.t.sol new file mode 100644 index 0000000..b558645 --- /dev/null +++ b/test/smoke/LidoARMSmokeTest.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Test, console2} from "forge-std/Test.sol"; + +import {AbstractSmokeTest} from "./AbstractSmokeTest.sol"; + +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {Proxy} from "contracts/Proxy.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; +import {console} from "forge-std/console.sol"; + +contract Fork_LidoARM_Smoke_Test is AbstractSmokeTest { + IERC20 BAD_TOKEN = IERC20(makeAddr("bad token")); + + IERC20 weth; + IERC20 steth; + Proxy proxy; + LidoARM lidoARM; + CapManager capManager; + address operator; + + function setUp() public { + weth = IERC20(resolver.resolve("WETH")); + steth = IERC20(resolver.resolve("STETH")); + operator = resolver.resolve("OPERATOR"); + + vm.label(address(weth), "WETH"); + vm.label(address(steth), "stETH"); + vm.label(address(operator), "OPERATOR"); + + proxy = Proxy(payable(deployManager.getDeployment("LIDO_ARM"))); + lidoARM = LidoARM(payable(deployManager.getDeployment("LIDO_ARM"))); + capManager = CapManager(deployManager.getDeployment("LIDO_ARM_CAP_MAN")); + + // Only fuzz from this address. Big speedup on fork. + targetSender(address(this)); + } + + function test_initialConfig() external view { + assertEq(lidoARM.name(), "Lido ARM", "Name"); + assertEq(lidoARM.symbol(), "ARM-WETH-stETH", "Symbol"); + assertEq(lidoARM.owner(), Mainnet.GOV_MULTISIG, "Owner"); + assertEq(lidoARM.operator(), Mainnet.ARM_RELAYER, "Operator"); + assertEq(lidoARM.feeCollector(), Mainnet.ARM_BUYBACK, "Fee collector"); + assertEq((100 * uint256(lidoARM.fee())) / lidoARM.FEE_SCALE(), 20, "Performance fee as a percentage"); + // LidoLiquidityManager + assertEq(address(lidoARM.lidoWithdrawalQueue()), Mainnet.LIDO_WITHDRAWAL, "Lido withdrawal queue"); + assertEq(address(lidoARM.steth()), Mainnet.STETH, "stETH"); + assertEq(address(lidoARM.weth()), Mainnet.WETH, "WETH"); + assertEq(lidoARM.liquidityAsset(), Mainnet.WETH, "liquidity asset"); + assertEq(lidoARM.claimDelay(), 10 minutes, "claim delay"); + assertEq(lidoARM.crossPrice(), 0.9998e36, "cross price"); + + assertEq(capManager.accountCapEnabled(), false, "account cap enabled"); + assertEq(capManager.operator(), Mainnet.ARM_RELAYER, "Operator"); + assertEq(capManager.arm(), address(lidoARM), "arm"); + } + + function test_swap_exact_steth_for_weth() external { + // trader sells stETH and buys WETH, the ARM buys stETH as a + // 4 bps discount + _swapExactTokensForTokens(steth, weth, 9996e32, 100 ether); + // 10 bps discount + _swapExactTokensForTokens(steth, weth, 9990e32, 1e15); + // 20 bps discount + _swapExactTokensForTokens(steth, weth, 9980e32, 1 ether); + } + + function test_swap_exact_weth_for_steth() external { + // trader buys stETH and sells WETH, the ARM sells stETH at a + // 1 bps discount + _swapExactTokensForTokens(weth, steth, 9999e32, 10 ether); + // 2 bps discount + _swapExactTokensForTokens(weth, steth, 9998e32, 100 ether); + } + + function test_swapTokensForExactTokens() external { + // trader sells stETH and buys WETH, the ARM buys stETH at a + // 4 bps discount + _swapTokensForExactTokens(steth, weth, 9996e32, 10 ether); + // 10 bps discount + _swapTokensForExactTokens(steth, weth, 9990e32, 100 ether); + // 50 bps discount + _swapTokensForExactTokens(steth, weth, 9950e32, 10 ether); + } + + function _swapExactTokensForTokens(IERC20 inToken, IERC20 outToken, uint256 price, uint256 amountIn) internal { + uint256 expectedOut; + if (inToken == weth) { + // Trader is buying stETH and selling WETH + // the ARM is selling stETH and buying WETH + deal(address(weth), address(this), 1000 ether); + _dealStETH(address(lidoARM), 1000 ether); + + expectedOut = amountIn * 1e36 / price; + + vm.prank(Mainnet.ARM_RELAYER); + lidoARM.setPrices(price - 2e32, price); + } else { + // Trader is selling stETH and buying WETH + // the ARM is buying stETH and selling WETH + _dealStETH(address(this), 1000 ether); + deal(address(weth), address(lidoARM), 1000 ether); + + expectedOut = amountIn * price / 1e36; + + vm.prank(Mainnet.ARM_RELAYER); + uint256 sellPrice = price < 9996e32 ? 9998e32 : price + 2e32; + lidoARM.setPrices(price, sellPrice); + } + // Approve the ARM to transfer the input token of the swap. + inToken.approve(address(lidoARM), amountIn); + + uint256 startIn = inToken.balanceOf(address(this)); + uint256 startOut = outToken.balanceOf(address(this)); + + lidoARM.swapExactTokensForTokens(inToken, outToken, amountIn, 0, address(this)); + + assertApproxEqAbs(inToken.balanceOf(address(this)), startIn - amountIn, 2, "In actual"); + assertApproxEqAbs(outToken.balanceOf(address(this)), startOut + expectedOut, 2, "Out actual"); + } + + function _swapTokensForExactTokens(IERC20 inToken, IERC20 outToken, uint256 price, uint256 amountOut) internal { + uint256 expectedIn; + if (inToken == weth) { + // Trader is buying stETH and selling WETH + // the ARM is selling stETH and buying WETH + deal(address(weth), address(this), 1000 ether); + _dealStETH(address(lidoARM), 1000 ether); + + expectedIn = amountOut * price / 1e36; + + vm.prank(Mainnet.ARM_RELAYER); + lidoARM.setPrices(price - 2e32, price); + } else { + // Trader is selling stETH and buying WETH + // the ARM is buying stETH and selling WETH + _dealStETH(address(this), 1000 ether); + deal(address(weth), address(lidoARM), 1000 ether); + // _dealWETH(address(lidoARM), 1000 ether); + + expectedIn = amountOut * 1e36 / price + 3; + + vm.prank(Mainnet.ARM_RELAYER); + uint256 sellPrice = price < 9996e32 ? 9998e32 : price + 2e32; + lidoARM.setPrices(price, sellPrice); + } + // Approve the ARM to transfer the input token of the swap. + inToken.approve(address(lidoARM), expectedIn + 10000); + console.log("Approved Lido ARM to spend %d", inToken.allowance(address(this), address(lidoARM))); + console.log("In token balance: %d", inToken.balanceOf(address(this))); + + uint256 startIn = inToken.balanceOf(address(this)); + uint256 startOut = outToken.balanceOf(address(this)); + + lidoARM.swapTokensForExactTokens(inToken, outToken, amountOut, 3 * amountOut, address(this)); + + assertApproxEqAbs(inToken.balanceOf(address(this)), startIn - expectedIn, 2, "In actual"); + assertApproxEqAbs(outToken.balanceOf(address(this)), startOut + amountOut, 2, "Out actual"); + } + + function test_proxy_unauthorizedAccess() external { + address RANDOM_ADDRESS = 0xfEEDBeef00000000000000000000000000000000; + vm.startPrank(RANDOM_ADDRESS); + + // Proxy's restricted methods. + vm.expectRevert("OSwap: Only owner can call this function."); + proxy.setOwner(RANDOM_ADDRESS); + + vm.expectRevert("OSwap: Only owner can call this function."); + proxy.initialize(address(this), address(this), ""); + + vm.expectRevert("OSwap: Only owner can call this function."); + proxy.upgradeTo(address(this)); + + vm.expectRevert("OSwap: Only owner can call this function."); + proxy.upgradeToAndCall(address(this), ""); + + // Implementation's restricted methods. + vm.expectRevert("OSwap: Only owner can call this function."); + lidoARM.setOwner(RANDOM_ADDRESS); + } + + // TODO replace _dealStETH with just deal + function _dealStETH(address to, uint256 amount) internal { + vm.prank(0xEB9c1CE881F0bDB25EAc4D74FccbAcF4Dd81020a); + steth.transfer(to, amount + 2); + // deal(address(steth), to, amount); + } + + /* Operator Tests */ + + function test_setOperator() external { + vm.prank(Mainnet.GOV_MULTISIG); + lidoARM.setOperator(address(this)); + assertEq(lidoARM.operator(), address(this)); + } + + function test_nonOwnerCannotSetOperator() external { + vm.expectRevert("ARM: Only owner can call this function."); + vm.prank(operator); + lidoARM.setOperator(operator); + } +} diff --git a/test/smoke/OethARMSmokeTest.t.sol b/test/smoke/OethARMSmokeTest.t.sol index 3f01057..abbbc2d 100644 --- a/test/smoke/OethARMSmokeTest.t.sol +++ b/test/smoke/OethARMSmokeTest.t.sol @@ -10,7 +10,7 @@ import {OethARM} from "contracts/OethARM.sol"; import {Proxy} from "contracts/Proxy.sol"; import {Mainnet} from "contracts/utils/Addresses.sol"; -contract OethARMSmokeTest is AbstractSmokeTest { +contract Fork_OethARM_Smoke_Test is AbstractSmokeTest { IERC20 BAD_TOKEN = IERC20(makeAddr("bad token")); IERC20 weth; @@ -28,7 +28,7 @@ contract OethARMSmokeTest is AbstractSmokeTest { vm.label(address(oeth), "OETH"); vm.label(address(operator), "OPERATOR"); - proxy = Proxy(deployManager.getDeployment("OETH_ARM")); + proxy = Proxy(payable(deployManager.getDeployment("OETH_ARM"))); oethARM = OethARM(deployManager.getDeployment("OETH_ARM")); _dealWETH(address(oethARM), 100 ether);