diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f5b358..f0cc938 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,4 +25,4 @@ jobs: run: forge test --match-contract FORK_LYRA_ --fork-url https://rpc.lyra.finance - name: Run Mainnet fork tests - run: forge test --match-contract FORK_MAINNET_ --fork-url https://mainnet.infura.io/v3/743507feddbd4a8088614092511076bc -vvv \ No newline at end of file + run: forge test --match-contract FORK_MAINNET_ --fork-url https://mainnet.infura.io/v3/b3801473275f4a0a846ea7fe5a629349 -vvv \ No newline at end of file diff --git a/src/intents/IntentExecutorBase.sol b/src/intents/IntentExecutorBase.sol new file mode 100644 index 0000000..2474745 --- /dev/null +++ b/src/intents/IntentExecutorBase.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "../../lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SafeERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title IntentExecutorBase + * @notice A shared contract that allows authorized EOAs to execute intents + */ +abstract contract IntentExecutorBase is Ownable { + using SafeERC20 for IERC20; + + /** + * @notice Whether the account is an intent executor + */ + mapping(address => bool) public isIntentExecutor; + + /** + * @notice The error emitted when the caller is not an intent executor + */ + error NotIntentExecutor(); + + /** + * @notice The event emitted when an intent executor is set + */ + event IntentExecutorSet(address indexed executor, bool isIntentExecutor); + + /** + * @notice Set an EOA as an intent executor + * @param _executor The EOA address + * @param _isIntentExecutor Whether the EOA is an intent executor + */ + function setIntentExecutor(address _executor, bool _isIntentExecutor) external onlyOwner { + isIntentExecutor[_executor] = _isIntentExecutor; + + emit IntentExecutorSet(_executor, _isIntentExecutor); + } + + /** + * @notice Allow owner to transfer any token out of the contract + * @param token The address of the token to rescue + */ + function rescueToken(address token) external onlyOwner { + // intentionally ignore return value + address(token).call(abi.encodeWithSignature("transfer(address,uint256)", msg.sender, IERC20(token).balanceOf(address(this)))); + } + + /** + * @notice Verify that the caller is an intent executor + */ + function _verifyIntentExecutor() internal view { + if (!isIntentExecutor[msg.sender]) revert NotIntentExecutor(); + } + + modifier onlyIntentExecutor() { + _verifyIntentExecutor(); + _; + } +} diff --git a/src/intents/StakeDRVIntent.sol b/src/intents/StakeDRVIntent.sol new file mode 100644 index 0000000..f869cfc --- /dev/null +++ b/src/intents/StakeDRVIntent.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IStakedDRV} from "../interfaces/derive/IStakedDRV.sol"; + +import {IntentExecutorBase} from "./IntentExecutorBase.sol"; + +/** + * @title StakeDRVIntent + * @notice A shared contract that allows executor to help users auto stake DRV from smart wallet + * @dev Users who wish to have the auto-stake feature need to approve this contract to spend their DRV + */ +contract StakeDRVIntent is IntentExecutorBase { + address public immutable DRV; + address public immutable StakedDRV; + + using SafeERC20 for IERC20; + + event IntentStakeDRV(address indexed scw, uint256 amount); + + constructor(address _drv, address _stakedDRV) { + DRV = _drv; + StakedDRV = _stakedDRV; + IERC20(_drv).approve(address(_stakedDRV), type(uint256).max); + } + + /** + * @notice Execute a stake intent to auto stake DRV. + * @dev The SCW must have approved this contract to spend the token. + * @param scw The light account address + * @param amount The amount of DRV to stake + */ + function executeStakeDRVIntent(address scw, uint256 amount) external onlyIntentExecutor { + IERC20(DRV).safeTransferFrom(scw, address(this), amount); + IStakedDRV(StakedDRV).convertTo(amount, scw); + emit IntentStakeDRV(scw, amount); + } +} diff --git a/src/intents/SubaccountDepositIntent.sol b/src/intents/SubaccountDepositIntent.sol new file mode 100644 index 0000000..95f9c24 --- /dev/null +++ b/src/intents/SubaccountDepositIntent.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IntentExecutorBase} from "./IntentExecutorBase.sol"; +import {ISubaccounts} from "../interfaces/ISubaccounts.sol"; +import {IERC20BasedAsset} from "../interfaces/derive/IERC20BasedAsset.sol"; +import {IMatching} from "../interfaces/derive/IMatching.sol"; +import {IStandardManager} from "../interfaces/derive/IStandardManager.sol"; +import {IPMRM2} from "../interfaces/derive/IPMRM2.sol"; +import {SafeERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title SubaccountDepositIntent + * @notice A shared contract that allows authorized user to deposit LightAccount tokens into Derive Subaccounts + * @dev Users who wish to have the auto-deposit feature need to approve this contract to spend their tokens + */ +contract SubaccountDepositIntent is IntentExecutorBase { + using SafeERC20 for IERC20; + + /// @dev The matching contract user deposits their subaccounts into to trade on Derive. + IMatching public immutable MATCHING; + + /// @dev Derive Subaccounts + ISubaccounts public immutable SUBACCOUNTS; + + /// @dev Special derive asset that's the base unit of the manager accounting system. + address public immutable CASH; + + error SubaccountOwnerMismatch(); + error DeriveAssetNotAllowed(); + + event IntentDeposit(uint256 indexed subaccountId, address indexed scw, address indexed token, uint256 amount); + event ManagerTypeSet(address indexed manager, uint256 indexed managerType); + + // The type of manager that is allowed to be used + enum ManagerType { + None, + Standard, + PM2 + } + + // Derive v2 asset addresses that are allowed to be deposited for intent executors + mapping(address manager => ManagerType) public managerTypes; + + constructor(IMatching _matching, address _cash) { + MATCHING = _matching; + + SUBACCOUNTS = ISubaccounts(_matching.subAccounts()); + + CASH = _cash; + } + + /** + * @notice Route tokens to a subaccount + * @param scw The light account address + * @param subaccountId The Derive subaccount ID + * @param deriveAsset The derive v2 asset address (IAsset) + * @param amount The amount of tokens to route + */ + function executeDepositIntent(address scw, uint256 subaccountId, address deriveAsset, uint256 amount) + external + onlyIntentExecutor + { + // Can only deposit to subaccounts that are owned by the SCW + _verifySubaccountOwner(subaccountId, scw); + + // Can only deposit to derive v2 assets that are allowed + if (!_isAllowedDeriveAsset(subaccountId, deriveAsset)) revert DeriveAssetNotAllowed(); + + IERC20 token = IERC20BasedAsset(deriveAsset).wrappedAsset(); + token.safeTransferFrom(scw, address(this), amount); + token.safeApprove(address(deriveAsset), amount); + + IERC20BasedAsset(deriveAsset).deposit(subaccountId, amount); + + emit IntentDeposit(subaccountId, scw, address(token), amount); + } + + /** + * @notice Verify that the subaccount owner is correct + * @param subaccountId The Derive subaccount ID + * @param scw The LightAccount address + */ + function _verifySubaccountOwner(uint256 subaccountId, address scw) internal view { + if (MATCHING.subAccountToOwner(subaccountId) != scw) revert SubaccountOwnerMismatch(); + } + + /** + * @notice Set the allowed derive v2 asset + * @param manager The derive v2 manager + * @param managerType The type of manager + */ + function setManagerTypes(address manager, ManagerType managerType) external onlyOwner { + managerTypes[manager] = managerType; + + emit ManagerTypeSet(manager, uint256(managerType)); + } + + /** + * @notice Check if the derive asset is valid given a subaccountId + * @dev We first check if the subaccountId is managed by a legitimate manager, then based on manager type, + * read the allowed derive asset list + * @param subaccountId subaccount ID + * @param deriveAsset address of the derive asset + */ + function _isAllowedDeriveAsset(uint256 subaccountId, address deriveAsset) internal view returns (bool) { + if (deriveAsset == CASH) return true; + + address manager = SUBACCOUNTS.manager(subaccountId); + + ManagerType managerType = managerTypes[manager]; + + if (managerType == ManagerType.None) { + return false; + } else if (managerType == ManagerType.Standard) { + IStandardManager.AssetDetail memory params = IStandardManager(manager).assetDetails(deriveAsset); + return params.isWhitelisted; + } else if (managerType == ManagerType.PM2) { + IPMRM2.CollateralParameters memory params = IPMRM2(manager).getCollateralParameters(deriveAsset); + return params.isEnabled; + } else { + return false; + } + } +} diff --git a/src/intents/WithdrawBridgeIntent.sol b/src/intents/WithdrawBridgeIntent.sol new file mode 100644 index 0000000..1dfdf56 --- /dev/null +++ b/src/intents/WithdrawBridgeIntent.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IntentExecutorBase} from "./IntentExecutorBase.sol"; +import {ILightAccount} from "../interfaces/ILightAccount.sol"; +import {ISocketWithdrawWrapper} from "../interfaces/derive/ISocketWithdrawWrapper.sol"; +import {IOFTWithdrawWrapper} from "../interfaces/derive/IOFTWithdrawWrapper.sol"; +import {SafeERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title WithdrawBridgeIntent + * @notice A shared contract that allows executor to help users to withdraw tokens from LightAccount off Derive through bridges + * @dev Users who wish to have the auto-withdraw feature need to approve this contract to spend their tokens + * + * @dev Trust Assumptions: + * - Users must trust the executor not to arbitrarily execute withdrawals. + * - Users must trust the owner to not add malicious executor + * - Users rely on executors to provide a valid maxFee for each action to avoid being charged high fees by bridges. + */ +contract WithdrawBridgeIntent is IntentExecutorBase { + using SafeERC20 for IERC20; + + ISocketWithdrawWrapper public immutable SOCKET_WITHDRAW_WRAPPER; + + IOFTWithdrawWrapper public immutable IOFT_WITHDRAW_WRAPPER; + + /// @dev Width of each bucket in seconds + uint64 public bucketWidth; + /// @dev The last time a bucket started + uint64 public lastBucketStart; + /// @dev Maximum number of withdrawals per bucket + uint64 public maxWithdrawPerBucket; + /// @dev Number of withdrawals for the current bucket + uint64 public withdrawCount; + + error InvalidRecipient(); + error FeeTooHigh(); + error WithdrawLimitReached(); + + event IntentWithdrawSocket( + address indexed scw, + address indexed token, + uint256 amount, + address recipient, + address controller, + address connector + ); + + event IntentWithdrawLZ( + address indexed scw, address indexed token, uint256 amount, address recipient, uint32 destEID + ); + + event BucketParamsSet(uint64 bucketWidth, uint64 maxWithdrawPerBucket); + + constructor(ISocketWithdrawWrapper _socketBridge, IOFTWithdrawWrapper _iOFTBridge) { + SOCKET_WITHDRAW_WRAPPER = _socketBridge; + IOFT_WITHDRAW_WRAPPER = _iOFTBridge; + } + + /** + * @notice Set the bucket parameters + * @param _bucketWidth The width of each bucket in seconds + * @param _maxWithdrawPerBucket The maximum number of withdrawals per bucket + */ + function setBucketParams(uint64 _bucketWidth, uint64 _maxWithdrawPerBucket) external onlyOwner { + bucketWidth = _bucketWidth; + maxWithdrawPerBucket = _maxWithdrawPerBucket; + + emit BucketParamsSet(_bucketWidth, _maxWithdrawPerBucket); + } + + /** + * @notice Check if the current withdraw limit is exceeded + * @return true if the withdraw limit is exceeded, false otherwise + */ + function isWithdrawLimitReached() external view returns (bool) { + if (block.timestamp >= lastBucketStart + bucketWidth) return false; + + return withdrawCount >= maxWithdrawPerBucket; + } + + /** + * @notice Execute a withdraw intent to auto bridge tokens off Derive. + * @dev The SCW must have approved this contract to spend the token. + * @param scw The light account address + * @param token The ERC20 token address + * @param amount The amount of tokens to withdraw + * @param recipient The recipient address, must specify explicitly as the SCW owner + * @param controller The Socket Controller address + * @param connector The Socket Connector address + */ + function executeWithdrawIntentSocket( + address scw, + address token, + uint256 amount, + uint256 maxFee, + address recipient, + address controller, + address connector, + uint256 gasLimit + ) external onlyIntentExecutor { + _checkAndUpdateWithdrawCount(); + + IERC20(token).safeTransferFrom(scw, address(this), amount); + IERC20(token).safeApprove(address(SOCKET_WITHDRAW_WRAPPER), amount); + + if (maxFee != type(uint256).max) { + uint256 feeInToken = SOCKET_WITHDRAW_WRAPPER.getFeeInToken(token, controller, connector, gasLimit); + if (feeInToken > maxFee) revert FeeTooHigh(); + } + + // The recipient must be the owner of the SCW + if (ILightAccount(scw).owner() != recipient) { + revert InvalidRecipient(); + } + + SOCKET_WITHDRAW_WRAPPER.withdrawToChain(token, amount, recipient, controller, connector, gasLimit); + + emit IntentWithdrawSocket(scw, token, amount, recipient, controller, connector); + } + + /** + * @notice Execute a withdraw intent to auto bridge tokens off Derive through LayerZero OFT Wrapper. + * @dev The SCW must have approved this contract to spend the token. + * @param scw The light account address + * @param token The ERC20 token address + * @param amount The amount of tokens to withdraw + * @param maxFee The maximum fee for the withdraw bridge + * @param recipient The recipient address, must specify explicitly as the SCW owner + * @param destEID The destination EID + */ + function executeWithdrawIntentLZ( + address scw, + address token, + uint256 amount, + uint256 maxFee, + address recipient, + uint32 destEID + ) external onlyIntentExecutor { + _checkAndUpdateWithdrawCount(); + + IERC20(token).safeTransferFrom(scw, address(this), amount); + IERC20(token).safeApprove(address(IOFT_WITHDRAW_WRAPPER), amount); + + if (maxFee != type(uint256).max) { + uint256 feeInToken = IOFT_WITHDRAW_WRAPPER.getFeeInToken(token, amount, destEID); + if (feeInToken > maxFee) revert FeeTooHigh(); + } + + // The recipient must be the owner of the SCW + if (ILightAccount(scw).owner() != recipient) { + revert InvalidRecipient(); + } + + IOFT_WITHDRAW_WRAPPER.withdrawToChain(token, amount, recipient, destEID); + + emit IntentWithdrawLZ(scw, token, amount, recipient, destEID); + } + + /** + * @dev check that the number of withdrawals for the current bucket is less than the maximum + */ + function _checkAndUpdateWithdrawCount() internal { + if (block.timestamp >= lastBucketStart + bucketWidth) { + lastBucketStart = uint64(block.timestamp); + withdrawCount = 0; + } + + withdrawCount++; + + if (withdrawCount > maxWithdrawPerBucket) revert WithdrawLimitReached(); + } +} diff --git a/src/interfaces/ILightAccount.sol b/src/interfaces/ILightAccount.sol new file mode 100644 index 0000000..3efd78b --- /dev/null +++ b/src/interfaces/ILightAccount.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.9; + +interface ILightAccount { + function owner() external view returns (address); +} diff --git a/src/interfaces/ISubaccounts.sol b/src/interfaces/ISubaccounts.sol new file mode 100644 index 0000000..8a2b9e3 --- /dev/null +++ b/src/interfaces/ISubaccounts.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable contract-name-camelcase +// solhint-disable func-name-mixedcase + +pragma solidity ^0.8.18; + +import {IERC721} from "lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol"; + +interface ISubaccounts is IERC721 { + function getBalance(uint256 subaccountId, address asset, uint256 subId) external view returns (uint256); + + function manager(uint256 subaccountId) external view returns (address); + + function lastAccountId() external view returns (uint256); + + function createAccount(address owner, address _manager) external returns (uint256 newId); +} diff --git a/src/interfaces/derive/ICash.sol b/src/interfaces/derive/ICash.sol new file mode 100644 index 0000000..1a96dcb --- /dev/null +++ b/src/interfaces/derive/ICash.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +interface ICash { + function accrueInterest() external; + + function calculateBalanceWithInterest(uint256 accountId) external returns (int256 balance); +} diff --git a/src/interfaces/derive/IERC20BasedAsset.sol b/src/interfaces/derive/IERC20BasedAsset.sol new file mode 100644 index 0000000..5550ef0 --- /dev/null +++ b/src/interfaces/derive/IERC20BasedAsset.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +import "openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; + +interface IERC20BasedAsset { + function wrappedAsset() external view returns (IERC20Metadata); + function deposit(uint256 recipientAccount, uint256 assetAmount) external; + function withdraw(uint256 accountId, uint256 assetAmount, address recipient) external; +} diff --git a/src/interfaces/derive/IMatching.sol b/src/interfaces/derive/IMatching.sol new file mode 100644 index 0000000..916ff35 --- /dev/null +++ b/src/interfaces/derive/IMatching.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +interface IMatching { + function subAccountToOwner(uint256 subAccountId) external view returns (address); + + function subAccounts() external view returns (address); + + function createSubAccount(address manager) external returns (uint256); +} diff --git a/src/interfaces/derive/IOFTWithdrawWrapper.sol b/src/interfaces/derive/IOFTWithdrawWrapper.sol new file mode 100644 index 0000000..6ccecff --- /dev/null +++ b/src/interfaces/derive/IOFTWithdrawWrapper.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +import "openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; + +interface IOFTWithdrawWrapper { + function withdrawToChain(address token, uint256 amount, address toAddress, uint32 destEID) external; + + function getFeeInToken(address token, uint256 amount, uint32 destEID) external view returns (uint256); +} diff --git a/src/interfaces/derive/IPMRM2.sol b/src/interfaces/derive/IPMRM2.sol new file mode 100644 index 0000000..5ce44c2 --- /dev/null +++ b/src/interfaces/derive/IPMRM2.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +interface IPMRM2 { + // Defined once per collateral + struct CollateralParameters { + bool isEnabled; + bool isRiskCancelling; + /// @dev % value of collateral to subtract from MM. Must be <= 1 + uint256 MMHaircut; + /// @dev % value of collateral to subtract from IM. Added on top of MMHaircut. Must be <= 1 + uint256 IMHaircut; + } + + function getCollateralParameters(address collateral) external view returns (CollateralParameters memory); +} diff --git a/src/interfaces/derive/ISocketWithdrawWrapper.sol b/src/interfaces/derive/ISocketWithdrawWrapper.sol new file mode 100644 index 0000000..4b7bea6 --- /dev/null +++ b/src/interfaces/derive/ISocketWithdrawWrapper.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +import "openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; + +interface ISocketWithdrawWrapper { + function withdrawToChain( + address token, + uint256 amount, + address recipient, + address socketController, + address connector, + uint256 gasLimit + ) external; + + function getFeeInToken(address token, address controller, address connector, uint256 gasLimit) + external + view + returns (uint256); +} diff --git a/src/interfaces/derive/IStakedDRV.sol b/src/interfaces/derive/IStakedDRV.sol new file mode 100644 index 0000000..5831728 --- /dev/null +++ b/src/interfaces/derive/IStakedDRV.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; + +interface IStakedDRV is IERC20 { + function convertTo(uint256 amount, address to) external; +} diff --git a/src/interfaces/derive/IStandardManager.sol b/src/interfaces/derive/IStandardManager.sol new file mode 100644 index 0000000..3e1e6ab --- /dev/null +++ b/src/interfaces/derive/IStandardManager.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.13; + +interface IStandardManager { + enum AssetType { + NotSet, + Option, + Perpetual, + Base + } + + struct AssetDetail { + bool isWhitelisted; + AssetType assetType; + uint256 marketId; + } + + function assetDetails(address asset) external view returns (AssetDetail memory); + + function settlePerpsWithIndex(uint256 subaccountId) external; +} diff --git a/src/withdraw/LyraWithdrawWrapperV2New.sol b/src/withdraw/LyraWithdrawWrapperV2New.sol index 4246334..b05c191 100644 --- a/src/withdraw/LyraWithdrawWrapperV2New.sol +++ b/src/withdraw/LyraWithdrawWrapperV2New.sol @@ -43,7 +43,7 @@ contract LyraWithdrawWrapperV2New is Ownable { mapping(address token => uint256) public staticPrice; mapping(address => ControllerType) public controllerType; address public feeRecipient; - uint public payloadSize = 161; + uint256 public payloadSize = 161; constructor() payable {} @@ -58,7 +58,7 @@ contract LyraWithdrawWrapperV2New is Ownable { feeRecipient = newRecipient; } - function setPayloadSize(uint newSize) external onlyOwner { + function setPayloadSize(uint256 newSize) external onlyOwner { payloadSize = newSize; } @@ -79,7 +79,7 @@ contract LyraWithdrawWrapperV2New is Ownable { function setStaticRates(address[] memory tokens, uint256[] memory rates) external onlyOwner { require(tokens.length == rates.length, "Array length mismatch"); - for (uint i = 0; i < tokens.length; i++) { + for (uint256 i = 0; i < tokens.length; i++) { staticPrice[tokens[i]] = rates[i]; } } diff --git a/test/fork-tests/intent/StakeDRVIntent.t.sol b/test/fork-tests/intent/StakeDRVIntent.t.sol new file mode 100644 index 0000000..63ec343 --- /dev/null +++ b/test/fork-tests/intent/StakeDRVIntent.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable contract-name-camelcase +// solhint-disable func-name-mixedcase + +pragma solidity ^0.8.18; + +import {Test} from "lib/forge-std/src/Test.sol"; + +import {StakeDRVIntent} from "src/intents/StakeDRVIntent.sol"; +import {IntentExecutorBase} from "src/intents/IntentExecutorBase.sol"; +import {IERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IStakedDRV} from "src/interfaces/derive/IStakedDRV.sol"; +import {ILightAccount} from "src/interfaces/ILightAccount.sol"; + +/** + * forge test --fork-url https://rpc.lyra.finance -vvv + */ +contract FORK_LYRA_StakeDRVIntent is Test { + address public drv = address(0x2EE0fd70756EDC663AcC9676658A1497C247693A); + address public stakedDRV = address(0x7499d654422023a407d92e1D83D387d81BC68De1); + + // Mock scws + address public scw = address(0xa0a); + + StakeDRVIntent public stakeIntent; + address public executor = address(0xb0b); + + /** + * Only run the test when running with --fork flag, and connected to Lyra mainnet + */ + modifier onlyDeriveMainnet() { + if (block.chainid != 957) return; + _; + } + + function setUp() public onlyDeriveMainnet { + stakeIntent = new StakeDRVIntent(drv, stakedDRV); + + deal(drv, scw, 1000 ether); + + // set executor as intent executor + stakeIntent.setIntentExecutor(executor, true); + + // scw approves stakeIntent to spend drv + vm.prank(scw); + IERC20(drv).approve(address(stakeIntent), type(uint256).max); + } + + function test_StakeIntent_DRV() public onlyDeriveMainnet { + uint256 erc20BalanceBefore = IERC20(drv).balanceOf(scw); + uint256 stakedDRVBalanceBefore = IERC20(stakedDRV).balanceOf(scw); + + vm.startPrank(executor); + stakeIntent.executeStakeDRVIntent(scw, 1 ether); + vm.stopPrank(); + + uint256 erc20BalanceAfter = IERC20(drv).balanceOf(scw); + assertEq(erc20BalanceAfter, erc20BalanceBefore - 1 ether); + + uint256 stakedDRVBalanceAfter = IERC20(stakedDRV).balanceOf(scw); + assertEq(stakedDRVBalanceAfter, stakedDRVBalanceBefore + 1 ether); + } + + function test_RevertIf_TriggerByNonExecutor() public onlyDeriveMainnet { + address nonExecutor = address(0x123); + vm.startPrank(nonExecutor); + vm.expectRevert(IntentExecutorBase.NotIntentExecutor.selector); + stakeIntent.executeStakeDRVIntent(scw, 1 ether); + vm.stopPrank(); + } + + function test_RescueToken() public onlyDeriveMainnet { + deal(address(drv), address(stakeIntent), 1000 ether); + + uint256 balanceBefore = IERC20(drv).balanceOf(address(this)); + stakeIntent.rescueToken(drv); + + uint256 balanceAfter = IERC20(drv).balanceOf(address(this)); + assertEq(balanceAfter, balanceBefore + 1000 ether); + } + + receive() external payable {} +} diff --git a/test/fork-tests/intent/SubaccountDepositIntent.t.sol b/test/fork-tests/intent/SubaccountDepositIntent.t.sol new file mode 100644 index 0000000..b1e0380 --- /dev/null +++ b/test/fork-tests/intent/SubaccountDepositIntent.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable contract-name-camelcase func-name-mixedcase +pragma solidity ^0.8.18; + +import {Test} from "lib/forge-std/src/Test.sol"; + +import {SubaccountDepositIntent} from "src/intents/SubaccountDepositIntent.sol"; +import {IntentExecutorBase} from "src/intents/IntentExecutorBase.sol"; +import {IERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {ISubaccounts} from "../../../src/interfaces/ISubaccounts.sol"; +import {IMatching} from "src/interfaces/derive/IMatching.sol"; +import {IStandardManager} from "src/interfaces/derive/IStandardManager.sol"; +import {ICash} from "src/interfaces/derive/ICash.sol"; + +/** + * forge test --fork-url https://rpc.lyra.finance -vvv + */ +contract FORK_LYRA_SubaccountDepositIntent is Test { + // ERC20 token on derive mainnet + address public immutable DAI = address(0xB56D58Ce246C31c4D3a3bFB354996FF28D081dB7); + address public immutable USDC = address(0x6879287835A86F50f784313dBEd5E5cCC5bb8481); + + // Derive v2 asset on derive mainnet + address public immutable DAIAsset = address(0x67bB0B7c87Df9C5C433ac7eCADfa7396A2927fcF); + address public immutable cash = address(0x57B03E14d409ADC7fAb6CFc44b5886CAD2D5f02b); + + // Matching contract on derive mainnet + IMatching public matching = IMatching(0xeB8d770ec18DB98Db922E9D83260A585b9F0DeAD); + ISubaccounts public subaccounts = ISubaccounts(0xE7603DF191D699d8BD9891b821347dbAb889E5a5); + + uint256 public subaccountId; + address public standardManager = address(0x28c9ddF9A3B29c2E6a561c1BC520954e5A33de5D); + + // Any Smart wallet address + address public user = address(0x03CdE1E0bc6C1e096505253b310Cf454b0b462FB); + + SubaccountDepositIntent public depositIntent; + + address public executor = address(0xb0b); + + /** + * Only run the test when running with --fork flag, and connected to Lyra mainnet + */ + modifier onlyDeriveMainnet() { + if (block.chainid != 957) return; + _; + } + + function setUp() public onlyDeriveMainnet { + depositIntent = new SubaccountDepositIntent(matching, cash); + + deal(DAI, user, 10 ether); + deal(USDC, user, 1000 * 1e6); + + // user approves depositIntent to spend DAI + vm.startPrank(user); + + // create a new subaccount to make sure balances are clean + subaccountId = matching.createSubAccount(standardManager); + + IERC20(DAI).approve(address(depositIntent), type(uint256).max); + IERC20(USDC).approve(address(depositIntent), type(uint256).max); + vm.stopPrank(); + + // set executor as intent executor + depositIntent.setIntentExecutor(executor, true); + + // set DAIAsset as allowed derive asset + depositIntent.setManagerTypes( + 0x28c9ddF9A3B29c2E6a561c1BC520954e5A33de5D, SubaccountDepositIntent.ManagerType.Standard + ); + } + + function test_DepositIntent() public onlyDeriveMainnet { + uint256 erc20BalanceBefore = IERC20(DAI).balanceOf(user); + uint256 subaccountBalanceBefore = subaccounts.getBalance(subaccountId, DAIAsset, 0); + + vm.startPrank(executor); + depositIntent.executeDepositIntent(user, subaccountId, DAIAsset, 10 ether); + vm.stopPrank(); + + uint256 erc20BalanceAfter = IERC20(DAI).balanceOf(user); + uint256 subaccountBalanceAfter = subaccounts.getBalance(subaccountId, DAIAsset, 0); + + assertEq(erc20BalanceAfter, erc20BalanceBefore - 10 ether); + assertEq(subaccountBalanceAfter, subaccountBalanceBefore + 10 ether); + } + + function test_DepositIntent_Cash() public onlyDeriveMainnet { + // // make sure we don't have pending PNL that might change cash balance on next call + // IStandardManager(standardManager).settlePerpsWithIndex(subaccountId); + // ICash(cash).accrueInterest(); + + uint256 erc20BalanceBefore = IERC20(USDC).balanceOf(user); + uint256 subaccountBalanceBefore = subaccounts.getBalance(subaccountId, cash, 0); + + uint256 amount = 1000 * 1e6; + uint256 amountInCash = 1000 ether; + + vm.startPrank(executor); + + depositIntent.executeDepositIntent(user, subaccountId, cash, amount); + vm.stopPrank(); + + uint256 erc20BalanceAfter = IERC20(USDC).balanceOf(user); + uint256 subaccountBalanceAfter = subaccounts.getBalance(subaccountId, cash, 0); + + assertEq(erc20BalanceAfter, erc20BalanceBefore - amount); + assertEq(subaccountBalanceAfter, subaccountBalanceBefore + amountInCash); + } + + function test_RevertIf_DepositToInvalidSubaccount() public onlyDeriveMainnet { + uint256 invalidSubaccount = 100; + + vm.startPrank(executor); + vm.expectRevert(SubaccountDepositIntent.SubaccountOwnerMismatch.selector); + depositIntent.executeDepositIntent(user, invalidSubaccount, DAIAsset, 10 ether); + vm.stopPrank(); + } + + function test_RevertIf_DeriveAssetNotAllowed() public onlyDeriveMainnet { + address mockedDeriveAsset = address(0x123); + + vm.startPrank(executor); + vm.expectRevert(SubaccountDepositIntent.DeriveAssetNotAllowed.selector); + depositIntent.executeDepositIntent(user, subaccountId, mockedDeriveAsset, 10 ether); + vm.stopPrank(); + } + + function test_RevertIf_AllowDeriveAsset_CallByExecutor() public onlyDeriveMainnet { + // executor cannot call setAllowedDeriveAsset + vm.startPrank(executor); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + depositIntent.setManagerTypes(standardManager, SubaccountDepositIntent.ManagerType.PM2); + vm.stopPrank(); + } + + function test_RevertIf_TriggerByNonExecutor() public onlyDeriveMainnet { + address nonExecutor = address(0x123); + vm.startPrank(nonExecutor); + vm.expectRevert(IntentExecutorBase.NotIntentExecutor.selector); + depositIntent.executeDepositIntent(user, subaccountId, DAIAsset, 10 ether); + vm.stopPrank(); + } + + receive() external payable {} +} diff --git a/test/fork-tests/intent/WithdrawBridgeIntent.t.sol b/test/fork-tests/intent/WithdrawBridgeIntent.t.sol new file mode 100644 index 0000000..8d7ab4b --- /dev/null +++ b/test/fork-tests/intent/WithdrawBridgeIntent.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable contract-name-camelcase +// solhint-disable func-name-mixedcase + +pragma solidity ^0.8.18; + +import {Test} from "lib/forge-std/src/Test.sol"; + +import {WithdrawBridgeIntent} from "src/intents/WithdrawBridgeIntent.sol"; +import {IntentExecutorBase} from "src/intents/IntentExecutorBase.sol"; +import {IERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {ISocketWithdrawWrapper} from "src/interfaces/derive/ISocketWithdrawWrapper.sol"; +import {IOFTWithdrawWrapper} from "src/interfaces/derive/IOFTWithdrawWrapper.sol"; +import {ILightAccount} from "src/interfaces/ILightAccount.sol"; +/** + * forge test --fork-url https://rpc.lyra.finance -vvv + */ + +contract FORK_LYRA_WithdrawBridgeIntent is Test { + address public weETH = address(0x7B35b4c05a90Ea5f311AeC815BE4148b446a68a2); + address public drv = address(0x2EE0fd70756EDC663AcC9676658A1497C247693A); + + // OFT withdraw wrapper + IOFTWithdrawWrapper public oftBridge = IOFTWithdrawWrapper(0x9400cc156dad38a716047a67c897973A29A06710); + + // Socket withdraw wrapper + ISocketWithdrawWrapper public socketBridge = ISocketWithdrawWrapper(0xea8E683D8C46ff05B871822a00461995F93df800); + + // Socket Parameters for testing + address public weETHController = address(0xf58fF1Adc4d045e712a6D91e69d93B4092516659); + address public weETHConnector = address(0x6Ee9b6ad1c97AdeeD071fd5f349cE65f91e43333); + + // Connector with min fee set + address public weETHConnector2 = address(0xF6c475d2aB23d84e45AD3634C8956dCDe27315E0); + + // Mock scws + address public scw = address(0x8dC92fB0e1C1F1Def6e424E50aaA66dbB124eb54); + + WithdrawBridgeIntent public bridgeIntent; + + address public executor = address(0xb0b); + + /** + * Only run the test when running with --fork flag, and connected to Lyra mainnet + */ + modifier onlyDeriveMainnet() { + if (block.chainid != 957) return; + _; + } + + function setUp() public onlyDeriveMainnet { + bridgeIntent = new WithdrawBridgeIntent(socketBridge, oftBridge); + + deal(weETH, scw, 10 ether); + deal(drv, scw, 1000 ether); + + // set executor as intent executor + bridgeIntent.setIntentExecutor(executor, true); + // set bucket params + bridgeIntent.setBucketParams(60, 10); // 10 withdrawals per minute + + // scw approves bridgeIntent to spend weETH + vm.startPrank(scw); + IERC20(weETH).approve(address(bridgeIntent), type(uint256).max); + IERC20(drv).approve(address(bridgeIntent), type(uint256).max); + + vm.stopPrank(); + } + + function test_WithdrawIntent_weETH() public onlyDeriveMainnet { + // test we can withdraw to SCW owner + address owner = ILightAccount(scw).owner(); + + uint256 erc20BalanceBefore = IERC20(weETH).balanceOf(scw); + + vm.startPrank(executor); + bridgeIntent.executeWithdrawIntentSocket( + scw, weETH, 1 ether, 0.1 ether, owner, weETHController, weETHConnector, 200000 + ); + vm.stopPrank(); + + uint256 erc20BalanceAfter = IERC20(weETH).balanceOf(scw); + assertEq(erc20BalanceAfter, erc20BalanceBefore - 1 ether); + } + + function test_WithdrawIntent_NoMaxFee() public onlyDeriveMainnet { + // test we can withdraw to SCW owner + address owner = ILightAccount(scw).owner(); + + uint256 erc20BalanceBefore = IERC20(weETH).balanceOf(scw); + + vm.startPrank(executor); + bridgeIntent.executeWithdrawIntentSocket( + scw, weETH, 1 ether, type(uint256).max, owner, weETHController, weETHConnector, 200000 + ); + vm.stopPrank(); + + uint256 erc20BalanceAfter = IERC20(weETH).balanceOf(scw); + assertEq(erc20BalanceAfter, erc20BalanceBefore - 1 ether); + } + + function test_WithdrawIntent_DRV() public onlyDeriveMainnet { + uint256 erc20BalanceBefore = IERC20(drv).balanceOf(scw); + + uint256 maxFee = 10e18; // 10 DRV + address owner = ILightAccount(scw).owner(); + + vm.startPrank(executor); + bridgeIntent.executeWithdrawIntentLZ(scw, drv, 10 ether, maxFee, owner, 30184); + vm.stopPrank(); + + uint256 erc20BalanceAfter = IERC20(drv).balanceOf(scw); + assertEq(erc20BalanceAfter, erc20BalanceBefore - 10 ether); + } + + function test_RevertIf_TriggerByNonExecutor() public onlyDeriveMainnet { + address nonExecutor = address(0x123); + vm.startPrank(nonExecutor); + vm.expectRevert(IntentExecutorBase.NotIntentExecutor.selector); + + bridgeIntent.executeWithdrawIntentSocket( + scw, weETH, 1 ether, 0.1 ether, address(0), weETHController, weETHConnector, 200000 + ); + + vm.expectRevert(IntentExecutorBase.NotIntentExecutor.selector); + bridgeIntent.executeWithdrawIntentLZ(scw, drv, 10 ether, 10e18, address(0), 30184); + vm.stopPrank(); + } + + function test_WithdrawStateUpdate() public onlyDeriveMainnet { + address owner = ILightAccount(scw).owner(); + assertEq(bridgeIntent.isWithdrawLimitReached(), false); + + // set the withdraw limit to 1 + bridgeIntent.setBucketParams(60, 1); + + vm.prank(executor); + _executeWithdrawWeETH(owner); + + assertEq(bridgeIntent.withdrawCount(), 1); + assertEq(bridgeIntent.isWithdrawLimitReached(), true); + + // increase the withdraw limit, more withdrawals are allowed + bridgeIntent.setBucketParams(60, 5); + assertEq(bridgeIntent.isWithdrawLimitReached(), false); + + vm.prank(executor); + _executeWithdrawWeETH(owner); + + assertEq(bridgeIntent.withdrawCount(), 2); + } + + function test_RevertIf_LimitNotSetByOwner() public onlyDeriveMainnet { + vm.expectRevert(bytes("Ownable: caller is not the owner")); + vm.prank(executor); + bridgeIntent.setBucketParams(60, 100); + } + + function test_RevertIf_WithdrawLimitReached() public onlyDeriveMainnet { + address owner = ILightAccount(scw).owner(); + // set the withdraw limit to 1 + bridgeIntent.setBucketParams(60, 1); + + vm.startPrank(executor); + _executeWithdrawWeETH(owner); + + // expect revert for the second withdraw + vm.expectRevert(WithdrawBridgeIntent.WithdrawLimitReached.selector); + _executeWithdrawDRV(owner); + + // after 1 minute, the withdraw can go through + vm.warp(block.timestamp + 60); + _executeWithdrawWeETH(owner); + + vm.stopPrank(); + } + + function test_WithdrawLimit_Update() public onlyDeriveMainnet { + address owner = ILightAccount(scw).owner(); + // set the withdraw limit to 2 per minute + bridgeIntent.setBucketParams(60, 2); + + vm.startPrank(executor); + + // withdraw 2 times + _executeWithdrawDRV(owner); + _executeWithdrawDRV(owner); + + // expect revert for the third withdraw + vm.expectRevert(WithdrawBridgeIntent.WithdrawLimitReached.selector); + _executeWithdrawDRV(owner); + vm.stopPrank(); + + // after 30 seconds, the withdraw limit is updated to 3 times every 120 seconds + // the timer will not reset + vm.warp(block.timestamp + 30); + bridgeIntent.setBucketParams(120, 3); + + vm.startPrank(executor); + // 1 more withdraw is allowed + _executeWithdrawDRV(owner); + + // revert for the fourth withdraw + vm.expectRevert(WithdrawBridgeIntent.WithdrawLimitReached.selector); + _executeWithdrawDRV(owner); + + // wait another 30 seconds, the limit is still there + vm.warp(block.timestamp + 30); + vm.expectRevert(WithdrawBridgeIntent.WithdrawLimitReached.selector); + _executeWithdrawDRV(owner); + assertEq(bridgeIntent.withdrawCount(), 3); + + // wait another 60 seconds, the limit is reset + vm.warp(block.timestamp + 60); + // 1 more withdraw is allowed + _executeWithdrawDRV(owner); + assertEq(bridgeIntent.withdrawCount(), 1); + + vm.stopPrank(); + } + + function test_RevertIf_FeeTooHigh() public onlyDeriveMainnet { + address owner = ILightAccount(scw).owner(); + vm.startPrank(executor); + + // DRV bridge + uint256 fee = oftBridge.getFeeInToken(drv, 10 ether, 30184); + + vm.expectRevert(WithdrawBridgeIntent.FeeTooHigh.selector); + bridgeIntent.executeWithdrawIntentLZ(scw, drv, 10 ether, fee - 1, owner, 30184); + + // weETH bridge + fee = socketBridge.getFeeInToken(weETH, weETHController, weETHConnector2, 200000); + vm.expectRevert(WithdrawBridgeIntent.FeeTooHigh.selector); + bridgeIntent.executeWithdrawIntentSocket( + scw, weETH, 1 ether, fee - 1, owner, weETHController, weETHConnector2, 200000 + ); + + vm.stopPrank(); + } + + /// @dev wrapped function used to simplify the limit tests + function _executeWithdrawDRV(address owner) internal { + bridgeIntent.executeWithdrawIntentLZ(scw, drv, 10 ether, 10e18, owner, 30184); + } + + /// @dev wrapped function used to simplify the limit tests + function _executeWithdrawWeETH(address owner) internal { + bridgeIntent.executeWithdrawIntentSocket( + scw, weETH, 1 ether, 0.1 ether, owner, weETHController, weETHConnector, 200000 + ); + } + + receive() external payable {} +} diff --git a/test/fork-tests/withdraw/LyraWithdrawWrapper.t.sol b/test/fork-tests/withdraw/LyraWithdrawWrapper.t.sol index 65188cb..a55c08d 100644 --- a/test/fork-tests/withdraw/LyraWithdrawWrapper.t.sol +++ b/test/fork-tests/withdraw/LyraWithdrawWrapper.t.sol @@ -1,4 +1,6 @@ // SPDX-License-Identifier: UNLICENSED +// solhint-disable contract-name-camelcase +// solhint-disable func-name-mixedcase pragma solidity ^0.8.18; import "lib/forge-std/src/Test.sol"; @@ -53,7 +55,7 @@ contract FORK_LYRA_LyraWithdrawalTest is Test { vm.startPrank(alice); IERC20(usdc).approve(address(wrapper), type(uint256).max); - uint256 amount = 1e6; + uint256 amount = 1e3; vm.expectRevert(bytes("withdraw amount < fee")); wrapper.withdrawToL1(amount, alice, connector, 200_000); @@ -71,11 +73,11 @@ contract FORK_LYRA_LyraWithdrawalTest is Test { function test_fork_getFee() public onlyLyra { uint256 fee = wrapper.getFeeUSDC(connector, 200_000); - assertGt(fee, 1e6); + assertGt(fee, 1e5); assertLt(fee, 300e6); } - function _mintLyraUSDC(address account, uint256 amount) public { + function _mintLyraUSDC(address account, uint256 amount) private { vm.prank(connector); IFiatController(controller).receiveInbound(abi.encode(account, amount)); } diff --git a/test/fork-tests/withdraw/LyraWithdrawWrapperV2.t.sol b/test/fork-tests/withdraw/LyraWithdrawWrapperV2.t.sol index 7ca92d9..4ae5002 100644 --- a/test/fork-tests/withdraw/LyraWithdrawWrapperV2.t.sol +++ b/test/fork-tests/withdraw/LyraWithdrawWrapperV2.t.sol @@ -1,4 +1,7 @@ // SPDX-License-Identifier: UNLICENSED +// solhint-disable contract-name-camelcase +// solhint-disable func-name-mixedcase +// solhint-disable var-name-mixedcase pragma solidity ^0.8.18; import "lib/forge-std/src/Test.sol"; @@ -113,7 +116,7 @@ contract FORK_LYRA_LyraWithdrawalV2Test is Test { vm.startPrank(alice); IERC20(usdc).approve(address(wrapper), type(uint256).max); - uint256 amount = 1e6; + uint256 amount = 1e3; vm.expectRevert(bytes("withdraw amount < fee")); wrapper.withdrawToChain(usdc, amount, alice, usdcController, usdc_Arbi_Connector, 200_000); @@ -122,19 +125,19 @@ contract FORK_LYRA_LyraWithdrawalV2Test is Test { function test_fork_getFee() public onlyLyra { uint256 fee = wrapper.getFeeInToken(usdc, usdcController, usdc_Mainnet_Connector, 200_000); - assertGt(fee, 1e6); + assertGt(fee, 1e5); assertLt(fee, 300e6); fee = wrapper.getFeeInToken(usdc, usdcController, usdc_Arbi_Connector, 200_000); assertLt(fee, 10e6); } - function _mintLyraUSDC(address account, uint256 amount) public { + function _mintLyraUSDC(address account, uint256 amount) private { vm.prank(usdc_Mainnet_Connector); IFiatController(usdcController).receiveInbound(abi.encode(account, amount)); } - function _mintLyraWBTC(address account, uint256 amount) public { + function _mintLyraWBTC(address account, uint256 amount) private { vm.prank(wBTC_OP_Connector); IFiatController(wBTCController).receiveInbound(abi.encode(account, amount)); }