diff --git a/.gitmodules b/.gitmodules index 56cf48f..a8afe21 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [submodule "lib/solidity-bytes-utils"] path = lib/solidity-bytes-utils url = https://github.com/GNSPS/solidity-bytes-utils.git +[submodule "lib/openzeppelin-contracts-v4"] + path = lib/openzeppelin-contracts-v4 + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/foundry.lock b/foundry.lock index 33153f4..45b0124 100644 --- a/foundry.lock +++ b/foundry.lock @@ -14,6 +14,12 @@ "lib/openzeppelin-contracts-upgradeable": { "rev": "e725abddf1e01cf05ace496e950fc8e243cc7cab" }, + "lib/openzeppelin-contracts-v4": { + "tag": { + "name": "v4.9.6", + "rev": "dc44c9f1a4c3b10af99492eed84f83ed244203f6" + } + }, "lib/openzeppelin-foundry-upgrades": { "tag": { "name": "v0.4.0", diff --git a/lib/openzeppelin-contracts-v4 b/lib/openzeppelin-contracts-v4 new file mode 160000 index 0000000..dc44c9f --- /dev/null +++ b/lib/openzeppelin-contracts-v4 @@ -0,0 +1 @@ +Subproject commit dc44c9f1a4c3b10af99492eed84f83ed244203f6 diff --git a/remappings.txt b/remappings.txt index 6937de8..5770c48 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,5 +1,6 @@ @openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ +@openzeppelin/contracts-v4/=lib/openzeppelin-contracts-v4/contracts/ openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/ solidity-bytes-utils/=lib/solidity-bytes-utils/ @layerzerolabs/oapp-evm/=lib/devtools/packages/oapp-evm/ diff --git a/script/DeployPrivateGateway.s.sol b/script/DeployPrivateGateway.s.sol new file mode 100644 index 0000000..85bcc22 --- /dev/null +++ b/script/DeployPrivateGateway.s.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Script, console} from "forge-std/Script.sol"; + +import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts-v4/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts-v4/proxy/transparent/ProxyAdmin.sol"; + +import {PrivateGatewayScroll} from "../src/cloak/PrivateGatewayScroll.sol"; +import {PrivateGatewayCloak} from "../src/cloak/PrivateGatewayCloak.sol"; + +/// @notice Minimal placeholder implementation for proxy. Used in step 1 so proxy is deployed with empty logic, then upgraded in step 2. +contract EmptyImpl { + fallback() external payable {} +} + +/** + * @title DeployPrivateGateway + * @dev Two-step deployment for PrivateGatewayScroll and PrivateGatewayCloak behind TransparentUpgradeableProxy. + * Step from env STEP (required: "1" or "2"). Chain determines which gateway is deployed: + * - Scroll (chainId 534352): deploy PrivateGatewayScroll only. + * - Cloak (chainId 5343523304): deploy PrivateGatewayCloak only. + * STEP=1: Deploy TransparentUpgradeableProxy with EmptyImpl as placeholder implementation. + * STEP=2: Deploy real implementation and upgrade proxy, then call initialize. + */ +contract DeployPrivateGateway is Script { + uint256 public constant SCROLL_CHAIN_ID = 534352; + uint256 public constant CLOAK_CHAIN_ID = 5343523304; + + uint256 public SCROLL_PRIVATE_KEY = vm.envUint("SCROLL_PRIVATE_KEY"); + uint256 public CLOAK_PRIVATE_KEY = vm.envUint("CLOAK_PRIVATE_KEY"); + + // ─── Step 1 outputs (set these as env for step 2 if running separately) ─── + address public scrollProxyAdmin; + address public scrollProxy; + address public cloakProxyAdmin; + address public cloakProxy; + address public emptyImpl; + + // ─── Step 2: implementation addresses after deploy ─── + address public scrollImpl; + address public cloakImpl; + + function _isScroll() internal view returns (bool) { + return block.chainid == SCROLL_CHAIN_ID; + } + + function _isCloak() internal view returns (bool) { + return block.chainid == CLOAK_CHAIN_ID; + } + + /// @dev Entry point: read STEP from env ("1" or "2") and run the corresponding step. + function run() public { + require(_isScroll() || _isCloak(), "Unsupported chain id"); + + string memory step = vm.envString("STEP"); + require( + keccak256(bytes(step)) == keccak256("1") || + keccak256(bytes(step)) == keccak256("2"), + "STEP must be 1 or 2" + ); + + if (keccak256(bytes(step)) == keccak256("1")) { + if (_isScroll()) { + vm.startBroadcast(SCROLL_PRIVATE_KEY); + } else if (_isCloak()) { + vm.startBroadcast(CLOAK_PRIVATE_KEY); + } + _step1_DeployProxiesWithEmptyImpl(); + vm.stopBroadcast(); + + console.log("=== Step 1 done. Set for step 2:"); + if (_isScroll()) { + console.log("export SCROLL_PROXY_ADMIN=%s", scrollProxyAdmin); + console.log("export SCROLL_GATEWAY_PROXY=%s", scrollProxy); + } + if (_isCloak()) { + console.log("export CLOAK_PROXY_ADMIN=%s", cloakProxyAdmin); + console.log("export CLOAK_GATEWAY_PROXY=%s", cloakProxy); + } + return; + } + + if (keccak256(bytes(step)) == keccak256("2")) { + scrollProxyAdmin = vm.envAddress("SCROLL_PROXY_ADMIN"); + scrollProxy = vm.envAddress("SCROLL_GATEWAY_PROXY"); + cloakProxyAdmin = vm.envAddress("CLOAK_PROXY_ADMIN"); + cloakProxy = vm.envAddress("CLOAK_GATEWAY_PROXY"); + + address deployer; + if (_isScroll()) { + vm.startBroadcast(SCROLL_PRIVATE_KEY); + deployer = vm.addr(SCROLL_PRIVATE_KEY); + } else if (_isCloak()) { + vm.startBroadcast(CLOAK_PRIVATE_KEY); + deployer = vm.addr(CLOAK_PRIVATE_KEY); + } + _step2_DeployImplAndUpgrade(deployer); + vm.stopBroadcast(); + _logSummary(); + } + } + + function _step1_DeployProxiesWithEmptyImpl() internal { + console.log( + "=== Step 1: Deploy EmptyImpl and TransparentUpgradeableProxy (chainId %s) ===", + block.chainid + ); + + emptyImpl = address(new EmptyImpl()); + console.log("EmptyImpl:", emptyImpl); + + bytes memory emptyData = ""; + + if (_isScroll()) { + scrollProxyAdmin = address(new ProxyAdmin()); + TransparentUpgradeableProxy scrollTUP = new TransparentUpgradeableProxy( + emptyImpl, + scrollProxyAdmin, + emptyData + ); + scrollProxy = address(scrollTUP); + console.log("PrivateGatewayScroll proxy admin:", scrollProxyAdmin); + console.log("PrivateGatewayScroll proxy:", scrollProxy); + } + if (_isCloak()) { + cloakProxyAdmin = address(new ProxyAdmin()); + TransparentUpgradeableProxy cloakTUP = new TransparentUpgradeableProxy( + emptyImpl, + cloakProxyAdmin, + emptyData + ); + cloakProxy = address(cloakTUP); + console.log("PrivateGatewayCloak proxy:", cloakProxy); + console.log("PrivateGatewayCloak proxy admin:", cloakProxyAdmin); + } + } + + function _step2_DeployImplAndUpgrade(address deployer) internal { + console.log( + "=== Step 2: Deploy implementation and upgrade proxy (chainId %s) ===", + block.chainid + ); + + if (_isScroll()) { + PrivateGatewayScroll scrollImplContract = new PrivateGatewayScroll( + 0x06eFdBFf2a14a7c8E15944D1F4A48F9F95F663A4, // USDC in Scroll + 0x3b005fefC63Ca7c8d25eE21FbA3787229ba4CF03, // USX in Scroll + 0xE22Ae02876539EdBa297EC4ec141BE806AB9F7f1, // L1 ERC20 Gateway in Scroll + address(cloakProxy) // PrivateGatewayCloak in Cloak + ); + scrollImpl = address(scrollImplContract); + console.log("PrivateGatewayScroll impl:", scrollImpl); + + bytes memory scrollInitData = abi.encodeCall( + PrivateGatewayScroll.initialize, + (deployer, 0, 0, 0) + ); + ProxyAdmin(scrollProxyAdmin).upgradeAndCall( + ITransparentUpgradeableProxy(payable(scrollProxy)), + scrollImpl, + scrollInitData + ); + console.log("PrivateGatewayScroll upgraded and initialized"); + } + + if (_isCloak()) { + PrivateGatewayCloak cloakImplContract = new PrivateGatewayCloak( + 0x8bD8424405d9518f9cDE6839F9b8d057d98Ced4E, // USDC in Cloak + 0x73D7c3dffB020f34d4c213161Cb98Ad25e8D9c18, // USX in Cloak + 0x5752C6E8478dE1eCc1f7C77f00811B3f9031c0d3, // L2 ERC20 Gateway in Cloak + address(scrollProxy) // PrivateGatewayScroll in Scroll + ); + cloakImpl = address(cloakImplContract); + console.log("PrivateGatewayCloak impl:", cloakImpl); + + bytes memory cloakInitData = abi.encodeCall( + PrivateGatewayCloak.initialize, + (deployer) + ); + ProxyAdmin(cloakProxyAdmin).upgradeAndCall( + ITransparentUpgradeableProxy(payable(cloakProxy)), + cloakImpl, + cloakInitData + ); + console.log("PrivateGatewayCloak upgraded and initialized"); + } + } + + function _logSummary() internal view { + console.log("=== Deployment summary (chainId %s) ===", block.chainid); + if (_isScroll()) { + console.log("PrivateGatewayScroll proxy:", scrollProxy); + console.log("PrivateGatewayScroll impl:", scrollImpl); + } + if (_isCloak()) { + console.log("PrivateGatewayCloak proxy:", cloakProxy); + console.log("PrivateGatewayCloak impl:", cloakImpl); + } + } +} diff --git a/src/cloak/IL1ERC20GatewayValidium.sol b/src/cloak/IL1ERC20GatewayValidium.sol new file mode 100644 index 0000000..f5e3d0e --- /dev/null +++ b/src/cloak/IL1ERC20GatewayValidium.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +interface IL1ERC20GatewayValidium { + /************************* + * Public View Functions * + *************************/ + + /// @notice The address of the messenger + /// @return messenger The address of the messenger + function messenger() external view returns (address); + + /***************************** + * Public Mutating Functions * + *****************************/ + + /// @notice Deposit some token to a recipient's account on L2. + /// @dev Make this function payable to send relayer fee in Ether. + /// @param _token The address of token in L1. + /// @param _to The encrypted address of recipient's account on L2. + /// @param _amount The amount of token to transfer. + /// @param _gasLimit Gas limit required to complete the deposit on L2. + function depositERC20( + address _token, + bytes memory _to, + uint256 _amount, + uint256 _gasLimit, + uint256 _keyId + ) external payable; +} diff --git a/src/cloak/IL2ERC20GatewayValidium.sol b/src/cloak/IL2ERC20GatewayValidium.sol new file mode 100644 index 0000000..902753c --- /dev/null +++ b/src/cloak/IL2ERC20GatewayValidium.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +interface IL2ERC20GatewayValidium { + /************************* + * Public View Functions * + *************************/ + + /// @notice The address of the messenger + /// @return messenger The address of the messenger + function messenger() external view returns (address); + + /***************************** + * Public Mutating Functions * + *****************************/ + + /// @notice Withdraw of some token to a recipient's account on L1. + /// @dev Make this function payable to send relayer fee in Ether. + /// @param token The address of token in L2. + /// @param to The address of recipient's account on L1. + /// @param amount The amount of token to transfer. + /// @param gasLimit Unused, but included for potential forward compatibility considerations. + function withdrawERC20( + address token, + address to, + uint256 amount, + uint256 gasLimit + ) external payable; +} diff --git a/src/cloak/IScrollMessengerValidium.sol b/src/cloak/IScrollMessengerValidium.sol new file mode 100644 index 0000000..82828bf --- /dev/null +++ b/src/cloak/IScrollMessengerValidium.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +interface IScrollMessengerValidium { + /************************* + * Public View Functions * + *************************/ + + /// @notice The address of the xDomainMessageSender + /// @return xDomainMessageSender The address of the xDomainMessageSender + function xDomainMessageSender() external view returns (address); + + /***************************** + * Public Mutating Functions * + *****************************/ + + /// @notice Send cross chain message from L1 to L2 or L2 to L1. + /// @param target The address of account who receive the message. + /// @param value The amount of ether passed when call target contract. + /// @param message The content of the message. + /// @param gasLimit Gas limit required to complete the message relay on corresponding chain. + function sendMessage( + address target, + uint256 value, + bytes calldata message, + uint256 gasLimit + ) external payable; +} diff --git a/src/cloak/PrivateGatewayCloak.sol b/src/cloak/PrivateGatewayCloak.sol new file mode 100644 index 0000000..71b3bdd --- /dev/null +++ b/src/cloak/PrivateGatewayCloak.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {AccessControlEnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +import {IL2ERC20GatewayValidium} from "./IL2ERC20GatewayValidium.sol"; +import {IScrollMessengerValidium} from "./IScrollMessengerValidium.sol"; + +/// @title PrivateGatewayCloak +/// @notice A contract for private gateway in cloak +/// @dev This contract is used to confirm USDC deposits and withdraw USX to scroll. +contract PrivateGatewayCloak is + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable +{ + using SafeERC20 for IERC20; + + /********** + * Events * + **********/ + + /// @notice Emitted when a deposit is confirmed + /// @param nonce The nonce of the deposit + /// @param encryptedReceiver The encrypted receiver + /// @param keyId The ID of the encryption key used to encrypt the receiver + /// @param amountUSDC The amount of USDC transferred + event DepositConfirmed( + uint256 nonce, + bytes encryptedReceiver, + uint256 keyId, + uint256 amountUSDC + ); + + /// @notice Emitted when USX is withdrawn + /// @param nonce The nonce of the deposit + /// @param encryptedReceiver The encrypted receiver + /// @param keyId The ID of the encryption key used to encrypt the receiver + /// @param amountUSDC The amount of USDC transferred + /// @param actualReceiver The actual receiver of the USX + /// @param amountUSX The amount of USX withdrawn + event WithdrawUSX( + uint256 nonce, + bytes encryptedReceiver, + uint256 keyId, + uint256 amountUSDC, + address actualReceiver, + uint256 amountUSX + ); + + /// @notice Emitted when the rebalancer is updated + /// @param oldRebalancer The old rebalancer + /// @param newRebalancer The new rebalancer + event RebalancerUpdated(address oldRebalancer, address newRebalancer); + + /********** + * Errors * + **********/ + + /// @dev Thrown when the no USDC balance + error ErrorNoUSDCBalance(); + + /// @dev Thrown when the rebalancer is not set + error ErrorRebalancerNotSet(); + + /// @dev Thrown when the deposit is already confirmed (duplicate confirm) + error ErrorDepositAlreadyConfirmed(); + + /// @dev Thrown when the deposit is not confirmed + error ErrorDepositNotConfirmed(); + + /// @dev Thrown when the deposit is already withdrawn (duplicate withdraw) + error ErrorDepositAlreadyWithdrawn(); + + /// @dev Thrown when the caller is not the messenger + error ErrorCallerIsNotMessenger(); + + /// @dev Thrown when the caller is not the counterpart gateway + error ErrorCallerIsNotCounterpartGateway(); + + /************* + * Constants * + *************/ + + /// @notice The role required to withdraw USX + bytes32 public constant WITHDRAW_USX_ROLE = keccak256("WITHDRAW_USX_ROLE"); + + /// @notice The role required to rebalance the contract + bytes32 public constant REBALANCE_ROLE = keccak256("REBALANCE_ROLE"); + + /*********************** + * Immutable Variables * + ***********************/ + + /// @notice The address of the USDC token + address public immutable USDC; + + /// @notice The address of the USX token + address public immutable USX; + + /// @notice The address of the ERC20 gateway in cloak + address public immutable erc20Gateway; + + /// @notice The address of the messenger + address public immutable messenger; + + /// @notice The address of the private gateway in scroll. + address public immutable counterpart; + + /********************* + * Storage Variables * + *********************/ + + /// @notice Mapping from hash to confirmed deposits + mapping(bytes32 => bool) public confirmedDeposits; + + /// @notice Mapping from hash to withdrawn deposits + mapping(bytes32 => bool) public withdrawnDeposits; + + /// @notice The address of the rebalancer in Scroll. + address public rebalancer; + + /*************** + * Constructor * + ***************/ + + /// @custom:oz-upgrades-unsafe-allow constructor + /// @dev This constructor is used to initialize the immutable variables + /// @param _USDC The address of the USDC token + /// @param _USX The address of the USX token + /// @param _erc20Gateway The address of the ERC20 gateway in cloak + /// @param _counterpart The address of the private gateway in scroll + constructor( + address _USDC, + address _USX, + address _erc20Gateway, + address _counterpart + ) { + _disableInitializers(); + + USDC = _USDC; + USX = _USX; + erc20Gateway = _erc20Gateway; + messenger = IL2ERC20GatewayValidium(_erc20Gateway).messenger(); + counterpart = _counterpart; + } + + /// @notice Initializes the contract + /// @param initialAdmin The address of the initial admin + function initialize(address initialAdmin) external initializer { + __Context_init(); + __ERC165_init(); + __AccessControl_init(); + __AccessControlEnumerable_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); + } + + /***************************** + * Public Mutating Functions * + *****************************/ + + /// @notice Confirms a deposit + /// @dev The caller must be the messenger and the counterpart gateway in scroll + /// @param nonce The nonce of the deposit + /// @param encryptedReceiver The encrypted receiver + /// @param keyId The ID of the encryption key used to encrypt the receiver + /// @param amountUSDC The amount of USDC transferred + function confirmDeposit( + uint256 nonce, + bytes memory encryptedReceiver, + uint256 keyId, + uint256 amountUSDC + ) external nonReentrant { + // check if the caller is the messenger + if (msg.sender != messenger) { + revert ErrorCallerIsNotMessenger(); + } + + // check if the caller is the counterpart gateway in scroll + if ( + counterpart != + IScrollMessengerValidium(messenger).xDomainMessageSender() + ) { + revert ErrorCallerIsNotCounterpartGateway(); + } + + bytes32 hash = keccak256( + abi.encode(nonce, encryptedReceiver, keyId, amountUSDC) + ); + // just in case, should not happen + if (confirmedDeposits[hash]) revert ErrorDepositAlreadyConfirmed(); + + confirmedDeposits[hash] = true; + + emit DepositConfirmed(nonce, encryptedReceiver, keyId, amountUSDC); + } + + /// @notice Withdraws USX from the contract + /// @dev The caller must have the WITHDRAW_USX_ROLE role to withdraw USX + /// @param nonce The nonce of the deposit + /// @param encryptedReceiver The encrypted receiver + /// @param amountUSDC The amount of USDC transferred + /// @param actualReceiver The actual receiver of the USX + function withdrawUSX( + uint256 nonce, + bytes memory encryptedReceiver, + uint256 keyId, + uint256 amountUSDC, + address actualReceiver + ) external onlyRole(WITHDRAW_USX_ROLE) nonReentrant { + bytes32 hash = keccak256( + abi.encode(nonce, encryptedReceiver, keyId, amountUSDC) + ); + if (!confirmedDeposits[hash]) { + revert ErrorDepositNotConfirmed(); + } + if (withdrawnDeposits[hash]) { + revert ErrorDepositAlreadyWithdrawn(); + } + withdrawnDeposits[hash] = true; + + // USDC has decimal 6 and USX has decimal 18, so we need to scale up by 10**12 + uint256 amountUSX = amountUSDC * 10 ** 12; + + // approve just in case + IERC20(USX).forceApprove(erc20Gateway, amountUSX); + IL2ERC20GatewayValidium(erc20Gateway).withdrawERC20( + USX, + actualReceiver, + amountUSX, + 0 + ); + + emit WithdrawUSX( + nonce, + encryptedReceiver, + keyId, + amountUSDC, + actualReceiver, + amountUSX + ); + } + + /// @notice Rebalances the contract + /// @dev The caller must have the REBALANCE_ROLE role to rebalance the contract + function rebalance() external onlyRole(REBALANCE_ROLE) { + uint256 usdcBalance = IERC20(USDC).balanceOf(address(this)); + if (usdcBalance == 0) revert ErrorNoUSDCBalance(); + if (rebalancer == address(0)) revert ErrorRebalancerNotSet(); + + // approve just in case + IERC20(USDC).forceApprove(erc20Gateway, usdcBalance); + IL2ERC20GatewayValidium(erc20Gateway).withdrawERC20( + USDC, + rebalancer, + usdcBalance, + 0 + ); + } + + /************************ + * Restricted Functions * + ************************/ + + /// @notice Updates the address of the rebalancer + /// @param newRebalancer The address of the new rebalancer + function updateRebalancer( + address newRebalancer + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _updateRebalancer(newRebalancer); + } + + /// @notice Withdraws tokens from the contract + /// @param token The address of the token to withdraw + /// @param amount The amount of tokens to withdraw + function withdrawTokens( + address token, + address receiver, + uint256 amount + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) { + Address.sendValue(payable(receiver), amount); + } else { + IERC20(token).safeTransfer(receiver, amount); + } + } + + /********************** + * Internal Functions * + **********************/ + + function _updateRebalancer(address newRebalancer) internal { + address oldRebalancer = rebalancer; + rebalancer = newRebalancer; + + emit RebalancerUpdated(oldRebalancer, newRebalancer); + } +} diff --git a/src/cloak/PrivateGatewayScroll.sol b/src/cloak/PrivateGatewayScroll.sol new file mode 100644 index 0000000..1e5f73f --- /dev/null +++ b/src/cloak/PrivateGatewayScroll.sol @@ -0,0 +1,561 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {AccessControlEnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +import {IL1ERC20GatewayValidium} from "./IL1ERC20GatewayValidium.sol"; +import {IScrollMessengerValidium} from "./IScrollMessengerValidium.sol"; + +import {PrivateGatewayCloak} from "./PrivateGatewayCloak.sol"; + +/// @title PrivateGatewayScroll +/// @notice A contract for private gateway in scroll +/// @dev This contract is used to transfer USDC to cloak. +contract PrivateGatewayScroll is + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + /********** + * Events * + **********/ + + /// @notice Emitted when a new encryption key is registered + /// @param keyId The ID of the new encryption key + /// @param key The new encryption key + event NewEncryptionKey(uint256 indexed keyId, bytes key); + + /// @notice Emitted when the minimum amount of USDC to transfer is updated + /// @param oldMinUSDCAmount The old minimum amount of USDC to transfer + /// @param newMinUSDCAmount The new minimum amount of USDC to transfer + event MinUSDCAmountUpdated( + uint256 oldMinUSDCAmount, + uint256 newMinUSDCAmount + ); + + /// @notice Emitted when the fee percentage is updated + /// @param oldFeePercentage The old fee percentage + /// @param newFeePercentage The new fee percentage + event FeePercentageUpdated( + uint256 oldFeePercentage, + uint256 newFeePercentage + ); + + /// @notice Emitted when the max fee amount is updated + /// @param oldMaxFeeAmount The old max fee amount + /// @param newMaxFeeAmount The new max fee amount + event MaxFeeAmountUpdated(uint256 oldMaxFeeAmount, uint256 newMaxFeeAmount); + + /// @notice Emitted when USDC is transferred + /// @param nonce The nonce of the transfer + /// @param encryptedReceiver The encrypted receiver + /// @param keyId The ID of the encryption key used to encrypt the receiver + /// @param amountUSDC The amount of USDC transferred + event USDCTransferred( + uint256 indexed nonce, + bytes encryptedReceiver, + uint256 keyId, + uint256 amountUSDC + ); + + /// @notice Emitted when the expected USDC receiver is updated + /// @param oldExpectedUSDCReceiver The old expected USDC receiver + /// @param newExpectedUSDCReceiver The new expected USDC receiver + event ExpectedUSDCReceiverUpdated(EncryptedReceiver oldExpectedUSDCReceiver, EncryptedReceiver newExpectedUSDCReceiver); + + /********** + * Errors * + **********/ + + /// @dev Thrown when the amount is invalid + error ErrorInvalidAmount(); + + /// @dev Thrown when the fee percentage is invalid + error ErrorInvalidFeePercentage(); + + /// @dev Thrown when the encryption key is unknown + error ErrorUnknownEncryptionKey(); + + /// @dev Thrown when the encryption key is deprecated + error ErrorDeprecatedEncryptionKey(); + + /// @dev Thrown when the encryption key length is invalid + error ErrorInvalidEncryptionKeyLength(); + + /// @dev Thrown when the encryption key is invalid + error ErrorInvalidEncryptionKey(); + + /// @dev Thrown when the token is not supported + error ErrorTokenNotSupported(); + + /// @dev Thrown when the swap router is not supported + error ErrorSwapRouterNotSupported(); + + /// @dev Thrown when the swap failed + error ErrorSwapFailed(); + + /// @dev Thrown when the USDC receiver is invalid + error ErrorInvalidUSDCReceiver(); + + /************* + * Constants * + *************/ + + /// @notice The role required to register new encryption keys + bytes32 public constant KEY_MANAGER_ROLE = keccak256("KEY_MANAGER_ROLE"); + + /// @notice The gas limit for the deposit operation + uint256 private constant GAS_LIMIT = 1000000; + + /// @notice The precision for the fee percentage + uint256 private constant PRECISION = 1e18; + + /// @notice The maximum fee percentage + uint256 private constant MAX_FEE_PERCENTAGE = 1e17; // 10% + + /*********** + * Structs * + ***********/ + + /// @notice A struct representing an encrypted receiver + /// @param receiver The encrypted receiver + /// @param keyId The ID of the encryption key used to encrypt the receiver + struct EncryptedReceiver { + bytes receiver; + uint256 keyId; + } + + /*********************** + * Immutable Variables * + ***********************/ + + /// @notice The address of the USDC token + address public immutable USDC; + + /// @notice The address of the USX token + address public immutable USX; + + /// @notice The address of the ERC20 gateway in scroll + address public immutable erc20Gateway; + + /// @notice The address of the messenger + address public immutable messenger; + + /// @notice The address of the private gateway in cloak. + address public immutable counterpart; + + /********************* + * Storage Variables * + *********************/ + + /// @notice The nonce of the private gateway scroll contract. + uint256 public nonce; + + /// @notice The list of encryption keys + bytes[] public encryptionKeys; + + /// @notice The list of supported tokens + EnumerableSet.AddressSet private supportedTokens; + + /// @notice The list of supported swap routers + EnumerableSet.AddressSet private supportedSwapRouters; + + /// @notice Mapping from swap router to token spender + mapping(address => address) private spenders; + + /// @notice The minimum amount of the USDC to transfer + uint256 public minUSDCAmount; + + /// @notice The fee percentage of the private gateway scroll contract. + uint256 public feePercentage; + + /// @notice The max fee amount of the private gateway scroll contract. + uint256 public maxFeeAmount; + + /// @notice The expected USDC receiver + EncryptedReceiver public expectedUSDCReceiver; + + /*************** + * Constructor * + ***************/ + + /// @custom:oz-upgrades-unsafe-allow constructor + /// @dev This constructor is used to initialize the immutable variables + /// @param _USDC The address of the USDC token + /// @param _USX The address of the USX token + /// @param _erc20Gateway The address of the ERC20 gateway in scroll + /// @param _counterpart The address of the private gateway in cloak + constructor( + address _USDC, + address _USX, + address _erc20Gateway, + address _counterpart + ) { + _disableInitializers(); + + USDC = _USDC; + USX = _USX; + erc20Gateway = _erc20Gateway; + messenger = IL1ERC20GatewayValidium(_erc20Gateway).messenger(); + counterpart = _counterpart; + } + + /// @notice Initializes the contract + /// @param initialAdmin The address of the initial admin + function initialize( + address initialAdmin, + uint256 _minUSDCAmount, + uint256 _feePercentage, + uint256 _maxFeeAmount + ) external initializer { + __Context_init(); + __ERC165_init(); + __AccessControl_init(); + __AccessControlEnumerable_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); + + _updateMinUSDCAmount(_minUSDCAmount); + _updateFeePercentage(_feePercentage); + _updateMaxFeeAmount(_maxFeeAmount); + } + + /// @notice Receive function for ETH + receive() external payable {} + + /************************* + * Public View Functions * + *************************/ + + /// @notice Returns the latest encryption key + /// @return keyId The ID of the latest encryption key + /// @return key The latest encryption key + function getLatestEncryptionKey() + public + view + returns (uint256 keyId, bytes memory key) + { + uint256 _numKeys = encryptionKeys.length; + if (_numKeys == 0) revert ErrorUnknownEncryptionKey(); + keyId = _numKeys - 1; + key = encryptionKeys[_numKeys - 1]; + } + + /// @notice Returns the encryption key at the given ID + /// @param _keyId The ID of the encryption key to return + /// @return key The encryption key at the given ID + function getEncryptionKey( + uint256 _keyId + ) external view returns (bytes memory) { + uint256 _numKeys = encryptionKeys.length; + if (_numKeys == 0) revert ErrorUnknownEncryptionKey(); + if (_keyId >= _numKeys) revert ErrorUnknownEncryptionKey(); + if (_keyId < _numKeys - 1) revert ErrorDeprecatedEncryptionKey(); + return encryptionKeys[_numKeys - 1]; + } + + /// @notice Returns the supported tokens + /// @return tokens The supported tokens + function getSupportedTokens() + external + view + returns (address[] memory tokens) + { + tokens = new address[](supportedTokens.length()); + for (uint256 i = 0; i < supportedTokens.length(); i++) { + tokens[i] = supportedTokens.at(i); + } + } + + /// @notice Returns the supported swap routers + /// @return swapRouters The supported swap routers + function getSupportedSwapRouters() + external + view + returns (address[] memory swapRouters) + { + swapRouters = new address[](supportedSwapRouters.length()); + for (uint256 i = 0; i < supportedSwapRouters.length(); i++) { + swapRouters[i] = supportedSwapRouters.at(i); + } + } + + /***************************** + * Public Mutating Functions * + *****************************/ + + /// @notice Transfers USDC to the encrypted receivers + /// @param amount The amount of USDC to transfer + /// @param usxReceiver The encrypted receiver for the USX token + /// @param usdcReceiver The encrypted receiver for the USDC token + function transferUSDC( + uint256 amount, + EncryptedReceiver memory usxReceiver, + EncryptedReceiver memory usdcReceiver + ) external nonReentrant { + IERC20(USDC).safeTransferFrom(msg.sender, address(this), amount); + + _transferUSDC(amount, usxReceiver, usdcReceiver); + } + + /// @notice Transfers a token to the encrypted receivers + /// @param token The address of the token to transfer + /// @param swapAmount The amount of token to swap + /// @param swapRouter The address of the swap router + /// @param swapData The data for the swap + /// @param usxReceiver The encrypted receiver for the USX token + /// @param usdcReceiver The encrypted receiver for the USDC token + function transferToken( + address token, + uint256 swapAmount, + address swapRouter, + bytes memory swapData, + EncryptedReceiver memory usxReceiver, + EncryptedReceiver memory usdcReceiver + ) external payable nonReentrant { + // check if the token is supported + if (!supportedTokens.contains(token)) { + revert ErrorTokenNotSupported(); + } + + // check if the swap router is supported + if (!supportedSwapRouters.contains(swapRouter)) { + revert ErrorSwapRouterNotSupported(); + } + + // transfer the token from msg.sender to this contract + if (token == address(0)) { + if (msg.value != swapAmount) revert ErrorInvalidAmount(); + } else { + if (msg.value != 0) revert ErrorInvalidAmount(); + IERC20(token).safeTransferFrom( + msg.sender, + address(this), + swapAmount + ); + } + + // swap the token to USDC + uint256 usdcBefore = IERC20(USDC).balanceOf(address(this)); + if (token != address(0)) { + IERC20(token).forceApprove(spenders[swapRouter], swapAmount); + } + (bool success, ) = swapRouter.call{value: msg.value}(swapData); + if (!success) revert ErrorSwapFailed(); + uint256 usdcAfter = IERC20(USDC).balanceOf(address(this)); + uint256 usdcAmount = usdcAfter - usdcBefore; + if (token != address(0)) { + IERC20(token).forceApprove(spenders[swapRouter], 0); // remove approval + } + + _transferUSDC(usdcAmount, usxReceiver, usdcReceiver); + } + + /************************ + * Restricted Functions * + ************************/ + + /// @notice Registers a new encryption key + /// @param _key The new encryption key to register + /// @return keyId The ID of the new encryption key + function registerNewEncryptionKey( + bytes memory _key + ) external onlyRole(KEY_MANAGER_ROLE) returns (uint256 keyId) { + if (_key.length != 33) revert ErrorInvalidEncryptionKeyLength(); + keyId = encryptionKeys.length; + encryptionKeys.push(_key); + + emit NewEncryptionKey(keyId, _key); + } + + /// @notice Updates the minimum amount of USDC to transfer + /// @param newMinUSDCAmount The new minimum amount of USDC to transfer + function updateMinUSDCAmount( + uint256 newMinUSDCAmount + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _updateMinUSDCAmount(newMinUSDCAmount); + } + + /// @notice Updates the fee percentage + /// @param newFeePercentage The new fee percentage + function updateFeePercentage( + uint256 newFeePercentage + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _updateFeePercentage(newFeePercentage); + } + + /// @notice Updates the max fee amount + /// @param newMaxFeeAmount The new max fee amount + function updateMaxFeeAmount( + uint256 newMaxFeeAmount + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + _updateMaxFeeAmount(newMaxFeeAmount); + } + + /// @notice Withdraws tokens from the contract + /// @dev This function is used to withdraw fees or unexpected tokens from the contract. + /// @param token The address of the token to withdraw + /// @param amount The amount of tokens to withdraw + function withdrawTokens( + address token, + address receiver, + uint256 amount + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) { + Address.sendValue(payable(receiver), amount); + } else { + IERC20(token).safeTransfer(receiver, amount); + } + } + + /// @notice Updates the supported tokens + /// @param tokens The addresses of the tokens to update + /// @param isSupported Whether the tokens are supported + function updateSupportedTokens( + address[] memory tokens, + bool isSupported + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + for (uint256 i = 0; i < tokens.length; i++) { + if (isSupported) { + supportedTokens.add(tokens[i]); + } else { + supportedTokens.remove(tokens[i]); + } + } + } + + /// @notice Updates the supported swap routers + /// @param swapRouter The address of the swap router to update + /// @param spender The address of the spender to update + /// @param isSupported Whether the swap router is supported + function updateSupportedSwapRouter( + address swapRouter, + address spender, + bool isSupported + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (isSupported) { + supportedSwapRouters.add(swapRouter); + spenders[swapRouter] = spender; + } else { + supportedSwapRouters.remove(swapRouter); + delete spenders[swapRouter]; + } + } + + /// @notice Updates the expected USDC receiver + /// @param newExpectedUSDCReceiver The new expected USDC receiver + function updateExpectedUSDCReceiver( + EncryptedReceiver memory newExpectedUSDCReceiver + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + EncryptedReceiver memory oldExpectedUSDCReceiver = expectedUSDCReceiver; + expectedUSDCReceiver = newExpectedUSDCReceiver; + + emit ExpectedUSDCReceiverUpdated(oldExpectedUSDCReceiver, newExpectedUSDCReceiver); + } + + /********************** + * Internal Functions * + **********************/ + + /// @dev Internal function to update the minimum amount of USDC to transfer + /// @param newMinUSDCAmount The new minimum amount of USDC to transfer + function _updateMinUSDCAmount(uint256 newMinUSDCAmount) internal { + uint256 oldMinUSDCAmount = minUSDCAmount; + minUSDCAmount = newMinUSDCAmount; + + emit MinUSDCAmountUpdated(oldMinUSDCAmount, newMinUSDCAmount); + } + + /// @dev Internal function to update the fee percentage + /// @param newFeePercentage The new fee percentage + function _updateFeePercentage(uint256 newFeePercentage) internal { + if (newFeePercentage > MAX_FEE_PERCENTAGE) + revert ErrorInvalidFeePercentage(); + + uint256 oldFeePercentage = feePercentage; + feePercentage = newFeePercentage; + + emit FeePercentageUpdated(oldFeePercentage, newFeePercentage); + } + + /// @dev Internal function to update the max fee amount + /// @param newMaxFeeAmount The new max fee amount + function _updateMaxFeeAmount(uint256 newMaxFeeAmount) internal { + uint256 oldMaxFeeAmount = maxFeeAmount; + maxFeeAmount = newMaxFeeAmount; + + emit MaxFeeAmountUpdated(oldMaxFeeAmount, newMaxFeeAmount); + } + + /// @dev Internal function to transfer USDC to the L1 ERC20 gateway validium + /// @param amount The amount of USDC to transfer + /// @param usxReceiver The encrypted receiver for the USX token + /// @param usdcReceiver The encrypted receiver for the USDC token + function _transferUSDC( + uint256 amount, + EncryptedReceiver memory usxReceiver, + EncryptedReceiver memory usdcReceiver + ) internal { + // 1. basic validation: + // - check usxReceiver.keyId is the latest encryption key + // - check the amount is greater than the minimum amount + // - the usdcReceiver.keyId will be checked in `IL1ERC20GatewayValidium(erc20Gateway).depositERC20`. + // - check the usdcReceiver is the expected USDC receiver, if not, revert. + (uint256 latestKeyId, ) = getLatestEncryptionKey(); + if (usxReceiver.keyId != latestKeyId) { + revert ErrorInvalidEncryptionKey(); + } + if (amount < minUSDCAmount) { + revert ErrorInvalidAmount(); + } + if (keccak256(abi.encode(usdcReceiver)) != keccak256(abi.encode(expectedUSDCReceiver))) { + revert ErrorInvalidUSDCReceiver(); + } + + // 2. charge the fee. + uint256 fee = (amount * feePercentage) / PRECISION; + if (fee > maxFeeAmount) fee = maxFeeAmount; + amount -= fee; + + // 3. approve and deposit USDC to the L1 ERC20 gateway validium + IERC20(USDC).forceApprove(erc20Gateway, amount); + IL1ERC20GatewayValidium(erc20Gateway).depositERC20( + USDC, + usdcReceiver.receiver, + amount, + GAS_LIMIT, + usdcReceiver.keyId + ); + + // 4. increment the nonce + uint256 nextNonce = nonce + 1; + nonce = nextNonce; + + // 5. send message to the L1 ERC20 gateway validium + IScrollMessengerValidium(messenger).sendMessage( + counterpart, + 0, + abi.encodeCall( + PrivateGatewayCloak.confirmDeposit, + (nextNonce, usxReceiver.receiver, usxReceiver.keyId, amount) + ), + GAS_LIMIT + ); + + emit USDCTransferred( + nextNonce, + usxReceiver.receiver, + usxReceiver.keyId, + amount + ); + } +} diff --git a/src/cloak/USXRebalancer.sol b/src/cloak/USXRebalancer.sol new file mode 100644 index 0000000..243066b --- /dev/null +++ b/src/cloak/USXRebalancer.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.30; + +import {AccessControlEnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +import {IL1ERC20GatewayValidium} from "./IL1ERC20GatewayValidium.sol"; +import {IScrollMessengerValidium} from "./IScrollMessengerValidium.sol"; + +import {IUSX} from "../interfaces/IUSX.sol"; +import {PrivateGatewayCloak} from "./PrivateGatewayCloak.sol"; + +/// @title USXRebalancer +/// @notice A contract for rebalancing USX and USDC. +contract USXRebalancer is + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + /********** + * Events * + **********/ + + /// @notice Emitted when the expected USX receiver is updated + /// @param oldExpectedUSXReceiver The old expected USX receiver + /// @param newExpectedUSXReceiver The new expected USX receiver + event ExpectedUSXReceiverUpdated( + EncryptedReceiver oldExpectedUSXReceiver, + EncryptedReceiver newExpectedUSXReceiver + ); + + /********** + * Errors * + **********/ + + /// @dev Thrown when the insufficient USDC balance + error ErrorInsufficientUSDCBalance(); + + /// @dev Thrown when the swap router is not supported + error ErrorSwapRouterNotSupported(); + + /// @dev Thrown when the swap failed + error ErrorSwapFailed(); + + /// @dev Thrown when the insufficient USX amount + error ErrorInsufficientUSXAmount(); + + /// @dev Thrown when the USX receiver is invalid + error ErrorInvalidUSXReceiver(); + + /************* + * Constants * + *************/ + + /// @notice The role required to rebalance the contract + bytes32 public constant REBALANCE_ROLE = keccak256("REBALANCE_ROLE"); + + /// @notice The gas limit for the deposit operation + uint256 private constant GAS_LIMIT = 1000000; + + /*********************** + * Immutable Variables * + ***********************/ + + /// @notice The address of the USDC token + address public immutable USDC; + + /// @notice The address of the USX token + address public immutable USX; + + /// @notice The address of the ERC20 gateway in scroll + address public immutable erc20Gateway; + + /*********** + * Structs * + ***********/ + + /// @notice A struct representing an encrypted receiver + /// @param receiver The encrypted receiver + /// @param keyId The ID of the encryption key used to encrypt the receiver + struct EncryptedReceiver { + bytes receiver; + uint256 keyId; + } + + /********************* + * Storage Variables * + *********************/ + + /// @notice The list of supported swap routers + EnumerableSet.AddressSet private supportedSwapRouters; + + /// @notice Mapping from swap router to token spender + mapping(address => address) private spenders; + + /// @notice The expected USX receiver + EncryptedReceiver public expectedUSXReceiver; + + /*************** + * Constructor * + ***************/ + + /// @custom:oz-upgrades-unsafe-allow constructor + /// @dev This constructor is used to initialize the immutable variables + /// @param _USDC The address of the USDC token + /// @param _USX The address of the USX token + /// @param _erc20Gateway The address of the ERC20 gateway in scroll + constructor(address _USDC, address _USX, address _erc20Gateway) { + _disableInitializers(); + + USDC = _USDC; + USX = _USX; + erc20Gateway = _erc20Gateway; + } + + /// @notice Initializes the contract + /// @param initialAdmin The address of the initial admin + function initialize(address initialAdmin) external initializer { + __Context_init(); + __ERC165_init(); + __AccessControl_init(); + __AccessControlEnumerable_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); + } + + /// @notice Receive function for ETH + receive() external payable {} + + /************************* + * Public View Functions * + *************************/ + + /// @notice Returns the supported swap routers + /// @return swapRouters The supported swap routers + function getSupportedSwapRouters() + external + view + returns (address[] memory swapRouters) + { + swapRouters = new address[](supportedSwapRouters.length()); + for (uint256 i = 0; i < supportedSwapRouters.length(); i++) { + swapRouters[i] = supportedSwapRouters.at(i); + } + } + + /***************************** + * Public Mutating Functions * + *****************************/ + + /// @notice Rebalances the contract by minting USX with USDC + /// @param amountUSDC The amount of USDC to mint + function rebalanceByMinting( + uint256 amountUSDC, + EncryptedReceiver memory usxReceiver + ) external onlyRole(REBALANCE_ROLE) nonReentrant { + uint256 usdcBalance = IERC20(USDC).balanceOf(address(this)); + if (usdcBalance < amountUSDC) revert ErrorInsufficientUSDCBalance(); + + // mint USX with USDC + uint256 minted = IUSX(USX).balanceOf(address(this)); + IERC20(USDC).forceApprove(USX, amountUSDC); + IUSX(USX).deposit(amountUSDC); + minted = IUSX(USX).balanceOf(address(this)) - minted; + + _transferUSX(minted, usxReceiver); + } + + /// @notice Transfers a token to the encrypted receivers + /// @param amountUSDC The amount of USDC to swap + /// @param swapRouter The address of the swap router + /// @param swapData The data for the swap + /// @param usxReceiver The encrypted receiver for the USX token + function rebalanceBySwapping( + uint256 amountUSDC, + address swapRouter, + bytes memory swapData, + EncryptedReceiver memory usxReceiver + ) external payable nonReentrant { + // check if the swap router is supported + if (!supportedSwapRouters.contains(swapRouter)) { + revert ErrorSwapRouterNotSupported(); + } + + uint256 usdcBalance = IERC20(USDC).balanceOf(address(this)); + if (usdcBalance < amountUSDC) revert ErrorInsufficientUSDCBalance(); + + // swap the token to USX + uint256 usxBefore = IERC20(USX).balanceOf(address(this)); + IERC20(USDC).forceApprove(spenders[swapRouter], amountUSDC); + (bool success, ) = swapRouter.call{value: msg.value}(swapData); + if (!success) revert ErrorSwapFailed(); + uint256 usxAfter = IERC20(USX).balanceOf(address(this)); + uint256 usxAmount = usxAfter - usxBefore; + IERC20(USDC).forceApprove(spenders[swapRouter], 0); // remove approval + + if (usxAmount < amountUSDC * 10 ** 12) { + revert ErrorInsufficientUSXAmount(); + } + + _transferUSX(usxAmount, usxReceiver); + } + + /************************ + * Restricted Functions * + ************************/ + + /// @notice Withdraws tokens from the contract + /// @dev This function is used to withdraw fees or unexpected tokens from the contract. + /// @param token The address of the token to withdraw + /// @param amount The amount of tokens to withdraw + function withdrawTokens( + address token, + address receiver, + uint256 amount + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) { + Address.sendValue(payable(receiver), amount); + } else { + IERC20(token).safeTransfer(receiver, amount); + } + } + + /// @notice Updates the supported swap routers + /// @param swapRouter The address of the swap router to update + /// @param spender The address of the spender to update + /// @param isSupported Whether the swap router is supported + function updateSupportedSwapRouter( + address swapRouter, + address spender, + bool isSupported + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (isSupported) { + supportedSwapRouters.add(swapRouter); + spenders[swapRouter] = spender; + } else { + supportedSwapRouters.remove(swapRouter); + delete spenders[swapRouter]; + } + } + + /// @notice Updates the expected USX receiver + /// @param newExpectedUSXReceiver The new expected USX receiver to update + function updateExpectedUSXReceiver( + EncryptedReceiver memory newExpectedUSXReceiver + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + EncryptedReceiver memory oldExpectedUSXReceiver = expectedUSXReceiver; + expectedUSXReceiver = newExpectedUSXReceiver; + + emit ExpectedUSXReceiverUpdated( + oldExpectedUSXReceiver, + newExpectedUSXReceiver + ); + } + + /********************** + * Internal Functions * + **********************/ + + /// @dev Internal function to transfer USX to the L1 ERC20 gateway validium + /// @param amountUSX The amount of USX to transfer + /// @param usxReceiver The encrypted receiver for the USX token + function _transferUSX( + uint256 amountUSX, + EncryptedReceiver memory usxReceiver + ) internal { + if ( + keccak256(abi.encode(usxReceiver)) != + keccak256(abi.encode(expectedUSXReceiver)) + ) { + revert ErrorInvalidUSXReceiver(); + } + + // approve and deposit USX to the L1 ERC20 gateway validium + IERC20(USX).forceApprove(erc20Gateway, amountUSX); + IL1ERC20GatewayValidium(erc20Gateway).depositERC20( + USX, + usxReceiver.receiver, + amountUSX, + GAS_LIMIT, + usxReceiver.keyId + ); + } +} diff --git a/src/interfaces/IUSX.sol b/src/interfaces/IUSX.sol index 4a7f9c0..eb7d948 100644 --- a/src/interfaces/IUSX.sol +++ b/src/interfaces/IUSX.sol @@ -12,6 +12,11 @@ interface IUSX is IERC20 { function pause() external; function unpause() external; + // public functions + function deposit(uint256 amount) external; + function requestUSDC(uint256 amount) external; + function claimUSDC() external; + // State getters function paused() external view returns (bool); function governance() external view returns (address); diff --git a/test/cloak/PrivateGatewayCloak.t.sol b/test/cloak/PrivateGatewayCloak.t.sol new file mode 100644 index 0000000..302ca44 --- /dev/null +++ b/test/cloak/PrivateGatewayCloak.t.sol @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {PrivateGatewayCloak} from "../../src/cloak/PrivateGatewayCloak.sol"; +import {MockUSDC} from "../mocks/MockUSDC.sol"; +import {MockScrollMessengerValidium, MockL2ERC20GatewayValidium} from "../mocks/MockScrollValidiumInfra.sol"; + +contract PrivateGatewayCloakTest is Test { + MockUSDC internal usdc; + IERC20 internal usx; + MockScrollMessengerValidium internal messenger; + MockL2ERC20GatewayValidium internal l2Gateway; + + PrivateGatewayCloak internal gateway; + + address internal admin = address(0xA11CE); + address internal withdrawer = address(0xBEEF); + address internal counterpart = address(0xC10A); // scroll gateway on the other chain + address internal rebalancer = address(0xDEAD); + + function setUp() public { + usdc = new MockUSDC(); + usx = IERC20(address(new MockUSDC())); // 18 decimals not required in logic, only address + + messenger = new MockScrollMessengerValidium(); + l2Gateway = new MockL2ERC20GatewayValidium(address(messenger)); + + PrivateGatewayCloak impl = new PrivateGatewayCloak( + address(usdc), + address(usx), + address(l2Gateway), + counterpart + ); + bytes memory data = abi.encodeCall( + PrivateGatewayCloak.initialize, + (admin) + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data); + gateway = PrivateGatewayCloak(address(proxy)); + + bytes32 withdrawRole = gateway.WITHDRAW_USX_ROLE(); + vm.prank(admin); + gateway.grantRole(withdrawRole, withdrawer); + + bytes32 rebalanceRole = gateway.REBALANCE_ROLE(); + vm.prank(admin); + gateway.grantRole(rebalanceRole, rebalancer); + } + + function _depositHash( + uint256 nonce, + bytes memory encryptedReceiver, + uint256 keyId, + uint256 amountUSDC + ) internal pure returns (bytes32) { + return + keccak256(abi.encode(nonce, encryptedReceiver, keyId, amountUSDC)); + } + + function test_confirmDeposit_reverts_when_caller_not_messenger() public { + vm.expectRevert(PrivateGatewayCloak.ErrorCallerIsNotMessenger.selector); + gateway.confirmDeposit(1, hex"aa", 0, 100e6); + } + + function test_confirmDeposit_reverts_when_xdomain_sender_not_counterpart() + public + { + uint256 nonce = 1; + bytes memory encryptedReceiver = hex"aa"; + uint256 keyId = 0; + uint256 amountUSDC = 100e6; + + messenger.setXDomainMessageSender(address(0xDEAD)); + + vm.prank(address(messenger)); + vm.expectRevert( + PrivateGatewayCloak.ErrorCallerIsNotCounterpartGateway.selector + ); + gateway.confirmDeposit(nonce, encryptedReceiver, keyId, amountUSDC); + } + + function test_confirmDeposit_happy_path_sets_flag_and_emits_event() public { + uint256 nonce = 1; + bytes memory encryptedReceiver = hex"aa"; + uint256 keyId = 0; + uint256 amountUSDC = 100e6; + + messenger.setXDomainMessageSender(counterpart); + + bytes32 hash = _depositHash( + nonce, + encryptedReceiver, + keyId, + amountUSDC + ); + assertFalse(gateway.confirmedDeposits(hash)); + + vm.prank(address(messenger)); + vm.expectEmit(true, true, true, true, address(gateway)); + emit PrivateGatewayCloak.DepositConfirmed( + nonce, + encryptedReceiver, + keyId, + amountUSDC + ); + gateway.confirmDeposit(nonce, encryptedReceiver, keyId, amountUSDC); + + assertTrue(gateway.confirmedDeposits(hash)); + + vm.prank(address(messenger)); + vm.expectRevert( + PrivateGatewayCloak.ErrorDepositAlreadyConfirmed.selector + ); + gateway.confirmDeposit(nonce, encryptedReceiver, keyId, amountUSDC); + } + + function _prepareConfirmedDeposit( + uint256 nonce, + bytes memory encryptedReceiver, + uint256 keyId, + uint256 amountUSDC + ) internal returns (bytes32 hash) { + messenger.setXDomainMessageSender(counterpart); + hash = _depositHash(nonce, encryptedReceiver, keyId, amountUSDC); + vm.prank(address(messenger)); + gateway.confirmDeposit(nonce, encryptedReceiver, keyId, amountUSDC); + } + + function test_withdrawUSX_reverts_when_deposit_not_confirmed() public { + uint256 nonce = 1; + bytes memory encryptedReceiver = hex"aa"; + uint256 keyId = 0; + uint256 amountUSDC = 100e6; + address actualReceiver = address(0x1234); + + vm.prank(withdrawer); + vm.expectRevert(PrivateGatewayCloak.ErrorDepositNotConfirmed.selector); + gateway.withdrawUSX( + nonce, + encryptedReceiver, + keyId, + amountUSDC, + actualReceiver + ); + } + + function test_withdrawUSX_reverts_when_caller_not_withdrawer() public { + uint256 nonce = 1; + bytes memory encryptedReceiver = hex"aa"; + uint256 keyId = 0; + uint256 amountUSDC = 100e6; + address actualReceiver = address(0x1234); + + messenger.setXDomainMessageSender(counterpart); + vm.prank(address(messenger)); + gateway.confirmDeposit(nonce, encryptedReceiver, keyId, amountUSDC); + + // caller without WITHDRAW_USX_ROLE should revert (AccessControl) + vm.expectRevert(); + gateway.withdrawUSX( + nonce, + encryptedReceiver, + keyId, + amountUSDC, + actualReceiver + ); + } + + function test_withdrawUSX_happy_path_calls_l2_gateway_and_emits_event() + public + { + uint256 nonce = 1; + bytes memory encryptedReceiver = hex"aa"; + uint256 keyId = 0; + uint256 amountUSDC = 100e6; + address actualReceiver = address(0x1234); + + bytes32 hash = _prepareConfirmedDeposit( + nonce, + encryptedReceiver, + keyId, + amountUSDC + ); + assertTrue(gateway.confirmedDeposits(hash)); + assertFalse(gateway.withdrawnDeposits(hash)); + + uint256 amountUSX = amountUSDC * 1e12; + deal(address(usx), address(gateway), amountUSX); + + uint256 gatewayUsxBefore = usx.balanceOf(address(gateway)); + uint256 l2UsxBefore = usx.balanceOf(address(l2Gateway)); + + vm.prank(withdrawer); + vm.expectEmit(true, true, true, true, address(gateway)); + emit PrivateGatewayCloak.WithdrawUSX( + nonce, + encryptedReceiver, + keyId, + amountUSDC, + actualReceiver, + amountUSX + ); + gateway.withdrawUSX( + nonce, + encryptedReceiver, + keyId, + amountUSDC, + actualReceiver + ); + + assertTrue(gateway.withdrawnDeposits(hash)); + + ( + address wToken, + address wTo, + uint256 wAmount, + uint256 wGasLimit, + uint256 wValue, + address wFrom + ) = l2Gateway.lastWithdrawal(); + assertEq(wToken, address(usx)); + assertEq(wTo, actualReceiver); + assertEq(wAmount, amountUSX); + assertEq(wGasLimit, 0); + assertEq(wValue, 0); + assertEq(wFrom, address(gateway)); + + // token balances: cloak gateway loses USX, L2 gateway holds locked USX + assertEq(usx.balanceOf(address(gateway)), gatewayUsxBefore - amountUSX); + assertEq(usx.balanceOf(address(l2Gateway)), l2UsxBefore + amountUSX); + } + + function test_withdrawUSX_reverts_when_already_withdrawn() public { + uint256 nonce = 1; + bytes memory encryptedReceiver = hex"aa"; + uint256 keyId = 0; + uint256 amountUSDC = 100e6; + address actualReceiver = address(0x1234); + + bytes32 hash = _prepareConfirmedDeposit( + nonce, + encryptedReceiver, + keyId, + amountUSDC + ); + deal(address(usx), address(gateway), amountUSDC * 1e12); + + vm.prank(withdrawer); + gateway.withdrawUSX( + nonce, + encryptedReceiver, + keyId, + amountUSDC, + actualReceiver + ); + assertTrue(gateway.withdrawnDeposits(hash)); + + vm.prank(withdrawer); + vm.expectRevert( + PrivateGatewayCloak.ErrorDepositAlreadyWithdrawn.selector + ); + gateway.withdrawUSX( + nonce, + encryptedReceiver, + keyId, + amountUSDC, + actualReceiver + ); + } + + function test_withdrawTokens_erc20_and_eth_paths() public { + address receiver = address(0xB0B); + + deal(address(usx), address(gateway), 1_000e18); + vm.prank(admin); + gateway.withdrawTokens(address(usx), receiver, 200e18); + assertEq(usx.balanceOf(receiver), 200e18); + + vm.deal(address(gateway), 1 ether); + uint256 beforeBal = receiver.balance; + vm.prank(admin); + gateway.withdrawTokens(address(0), receiver, 0.5 ether); + assertEq(receiver.balance, beforeBal + 0.5 ether); + } + + function test_withdrawTokens_reverts_when_caller_not_admin() public { + address receiver = address(0xB0B); + + deal(address(usx), address(gateway), 1_000e18); + + vm.expectRevert(); + gateway.withdrawTokens(address(usx), receiver, 200e18); + } + + function test_updateRebalancer_only_admin_and_emits_event() public { + address newRebalancer = address(0xF00D); + + vm.prank(admin); + vm.expectEmit(true, true, true, true, address(gateway)); + emit PrivateGatewayCloak.RebalancerUpdated( + address(0), + newRebalancer + ); + gateway.updateRebalancer(newRebalancer); + + assertEq(gateway.rebalancer(), newRebalancer); + + vm.expectRevert(); + gateway.updateRebalancer(address(0xB0B)); + } + + function test_rebalance_reverts_when_caller_not_authorized() public { + deal(address(usdc), address(gateway), 100e6); + + vm.expectRevert(); + gateway.rebalance(); + } + + function test_rebalance_reverts_when_no_usdc_balance() public { + vm.prank(rebalancer); + vm.expectRevert(PrivateGatewayCloak.ErrorNoUSDCBalance.selector); + gateway.rebalance(); + } + + function test_rebalance_reverts_when_rebalancer_not_set() public { + deal(address(usdc), address(gateway), 100e6); + + // clear rebalancer address + vm.prank(admin); + gateway.updateRebalancer(address(0)); + + vm.prank(rebalancer); + vm.expectRevert(PrivateGatewayCloak.ErrorRebalancerNotSet.selector); + gateway.rebalance(); + } + + function test_rebalance_happy_path_withdraws_usdc_to_rebalancer() public { + uint256 amountUSDC = 250e6; + + // fund the gateway with USDC + deal(address(usdc), address(gateway), amountUSDC); + + // set rebalancer address + vm.prank(admin); + gateway.updateRebalancer(rebalancer); + + uint256 gatewayUsdcBefore = usdc.balanceOf(address(gateway)); + uint256 l2UsdcBefore = usdc.balanceOf(address(l2Gateway)); + + vm.prank(rebalancer); + gateway.rebalance(); + + ( + address wToken, + address wTo, + uint256 wAmount, + uint256 wGasLimit, + uint256 wValue, + address wFrom + ) = l2Gateway.lastWithdrawal(); + + assertEq(wToken, address(usdc)); + assertEq(wTo, rebalancer); + assertEq(wAmount, amountUSDC); + assertEq(wGasLimit, 0); + assertEq(wValue, 0); + assertEq(wFrom, address(gateway)); + + assertEq( + usdc.balanceOf(address(gateway)), + gatewayUsdcBefore - amountUSDC + ); + assertEq( + usdc.balanceOf(address(l2Gateway)), + l2UsdcBefore + amountUSDC + ); + } +} diff --git a/test/cloak/PrivateGatewayScroll.t.sol b/test/cloak/PrivateGatewayScroll.t.sol new file mode 100644 index 0000000..55688b9 --- /dev/null +++ b/test/cloak/PrivateGatewayScroll.t.sol @@ -0,0 +1,799 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {PrivateGatewayScroll} from "../../src/cloak/PrivateGatewayScroll.sol"; +import {PrivateGatewayCloak} from "../../src/cloak/PrivateGatewayCloak.sol"; +import {MockUSDC} from "../mocks/MockUSDC.sol"; +import {MockScrollMessengerValidium, MockL1ERC20GatewayValidium, MockSwapRouter, MockSwapRouterReverting} from "../mocks/MockScrollValidiumInfra.sol"; + +contract PrivateGatewayScrollTest is Test { + MockUSDC internal usdc; + IERC20 internal usx; + + MockScrollMessengerValidium internal messenger; + MockL1ERC20GatewayValidium internal l1Gateway; + MockSwapRouter internal swapRouter; + MockSwapRouterReverting internal swapRouterReverting; + + PrivateGatewayScroll internal gateway; + + address internal admin = address(0xA11CE); + address internal keyManager = address(0xBEEF); + address internal counterpart = address(0xC10A); // cloak gateway on the other chain + + uint256 internal constant INITIAL_USDC_BALANCE = 1_000_000e6; + uint256 internal constant MIN_USDC_AMOUNT = 100e6; + uint256 internal constant FEE_PERCENTAGE = 1e16; // 1% + uint256 internal constant MAX_FEE_AMOUNT = 1_000e6; + + function setUp() public { + usdc = new MockUSDC(); + usx = IERC20(address(new MockUSDC())); // simple 18-decimals not required; only address is used + + messenger = new MockScrollMessengerValidium(); + l1Gateway = new MockL1ERC20GatewayValidium(address(messenger)); + swapRouter = new MockSwapRouter(); + swapRouterReverting = new MockSwapRouterReverting(); + + PrivateGatewayScroll impl = new PrivateGatewayScroll( + address(usdc), + address(usx), + address(l1Gateway), + counterpart + ); + bytes memory data = abi.encodeCall( + PrivateGatewayScroll.initialize, + (admin, MIN_USDC_AMOUNT, FEE_PERCENTAGE, MAX_FEE_AMOUNT) + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data); + gateway = PrivateGatewayScroll(payable(address(proxy))); + + bytes32 keyRole = gateway.KEY_MANAGER_ROLE(); + vm.prank(admin); + gateway.grantRole(keyRole, keyManager); + } + + function test_registerNewEncryptionKey_and_getters() public { + bytes memory key = hex"01"; + key = abi.encodePacked(key, new bytes(32)); // length 33 + + vm.prank(keyManager); + uint256 keyId = gateway.registerNewEncryptionKey(key); + assertEq(keyId, 0); + + (uint256 latestId, bytes memory latestKey) = gateway + .getLatestEncryptionKey(); + assertEq(latestId, 0); + assertEq(latestKey, key); + + bytes memory fetched = gateway.getEncryptionKey(0); + assertEq(fetched, key); + } + + function test_registerNewEncryptionKey_reverts_on_invalid_length() public { + bytes memory key = new bytes(32); + + vm.prank(keyManager); + vm.expectRevert( + PrivateGatewayScroll.ErrorInvalidEncryptionKeyLength.selector + ); + gateway.registerNewEncryptionKey(key); + } + + function test_getLatestEncryptionKey_reverts_when_empty() public { + vm.expectRevert( + PrivateGatewayScroll.ErrorUnknownEncryptionKey.selector + ); + gateway.getLatestEncryptionKey(); + } + + function test_getSupportedTokens_and_swapRouters_after_updates() public { + address token1 = address(0x1); + address token2 = address(0x2); + address router1 = address(0x3); + address router2 = address(0x4); + + vm.startPrank(admin); + address[] memory tokens = new address[](2); + tokens[0] = token1; + tokens[1] = token2; + gateway.updateSupportedTokens(tokens, true); + gateway.updateSupportedSwapRouter(router1, router1, true); + gateway.updateSupportedSwapRouter(router2, router2, true); + + // remove one token and one router to hit the removal branches + address[] memory toRemove = new address[](1); + toRemove[0] = token2; + gateway.updateSupportedTokens(toRemove, false); + gateway.updateSupportedSwapRouter(router2, address(0), false); + vm.stopPrank(); + + address[] memory supportedTokens = gateway.getSupportedTokens(); + address[] memory supportedRouters = gateway.getSupportedSwapRouters(); + + assertEq(supportedTokens.length, 1); + assertEq(supportedTokens[0], token1); + + assertEq(supportedRouters.length, 1); + assertEq(supportedRouters[0], router1); + } + + function test_getEncryptionKey_reverts_when_unknown_or_deprecated() public { + bytes memory key1 = abi.encodePacked(bytes1(0x01), new bytes(32)); + bytes memory key2 = abi.encodePacked(bytes1(0x02), new bytes(32)); + + vm.startPrank(keyManager); + gateway.registerNewEncryptionKey(key1); + gateway.registerNewEncryptionKey(key2); + vm.stopPrank(); + + // keyId too large + vm.expectRevert( + PrivateGatewayScroll.ErrorUnknownEncryptionKey.selector + ); + gateway.getEncryptionKey(2); + + // deprecated key + vm.expectRevert( + PrivateGatewayScroll.ErrorDeprecatedEncryptionKey.selector + ); + gateway.getEncryptionKey(0); + } + + function test_updateMinUSDCAmount_emits_event() public { + uint256 newMin = 200e6; + + vm.prank(admin); + vm.expectEmit(true, true, true, true, address(gateway)); + emit PrivateGatewayScroll.MinUSDCAmountUpdated(MIN_USDC_AMOUNT, newMin); + gateway.updateMinUSDCAmount(newMin); + + assertEq(gateway.minUSDCAmount(), newMin); + } + + function test_updateFeePercentage_bounds_and_event() public { + uint256 newFee = 5e16; // 5% + + vm.prank(admin); + vm.expectEmit(true, true, true, true, address(gateway)); + emit PrivateGatewayScroll.FeePercentageUpdated(FEE_PERCENTAGE, newFee); + gateway.updateFeePercentage(newFee); + assertEq(gateway.feePercentage(), newFee); + + uint256 invalid = 1e17 + 1; // > 10% + vm.prank(admin); + vm.expectRevert( + PrivateGatewayScroll.ErrorInvalidFeePercentage.selector + ); + gateway.updateFeePercentage(invalid); + } + + function test_updateMaxFeeAmount_emits_event() public { + uint256 newMax = 2_000e6; + + vm.prank(admin); + vm.expectEmit(true, true, true, true, address(gateway)); + emit PrivateGatewayScroll.MaxFeeAmountUpdated(MAX_FEE_AMOUNT, newMax); + gateway.updateMaxFeeAmount(newMax); + assertEq(gateway.maxFeeAmount(), newMax); + } + + function test_only_admin_can_update_parameters_and_withdrawTokens() public { + uint256 newMin = 200e6; + + // non-admin should revert + vm.expectRevert(); + gateway.updateMinUSDCAmount(newMin); + + // admin succeeds + vm.prank(admin); + gateway.updateMinUSDCAmount(newMin); + assertEq(gateway.minUSDCAmount(), newMin); + + // withdrawTokens access control + address receiver = address(0xB0B); + deal(address(usdc), address(gateway), 1_000e6); + + vm.expectRevert(); + gateway.withdrawTokens(address(usdc), receiver, 100e6); + + vm.prank(admin); + gateway.withdrawTokens(address(usdc), receiver, 100e6); + assertEq(usdc.balanceOf(receiver), 100e6); + } + + function test_withdrawTokens_eth_path() public { + address receiver = address(0xB0B); + + vm.deal(address(gateway), 1 ether); + uint256 beforeBal = receiver.balance; + + vm.prank(admin); + gateway.withdrawTokens(address(0), receiver, 0.5 ether); + + assertEq(receiver.balance, beforeBal + 0.5 ether); + } + + function test_only_key_manager_can_register_encryption_key() public { + bytes memory key = abi.encodePacked(bytes1(0x01), new bytes(32)); + + vm.expectRevert(); + gateway.registerNewEncryptionKey(key); + } + + function _registerKey() internal returns (uint256 keyId, bytes memory key) { + key = abi.encodePacked(bytes1(0x01), new bytes(32)); + vm.prank(keyManager); + keyId = gateway.registerNewEncryptionKey(key); + } + + function _prepareUser(uint256 amount) internal returns (address user) { + user = address(0xD00D); + deal(address(usdc), user, amount); + vm.prank(user); + IERC20(address(usdc)).approve(address(gateway), type(uint256).max); + } + + function test_transferUSDC_fee_capped_by_maxFeeAmount() public { + (uint256 keyId, bytes memory usxReceiverBytes) = _registerKey(); + address user = _prepareUser(INITIAL_USDC_BALANCE); + + // choose amount large enough that raw fee > MAX_FEE_AMOUNT + uint256 amount = 200_000e6; + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: usxReceiverBytes, + keyId: keyId + }); + bytes memory usdcReceiverBytes = hex"abcd"; + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: usdcReceiverBytes, + keyId: keyId + }); + vm.prank(admin); + gateway.updateExpectedUSDCReceiver(usdcReceiver); + + uint256 expectedFee = (amount * gateway.feePercentage()) / 1e18; + assertTrue(expectedFee > gateway.maxFeeAmount()); + + uint256 gatewayUsdcBefore = usdc.balanceOf(address(gateway)); + uint256 l1UsdcBefore = usdc.balanceOf(address(l1Gateway)); + + vm.prank(user); + gateway.transferUSDC(amount, usxReceiver, usdcReceiver); + + (, , uint256 depAmount, , , , ) = l1Gateway.lastDeposit(); + + // actual fee must be capped by maxFeeAmount + uint256 actualFee = usdc.balanceOf(address(gateway)) - + gatewayUsdcBefore; + assertEq(actualFee, gateway.maxFeeAmount()); + assertEq(depAmount, amount - gateway.maxFeeAmount()); + assertEq(usdc.balanceOf(address(l1Gateway)), l1UsdcBefore + depAmount); + } + + function test_transferUSDC_happy_path_deposits_and_sends_message() public { + (uint256 keyId, bytes memory usxReceiverBytes) = _registerKey(); + address user = _prepareUser(INITIAL_USDC_BALANCE); + + uint256 amount = 1_000e6; + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: usxReceiverBytes, + keyId: keyId + }); + bytes memory usdcReceiverBytes = hex"abcd"; + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: usdcReceiverBytes, + keyId: keyId + }); + vm.prank(admin); + gateway.updateExpectedUSDCReceiver(usdcReceiver); + + uint256 netAmount; + + { + uint256 fee = (amount * gateway.feePercentage()) / 1e18; + if (fee > gateway.maxFeeAmount()) { + fee = gateway.maxFeeAmount(); + } + netAmount = amount - fee; + uint256 gatewayUsdcBefore = usdc.balanceOf(address(gateway)); + uint256 l1UsdcBefore = usdc.balanceOf(address(l1Gateway)); + + vm.prank(user); + vm.expectEmit(true, true, true, true, address(gateway)); + emit PrivateGatewayScroll.USDCTransferred( + 1, + usxReceiverBytes, + keyId, + netAmount + ); + gateway.transferUSDC(amount, usxReceiver, usdcReceiver); + assertEq(gateway.nonce(), 1); + + // token balances: user -> gateway -> L1 gateway (net amount), fee stays in gateway + assertEq(usdc.balanceOf(address(gateway)), gatewayUsdcBefore + fee); + assertEq( + usdc.balanceOf(address(l1Gateway)), + l1UsdcBefore + netAmount + ); + } + + { + MockL1ERC20GatewayValidium.Deposit memory deposit; + ( + deposit.token, + deposit.to, + deposit.amount, + deposit.gasLimit, + deposit.keyId, + deposit.value, + deposit.from + ) = l1Gateway.lastDeposit(); + assertEq(deposit.token, address(usdc)); + assertEq(deposit.to, usdcReceiverBytes); + assertEq(deposit.amount, netAmount); + assertEq(deposit.gasLimit, 1_000_000); + assertEq(deposit.keyId, keyId); + assertEq(deposit.value, 0); + assertEq(deposit.from, address(gateway)); + } + + { + MockScrollMessengerValidium.Message memory message; + ( + message.target, + message.value, + message.message, + message.gasLimit, + message.from, + message.msgValue + ) = messenger.lastMessage(); + assertEq(message.target, counterpart); + assertEq(message.value, 0); + assertEq(message.gasLimit, 1_000_000); + assertEq(message.from, address(gateway)); + assertEq(message.msgValue, 0); + bytes memory expectedMsg = abi.encodeCall( + PrivateGatewayCloak.confirmDeposit, + (uint256(1), usxReceiverBytes, keyId, netAmount) + ); + assertEq(message.message, expectedMsg); + } + } + + function test_transferUSDC_reverts_when_no_encryption_key_registered() + public + { + address user = _prepareUser(INITIAL_USDC_BALANCE); + + uint256 amount = MIN_USDC_AMOUNT; + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"aa", + keyId: 0 + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"bb", + keyId: 0 + }); + + vm.prank(user); + vm.expectRevert( + PrivateGatewayScroll.ErrorUnknownEncryptionKey.selector + ); + gateway.transferUSDC(amount, usxReceiver, usdcReceiver); + } + + function test_transferUSDC_reverts_when_amount_below_min() public { + (uint256 keyId, bytes memory usxReceiverBytes) = _registerKey(); + address user = _prepareUser(INITIAL_USDC_BALANCE); + + uint256 amount = MIN_USDC_AMOUNT - 1; + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: usxReceiverBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"11", + keyId: keyId + }); + + vm.prank(user); + vm.expectRevert(PrivateGatewayScroll.ErrorInvalidAmount.selector); + gateway.transferUSDC(amount, usxReceiver, usdcReceiver); + } + + function test_transferUSDC_reverts_when_key_not_latest() public { + (uint256 keyId, bytes memory keyBytes) = _registerKey(); + + // register another key so that keyId becomes deprecated + bytes memory key2 = abi.encodePacked(bytes1(0x02), new bytes(32)); + vm.prank(keyManager); + gateway.registerNewEncryptionKey(key2); + + address user = _prepareUser(INITIAL_USDC_BALANCE); + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: keyBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"11", + keyId: keyId + }); + + vm.prank(user); + vm.expectRevert( + PrivateGatewayScroll.ErrorInvalidEncryptionKey.selector + ); + gateway.transferUSDC(MIN_USDC_AMOUNT, usxReceiver, usdcReceiver); + } + + function test_transferToken_reverts_when_token_not_supported() public { + (uint256 keyId, bytes memory keyBytes) = _registerKey(); + address user = _prepareUser(INITIAL_USDC_BALANCE); + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: keyBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"22", + keyId: keyId + }); + + address token = address(0xDEAD); + address router = address(swapRouter); + + vm.prank(user); + vm.expectRevert(PrivateGatewayScroll.ErrorTokenNotSupported.selector); + gateway.transferToken( + token, + 1e18, + router, + "", + usxReceiver, + usdcReceiver + ); + } + + function test_transferToken_reverts_when_router_not_supported() public { + (uint256 keyId, bytes memory keyBytes) = _registerKey(); + address user = _prepareUser(INITIAL_USDC_BALANCE); + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: keyBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"22", + keyId: keyId + }); + + address token = address(0); + address router = address(0xDEAD); + + vm.prank(admin); + address[] memory tokens = new address[](1); + tokens[0] = token; + gateway.updateSupportedTokens(tokens, true); + + vm.prank(user); + vm.expectRevert( + PrivateGatewayScroll.ErrorSwapRouterNotSupported.selector + ); + gateway.transferToken( + token, + 1e18, + router, + "", + usxReceiver, + usdcReceiver + ); + } + + function test_transferToken_eth_happy_path_swaps_and_bridges() public { + (uint256 keyId, bytes memory keyBytes) = _registerKey(); + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: keyBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"33", + keyId: keyId + }); + vm.prank(admin); + gateway.updateExpectedUSDCReceiver(usdcReceiver); + + address token = address(0); + address router = address(swapRouter); + + { + vm.startPrank(admin); + address[] memory tokens = new address[](1); + tokens[0] = token; + gateway.updateSupportedTokens(tokens, true); + gateway.updateSupportedSwapRouter(router, router, true); + vm.stopPrank(); + } + + // router pre-funded with USDC so it can pay out swap proceeds + deal(address(usdc), address(swapRouter), 1_000_000e6); + + uint256 routerUsdcBefore = usdc.balanceOf(address(swapRouter)); + uint256 gatewayUsdcBefore = usdc.balanceOf(address(gateway)); + uint256 l1UsdcBefore = usdc.balanceOf(address(l1Gateway)); + uint256 gatewayEthBefore = address(gateway).balance; + + uint256 swapAmount = 500e6; + bytes memory swapData = abi.encodeWithSelector( + MockSwapRouter.swapToUSDC.selector, + swapAmount, + address(0), + address(usdc) + ); + + vm.deal(address(0xF00D), 10 ether); + vm.prank(address(0xF00D)); + gateway.transferToken{value: swapAmount}( + token, + swapAmount, + router, + swapData, + usxReceiver, + usdcReceiver + ); + + // swapRouter sends exactly swapAmount USDC to gateway, fee is charged before bridging + ( + address depToken, + bytes memory depTo, + uint256 depAmount, + , + , + , + + ) = l1Gateway.lastDeposit(); + uint256 expectedFee = (swapAmount * gateway.feePercentage()) / 1e18; + if (expectedFee > gateway.maxFeeAmount()) { + expectedFee = gateway.maxFeeAmount(); + } + uint256 expectedNet = swapAmount - expectedFee; + assertEq(depAmount, expectedNet); + assertEq(depTo, usdcReceiver.receiver); + assertEq(depToken, address(usdc)); + + // balances: router -> gateway (swapAmount) -> L1 gateway (net), fee remains in gateway + assertEq( + usdc.balanceOf(address(swapRouter)), + routerUsdcBefore - swapAmount + ); + assertEq( + usdc.balanceOf(address(gateway)), + gatewayUsdcBefore + expectedFee + ); + assertEq( + usdc.balanceOf(address(l1Gateway)), + l1UsdcBefore + expectedNet + ); + // ETH should not be left in the gateway (fully forwarded to router) + assertEq(address(gateway).balance, gatewayEthBefore); + } + + function test_transferToken_eth_reverts_when_msgValue_mismatch() public { + (uint256 keyId, bytes memory keyBytes) = _registerKey(); + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: keyBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"55", + keyId: keyId + }); + + address token = address(0); + address router = address(swapRouter); + + vm.startPrank(admin); + address[] memory tokens = new address[](1); + tokens[0] = token; + gateway.updateSupportedTokens(tokens, true); + gateway.updateSupportedSwapRouter(router, router, true); + vm.stopPrank(); + + uint256 swapAmount = 500e6; + bytes memory swapData = abi.encodeWithSelector( + MockSwapRouter.swapToUSDC.selector, + swapAmount, + address(0), + address(usdc) + ); + + address user = address(0xF0F0); + vm.deal(user, 10 ether); + + vm.prank(user); + vm.expectRevert(PrivateGatewayScroll.ErrorInvalidAmount.selector); + gateway.transferToken{value: swapAmount - 1}( + token, + swapAmount, + router, + swapData, + usxReceiver, + usdcReceiver + ); + } + + function test_transferToken_noneth_happy_path_swaps_and_bridges() public { + (uint256 keyId, bytes memory keyBytes) = _registerKey(); + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: keyBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"44", + keyId: keyId + }); + vm.prank(admin); + gateway.updateExpectedUSDCReceiver(usdcReceiver); + + // use USX mock token as generic ERC20 to be swapped into USDC + address tokenIn = address(usx); + address router = address(swapRouter); + + vm.startPrank(admin); + address[] memory tokens = new address[](1); + tokens[0] = tokenIn; + gateway.updateSupportedTokens(tokens, true); + gateway.updateSupportedSwapRouter(router, router, true); + vm.stopPrank(); + + // router pre-funded with USDC so it can pay out swap proceeds + deal(address(usdc), address(swapRouter), 1_000_000e6); + + uint256 routerUsdcBefore = usdc.balanceOf(address(swapRouter)); + uint256 gatewayUsdcBefore = usdc.balanceOf(address(gateway)); + uint256 l1UsdcBefore = usdc.balanceOf(address(l1Gateway)); + + uint256 swapAmount = 400e6; + address user = address(0xCAFE); + + // give user the input token and approve the gateway + deal(tokenIn, user, swapAmount); + vm.startPrank(user); + IERC20(tokenIn).approve(address(gateway), type(uint256).max); + bytes memory swapData = abi.encodeWithSelector( + MockSwapRouter.swapToUSDC.selector, + swapAmount, + tokenIn, + address(usdc) + ); + gateway.transferToken( + tokenIn, + swapAmount, + router, + swapData, + usxReceiver, + usdcReceiver + ); + vm.stopPrank(); + + // check deposit parameters + ( + address depToken, + bytes memory depTo, + uint256 depAmount, + , + , + , + + ) = l1Gateway.lastDeposit(); + uint256 expectedFee = (swapAmount * gateway.feePercentage()) / 1e18; + if (expectedFee > gateway.maxFeeAmount()) { + expectedFee = gateway.maxFeeAmount(); + } + uint256 expectedNet = swapAmount - expectedFee; + assertEq(depAmount, expectedNet); + assertEq(depTo, usdcReceiver.receiver); + assertEq(depToken, address(usdc)); + + // balances: + // - router: loses USDC equal to swapAmount, gains input token + // - gateway: gains USDC fee, does not hold input token + // - L1 gateway: gains net bridged USDC + // - user: loses the input token + assertEq( + usdc.balanceOf(address(swapRouter)), + routerUsdcBefore - swapAmount + ); + assertEq( + usdc.balanceOf(address(gateway)), + gatewayUsdcBefore + expectedFee + ); + assertEq( + usdc.balanceOf(address(l1Gateway)), + l1UsdcBefore + expectedNet + ); + assertEq(IERC20(tokenIn).balanceOf(user), 0); + assertEq(IERC20(tokenIn).balanceOf(address(gateway)), 0); + } + + function test_transferToken_reverts_when_swap_fails() public { + (uint256 keyId, bytes memory keyBytes) = _registerKey(); + + PrivateGatewayScroll.EncryptedReceiver + memory usxReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: keyBytes, + keyId: keyId + }); + PrivateGatewayScroll.EncryptedReceiver + memory usdcReceiver = PrivateGatewayScroll.EncryptedReceiver({ + receiver: hex"66", + keyId: keyId + }); + + address tokenIn = address(usx); + address router = address(swapRouterReverting); + + vm.startPrank(admin); + address[] memory tokens = new address[](1); + tokens[0] = tokenIn; + gateway.updateSupportedTokens(tokens, true); + gateway.updateSupportedSwapRouter(router, router, true); + vm.stopPrank(); + + uint256 swapAmount = 400e6; + address user = address(0xBADD); + + deal(tokenIn, user, swapAmount); + vm.startPrank(user); + IERC20(tokenIn).approve(address(gateway), type(uint256).max); + bytes memory swapData = abi.encodeWithSelector( + MockSwapRouterReverting.swapToUSDC.selector, + swapAmount, + tokenIn, + address(usdc) + ); + vm.expectRevert(PrivateGatewayScroll.ErrorSwapFailed.selector); + gateway.transferToken( + tokenIn, + swapAmount, + router, + swapData, + usxReceiver, + usdcReceiver + ); + vm.stopPrank(); + } +} diff --git a/test/cloak/USXRebalancer.t.sol b/test/cloak/USXRebalancer.t.sol new file mode 100644 index 0000000..0413bac --- /dev/null +++ b/test/cloak/USXRebalancer.t.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {USXRebalancer} from "../../src/cloak/USXRebalancer.sol"; +import {MockUSDC} from "../mocks/MockUSDC.sol"; +import {MockScrollMessengerValidium, MockL1ERC20GatewayValidium, MockSwapRouterReverting} from "../mocks/MockScrollValidiumInfra.sol"; +import {MockUSX, MockUSXSwapRouter} from "../mocks/MockUSXInfra.sol"; + +contract USXRebalancerTest is Test { + MockUSDC internal usdc; + MockUSX internal usx; + + MockScrollMessengerValidium internal messenger; + MockL1ERC20GatewayValidium internal l1Gateway; + + MockUSXSwapRouter internal swapRouter; + MockSwapRouterReverting internal revertingRouter; + + USXRebalancer internal rebalancer; + + address internal admin = address(0xA11CE); + address internal rebalancerOperator = address(0xBEEF); + + uint256 internal constant GAS_LIMIT = 1_000_000; + + function setUp() public { + usdc = new MockUSDC(); + usx = new MockUSX(); + + messenger = new MockScrollMessengerValidium(); + l1Gateway = new MockL1ERC20GatewayValidium(address(messenger)); + + USXRebalancer impl = new USXRebalancer( + address(usdc), + address(usx), + address(l1Gateway) + ); + bytes memory data = abi.encodeCall(USXRebalancer.initialize, (admin)); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), data); + rebalancer = USXRebalancer(payable(address(proxy))); + + bytes32 rebalanceRole = rebalancer.REBALANCE_ROLE(); + vm.prank(admin); + rebalancer.grantRole(rebalanceRole, rebalancerOperator); + + swapRouter = new MockUSXSwapRouter(); + revertingRouter = new MockSwapRouterReverting(); + + vm.startPrank(admin); + rebalancer.updateSupportedSwapRouter( + address(swapRouter), + address(swapRouter), + true + ); + rebalancer.updateSupportedSwapRouter( + address(revertingRouter), + address(revertingRouter), + true + ); + vm.stopPrank(); + } + + function _encryptedReceiver() + internal + pure + returns (USXRebalancer.EncryptedReceiver memory) + { + return USXRebalancer.EncryptedReceiver({receiver: hex"aa", keyId: 1}); + } + + function test_rebalanceByMinting_reverts_when_caller_not_authorized() + public + { + uint256 amountUSDC = 100e6; + + USXRebalancer.EncryptedReceiver memory receiver = _encryptedReceiver(); + + vm.expectRevert(); + rebalancer.rebalanceByMinting(amountUSDC, receiver); + } + + function test_rebalanceByMinting_reverts_when_insufficient_usdc() public { + uint256 amountUSDC = 100e6; + USXRebalancer.EncryptedReceiver memory receiver = _encryptedReceiver(); + + vm.prank(rebalancerOperator); + vm.expectRevert(USXRebalancer.ErrorInsufficientUSDCBalance.selector); + rebalancer.rebalanceByMinting(amountUSDC, receiver); + } + + function test_rebalanceByMinting_happy_path_mints_and_deposits() public { + uint256 amountUSDC = 100e6; + USXRebalancer.EncryptedReceiver memory receiver = _encryptedReceiver(); + vm.prank(admin); + rebalancer.updateExpectedUSXReceiver(receiver); + + // fund the rebalancer with USDC + deal(address(usdc), address(rebalancer), amountUSDC); + + uint256 l1UsxBefore = usx.balanceOf(address(l1Gateway)); + + vm.prank(rebalancerOperator); + rebalancer.rebalanceByMinting(amountUSDC, receiver); + + ( + address depToken, + bytes memory depTo, + uint256 depAmount, + uint256 depGasLimit, + uint256 depKeyId, + uint256 depValue, + address depFrom + ) = l1Gateway.lastDeposit(); + + // MockUSX mints 1:1 with USDC amount + uint256 expectedMinted = amountUSDC * 10 ** 12; + + assertEq(depToken, address(usx)); + assertEq(depTo, receiver.receiver); + assertEq(depAmount, expectedMinted); + assertEq(depGasLimit, GAS_LIMIT); + assertEq(depKeyId, receiver.keyId); + assertEq(depValue, 0); + assertEq(depFrom, address(rebalancer)); + + assertEq( + usx.balanceOf(address(l1Gateway)), + l1UsxBefore + expectedMinted + ); + // all minted USX should have been bridged out + assertEq(usx.balanceOf(address(rebalancer)), 0); + } + + function test_rebalanceBySwapping_reverts_when_insufficient_usdc() public { + uint256 amountUSDC = 100e6; + USXRebalancer.EncryptedReceiver memory receiver = _encryptedReceiver(); + + bytes memory swapData = abi.encodeWithSelector( + MockUSXSwapRouter.swapToUSX.selector, + amountUSDC, + address(usdc), + address(usx), + uint256(1e12) + ); + + vm.expectRevert(USXRebalancer.ErrorInsufficientUSDCBalance.selector); + rebalancer.rebalanceBySwapping( + amountUSDC, + address(swapRouter), + swapData, + receiver + ); + } + + function test_rebalanceBySwapping_reverts_when_swap_fails() public { + uint256 amountUSDC = 100e6; + USXRebalancer.EncryptedReceiver memory receiver = _encryptedReceiver(); + + // fund the rebalancer with USDC + deal(address(usdc), address(rebalancer), amountUSDC); + + bytes memory swapData = abi.encodeWithSelector( + MockSwapRouterReverting.swapToUSDC.selector, + amountUSDC, + address(usdc), + address(usx) + ); + + vm.expectRevert(USXRebalancer.ErrorSwapFailed.selector); + rebalancer.rebalanceBySwapping( + amountUSDC, + address(revertingRouter), + swapData, + receiver + ); + } + + function test_rebalanceBySwapping_reverts_when_insufficient_usx_amount() + public + { + uint256 amountUSDC = 100e6; + USXRebalancer.EncryptedReceiver memory receiver = _encryptedReceiver(); + + // fund the rebalancer with USDC + deal(address(usdc), address(rebalancer), amountUSDC); + // fund the router with some USX but with a low rate so that the + // received USX is less than amountUSDC * 1e12 + uint256 lowRate = 1e11; + deal(address(usx), address(swapRouter), amountUSDC * lowRate); + + bytes memory swapData = abi.encodeWithSelector( + MockUSXSwapRouter.swapToUSX.selector, + amountUSDC, + address(usdc), + address(usx), + lowRate + ); + + vm.expectRevert(USXRebalancer.ErrorInsufficientUSXAmount.selector); + rebalancer.rebalanceBySwapping( + amountUSDC, + address(swapRouter), + swapData, + receiver + ); + } + + function test_rebalanceBySwapping_happy_path_swaps_and_deposits() public { + uint256 amountUSDC = 200e6; + USXRebalancer.EncryptedReceiver memory receiver = _encryptedReceiver(); + vm.prank(admin); + rebalancer.updateExpectedUSXReceiver(receiver); + + // fund the rebalancer with USDC and the router with enough USX + deal(address(usdc), address(rebalancer), amountUSDC); + uint256 rate = 1e12; + uint256 usxOut = amountUSDC * rate; + deal(address(usx), address(swapRouter), usxOut * 2); + + uint256 l1UsxBefore = usx.balanceOf(address(l1Gateway)); + + bytes memory swapData = abi.encodeWithSelector( + MockUSXSwapRouter.swapToUSX.selector, + amountUSDC, + address(usdc), + address(usx), + rate + ); + + rebalancer.rebalanceBySwapping( + amountUSDC, + address(swapRouter), + swapData, + receiver + ); + + ( + address depToken, + bytes memory depTo, + uint256 depAmount, + uint256 depGasLimit, + uint256 depKeyId, + uint256 depValue, + address depFrom + ) = l1Gateway.lastDeposit(); + + assertEq(depToken, address(usx)); + assertEq(depTo, receiver.receiver); + assertEq(depAmount, usxOut); + assertEq(depGasLimit, GAS_LIMIT); + assertEq(depKeyId, receiver.keyId); + assertEq(depValue, 0); + assertEq(depFrom, address(rebalancer)); + + assertEq(usx.balanceOf(address(rebalancer)), 0); + assertEq(usx.balanceOf(address(l1Gateway)), l1UsxBefore + usxOut); + } + + function test_withdrawTokens_erc20_and_eth_paths() public { + address receiver = address(0xB0B); + + deal(address(usdc), address(rebalancer), 1_000e6); + vm.prank(admin); + rebalancer.withdrawTokens(address(usdc), receiver, 200e6); + assertEq(usdc.balanceOf(receiver), 200e6); + + vm.deal(address(rebalancer), 1 ether); + uint256 beforeBal = receiver.balance; + vm.prank(admin); + rebalancer.withdrawTokens(address(0), receiver, 0.5 ether); + assertEq(receiver.balance, beforeBal + 0.5 ether); + } + + function test_withdrawTokens_reverts_when_caller_not_admin() public { + address receiver = address(0xB0B); + + deal(address(usdc), address(rebalancer), 1_000e6); + + vm.expectRevert(); + rebalancer.withdrawTokens(address(usdc), receiver, 200e6); + } + + function test_getSupportedSwapRouters_after_updates() public { + address router2 = address(0x1234); + + vm.startPrank(admin); + rebalancer.updateSupportedSwapRouter(router2, router2, true); + rebalancer.updateSupportedSwapRouter( + address(swapRouter), + address(0), + false + ); + vm.stopPrank(); + + address[] memory routers = rebalancer.getSupportedSwapRouters(); + assertEq(routers.length, 2); + } +} diff --git a/test/mocks/MockScrollValidiumInfra.sol b/test/mocks/MockScrollValidiumInfra.sol new file mode 100644 index 0000000..05a9a82 --- /dev/null +++ b/test/mocks/MockScrollValidiumInfra.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {IL1ERC20GatewayValidium} from "../../src/cloak/IL1ERC20GatewayValidium.sol"; +import {IL2ERC20GatewayValidium} from "../../src/cloak/IL2ERC20GatewayValidium.sol"; +import {IScrollMessengerValidium} from "../../src/cloak/IScrollMessengerValidium.sol"; + +contract MockScrollMessengerValidium is IScrollMessengerValidium { + address private _xDomainMessageSender; + + struct Message { + address target; + uint256 value; + bytes message; + uint256 gasLimit; + address from; + uint256 msgValue; + } + + Message public lastMessage; + uint256 public messagesSent; + + function setXDomainMessageSender(address sender) external { + _xDomainMessageSender = sender; + } + + function xDomainMessageSender() external view returns (address) { + return _xDomainMessageSender; + } + + function sendMessage( + address target, + uint256 value, + bytes calldata message, + uint256 gasLimit + ) external payable { + lastMessage = Message({ + target: target, + value: value, + message: message, + gasLimit: gasLimit, + from: msg.sender, + msgValue: msg.value + }); + messagesSent++; + } +} + +contract MockL1ERC20GatewayValidium is IL1ERC20GatewayValidium { + address public override messenger; + + struct Deposit { + address token; + bytes to; + uint256 amount; + uint256 gasLimit; + uint256 keyId; + uint256 value; + address from; + } + + Deposit public lastDeposit; + uint256 public deposits; + + constructor(address _messenger) { + messenger = _messenger; + } + + function setMessenger(address _messenger) external { + messenger = _messenger; + } + + function depositERC20( + address _token, + bytes memory _to, + uint256 _amount, + uint256 _gasLimit, + uint256 _keyId + ) external payable { + // Simulate locking tokens in the gateway before bridging. + IERC20(_token).transferFrom(msg.sender, address(this), _amount); + + lastDeposit = Deposit({ + token: _token, + to: _to, + amount: _amount, + gasLimit: _gasLimit, + keyId: _keyId, + value: msg.value, + from: msg.sender + }); + deposits++; + } +} + +contract MockL2ERC20GatewayValidium is IL2ERC20GatewayValidium { + address public override messenger; + + struct Withdrawal { + address token; + address to; + uint256 amount; + uint256 gasLimit; + uint256 value; + address from; + } + + Withdrawal public lastWithdrawal; + uint256 public withdrawals; + + constructor(address _messenger) { + messenger = _messenger; + } + + function setMessenger(address _messenger) external { + messenger = _messenger; + } + + function withdrawERC20( + address token, + address to, + uint256 amount, + uint256 gasLimit + ) external payable { + // Simulate locking tokens in the L2 gateway before bridging to L1. + IERC20(token).transferFrom(msg.sender, address(this), amount); + + lastWithdrawal = Withdrawal({ + token: token, + to: to, + amount: amount, + gasLimit: gasLimit, + value: msg.value, + from: msg.sender + }); + withdrawals++; + } +} + +contract MockSwapRouter { + function swapToUSDC( + uint256 amountIn, + address tokenIn, + address usdc + ) external payable { + // For ERC20 swaps, pull the input token from the caller (gateway) + if (tokenIn != address(0)) { + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + } + + // Assumes the router already holds enough USDC balance. + IERC20(usdc).transfer(msg.sender, amountIn); + } +} + +contract MockSwapRouterReverting { + function swapToUSDC( + uint256, + address, + address + ) external payable { + revert("swap failed"); + } +} diff --git a/test/mocks/MockUSXInfra.sol b/test/mocks/MockUSXInfra.sol new file mode 100644 index 0000000..0502dba --- /dev/null +++ b/test/mocks/MockUSXInfra.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {IUSX} from "../../src/interfaces/IUSX.sol"; + +contract MockUSX is ERC20, IUSX { + bool private _paused; + address private _governance; + + constructor() ERC20("USX", "USX") { + _governance = msg.sender; + } + + function mintUSX(address to, uint256 amount) external override { + _mint(to, amount); + } + + function burnUSX(address from, uint256 amount) external override { + _burn(from, amount); + } + + function deposit(uint256 amount) external override { + _mint(msg.sender, amount * 10**12); + } + + function claimUSDC() external override {} + + function requestUSDC(uint256 amount) external {} + + function pause() external override { + _paused = true; + } + + function unpause() external override { + _paused = false; + } + + function paused() external view override returns (bool) { + return _paused; + } + + function governance() external view override returns (address) { + return _governance; + } + + function updateTotalMatchedWithdrawalAmount() external override {} + + function totalOutstandingWithdrawalAmount() + external + pure + override + returns (uint256) + { + return 0; + } + + function totalMatchedWithdrawalAmount() + external + pure + override + returns (uint256) + { + return 0; + } +} + +contract MockUSXSwapRouter { + function swapToUSX( + uint256 amountIn, + address usdc, + address usx, + uint256 rate + ) external payable { + IERC20(usdc).transferFrom(msg.sender, address(this), amountIn); + IERC20(usx).transfer(msg.sender, amountIn * rate); + } +} +