From 25eee39955f3ea80a5ff76b3f3be2a793debff8b Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Sat, 7 Sep 2024 14:19:08 +0000 Subject: [PATCH] feat: prevent misconfig of supply and tvl limits The objective of this change is to prevent deposit failures by ensuring that the tvlLimit (as enforced on the client chain) <= totalSupply (as enforced by Exocore). Note that, with time, the totalSupply stored on Exocore may become outdated as well; however, it is a secondary check which occurs only after tokens are transferred into the Vault on the client chain. Since the addition of tokens to the whitelist happens through Exocore, it can be enforced easily that the tvlLimit of said token is less than the total supply (as known to Exocore). Whenever a transaction to update the tvlLimit on a client chain is received, it is handled on a case-by-case basis. - A decrease in tvl limit is always allowed. - An increase in tvl limit is forwarded to Exocore for validation. Exocore receives this validation request, and responds in the affirmative if the new tvl limit <= supply of the token (again, according to Exocore). It responds in the negative, otherwise. Reciprocally, whenever an attempt is made to change the recorded value of total supply corresponding to a token on Exocore, here's what happens. - An increase in the total supply is always allowed. - A decrease in the total supply is forwarded to the client chain for validation. when the client chain receives this validation request, it responds in the affirmative if the tvl limit <= new total supply (which may be totally different from the actual total supply due to changes in the number when the message was in-flight). Otherwise, it responds in the negative. One caveat for both of these validations is that if a validation request arising from the validating chain is already in-flight, the validation is rejected. This is to ensure that in-flight messages cannot result in a misconfiguration. For example, if the TVL limit is increased from 400m to 500m and the total supply is 600m, it will be approved. However, if, at the same time, there is a validation request sent to the client chain by Exocore to verify if the total supply can be reduced to 450m, it could cause inconsistency. In this event, the TVL limit increase is rejected and the total supply decrease will be approved. --- script/3_Setup.s.sol | 4 +- src/core/BaseRestakingController.sol | 18 +- src/core/Bootstrap.sol | 26 +- src/core/ClientChainGateway.sol | 49 ++- src/core/ClientGatewayLzReceiver.sol | 50 ++- src/core/ExocoreGateway.sol | 123 ++++++- src/core/Vault.sol | 5 +- src/interfaces/IExocoreGateway.sol | 15 +- src/interfaces/ITokenWhitelister.sol | 10 +- src/interfaces/IVault.sol | 2 + src/interfaces/precompiles/IAssets.sol | 10 + src/libraries/Errors.sol | 6 + src/storage/BootstrapStorage.sol | 21 -- src/storage/ClientChainGatewayStorage.sol | 5 + src/storage/ExocoreGatewayStorage.sol | 26 ++ src/storage/GatewayStorage.sol | 6 + src/storage/VaultStorage.sol | 4 + test/foundry/Delegation.t.sol | 68 ++-- test/foundry/DepositThenDelegateTo.t.sol | 87 +++-- test/foundry/DepositWithdrawPrinciple.t.sol | 369 ++++++++++++++------ test/foundry/ExocoreDeployer.t.sol | 46 ++- test/foundry/TvlLimits.t.sol | 265 ++++++++++++-- test/foundry/WithdrawReward.t.sol | 33 +- test/foundry/unit/Bootstrap.t.sol | 79 ++--- test/foundry/unit/ExocoreGateway.t.sol | 2 + test/mocks/AssetsMock.sol | 20 +- test/mocks/ExocoreGatewayMock.sol | 108 +++++- 27 files changed, 1099 insertions(+), 358 deletions(-) diff --git a/script/3_Setup.s.sol b/script/3_Setup.s.sol index abb0095c..35005498 100644 --- a/script/3_Setup.s.sol +++ b/script/3_Setup.s.sol @@ -107,12 +107,12 @@ contract SetupScript is BaseScript { names[0] = "RestakeToken"; metaDatas[0] = "ERC20 LST token"; oracleInfos[0] = "{'a': 'b'}"; - tvlLimits[0] = restakeToken.totalSupply() / 5; // in phases of 20% + tvlLimits[0] = supplies[0] / 5; // in phases of 20% // this stands for Native Restaking for ETH whitelistTokensBytes32[1] = bytes32(bytes20(VIRTUAL_STAKED_ETH_ADDRESS)); decimals[1] = 18; - supplies[1] = type(uint256).max; // no supply limit for native restaking + supplies[1] = 0; // irrelevant for native restaking names[1] = "StakedETH"; metaDatas[1] = "natively staked ETH on Ethereum"; oracleInfos[1] = "{'b': 'a'}"; diff --git a/src/core/BaseRestakingController.sol b/src/core/BaseRestakingController.sol index c9c1555d..0b25e5d9 100644 --- a/src/core/BaseRestakingController.sol +++ b/src/core/BaseRestakingController.sol @@ -89,23 +89,31 @@ abstract contract BaseRestakingController is /// @param encodedRequest The encoded request. function _processRequest(Action action, bytes memory actionArgs, bytes memory encodedRequest) internal { uint64 requestNonce = _sendMsgToExocore(action, actionArgs); - - _registeredRequests[requestNonce] = encodedRequest; - _registeredRequestActions[requestNonce] = action; + if (action != Action.RESPOND) { + _registeredRequests[requestNonce] = encodedRequest; + _registeredRequestActions[requestNonce] = action; + } } /// @dev Sends a message to Exocore. /// @param action The action to be performed. /// @param actionArgs The encodePacked arguments for the action. function _sendMsgToExocore(Action action, bytes memory actionArgs) internal returns (uint64) { + bool payByApp = action == Action.RESPOND; bytes memory payload = abi.encodePacked(action, actionArgs); bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption( DESTINATION_GAS_LIMIT, DESTINATION_MSG_VALUE ).addExecutorOrderedExecutionOption(); MessagingFee memory fee = _quote(EXOCORE_CHAIN_ID, payload, options, false); - MessagingReceipt memory receipt = - _lzSend(EXOCORE_CHAIN_ID, payload, options, MessagingFee(fee.nativeFee, 0), msg.sender, false); + MessagingReceipt memory receipt = _lzSend( + EXOCORE_CHAIN_ID, + payload, + options, + MessagingFee(fee.nativeFee, 0), + payByApp ? address(this) : msg.sender, + payByApp + ); emit MessageSent(action, receipt.guid, receipt.nonce, receipt.fee.nativeFee); return receipt.nonce; diff --git a/src/core/Bootstrap.sol b/src/core/Bootstrap.sol index 31474c0f..3cb63a8a 100644 --- a/src/core/Bootstrap.sol +++ b/src/core/Bootstrap.sol @@ -206,7 +206,6 @@ contract Bootstrap is if (isWhitelistedToken[token]) { revert Errors.BootstrapAlreadyWhitelisted(token); } - whitelistTokens.push(token); isWhitelistedToken[token] = true; @@ -215,6 +214,11 @@ contract Bootstrap is // pre-existing vault. however, we still do ensure that the vault is not deployed // for restaking natively staked ETH. if (token != VIRTUAL_STAKED_ETH_ADDRESS) { + // during bootstrap phase, ensure that the tvl limit <= total supply of the token + uint256 totalSupply = ERC20(token).totalSupply(); + if (tvlLimit > totalSupply) { + revert Errors.BootstrapTvlLimitExceedsTotalSupply(); + } _deployVault(token, tvlLimit); } @@ -228,13 +232,19 @@ contract Bootstrap is } /// @inheritdoc ITokenWhitelister - function updateTvlLimits(address[] calldata tokens, uint256[] calldata tvlLimits) - external - beforeLocked - onlyOwner - whenNotPaused - { - _updateTvlLimits(tokens, tvlLimits); + function updateTvlLimit(address token, uint256 tvlLimit) external payable beforeLocked onlyOwner whenNotPaused { + if (msg.value > 0) { + revert Errors.NonZeroValue(); + } + if (!isWhitelistedToken[token]) { + revert Errors.TokenNotWhitelisted(token); + } + if (token == VIRTUAL_STAKED_ETH_ADDRESS) { + revert Errors.NoTvlLimitForNativeRestaking(); + } + IVault vault = _getVault(token); + // no checks for Bootstrap phase + vault.setTvlLimit(tvlLimit); } /// @inheritdoc IValidatorRegistry diff --git a/src/core/ClientChainGateway.sol b/src/core/ClientChainGateway.sol index 5b49ec0a..8737d1cd 100644 --- a/src/core/ClientChainGateway.sol +++ b/src/core/ClientChainGateway.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.19; import {IClientChainGateway} from "../interfaces/IClientChainGateway.sol"; import {ITokenWhitelister} from "../interfaces/ITokenWhitelister.sol"; +import {IVault} from "../interfaces/IVault.sol"; + import {OAppCoreUpgradeable} from "../lzApp/OAppCoreUpgradeable.sol"; import {OAppReceiverUpgradeable} from "../lzApp/OAppReceiverUpgradeable.sol"; import {MessagingFee, OAppSenderUpgradeable} from "../lzApp/OAppSenderUpgradeable.sol"; @@ -73,6 +75,7 @@ contract ClientChainGateway is _whiteListFunctionSelectors[Action.REQUEST_ADD_WHITELIST_TOKEN] = this.afterReceiveAddWhitelistTokenRequest.selector; + _whiteListFunctionSelectors[Action.REQUEST_VALIDATE_LIMITS] = this.afterReceiveValidateLimitsRequest.selector; // overwrite the bootstrap function selector _whiteListFunctionSelectors[Action.REQUEST_MARK_BOOTSTRAP] = this.afterReceiveMarkBootstrapRequest.selector; @@ -121,12 +124,32 @@ contract ClientChainGateway is } /// @inheritdoc ITokenWhitelister - function updateTvlLimits(address[] calldata tokens, uint256[] calldata tvlLimits) - external - onlyOwner - whenNotPaused - { - _updateTvlLimits(tokens, tvlLimits); + function updateTvlLimit(address token, uint256 tvlLimit) external payable onlyOwner whenNotPaused { + if (!isWhitelistedToken[token]) { + // grave error, should never happen + revert Errors.TokenNotWhitelisted(token); + } + if (token == VIRTUAL_STAKED_ETH_ADDRESS) { + // not possible to set a TVL limit for native restaking + revert Errors.NoTvlLimitForNativeRestaking(); + } + IVault vault = _getVault(token); + uint256 previousLimit = vault.getTvlLimit(); + if (tvlLimit <= previousLimit) { + // reduction of TVL limit is always allowed. + if (msg.value > 0) { + revert Errors.NonZeroValue(); + } + vault.setTvlLimit(tvlLimit); + } else { + // queue message to Exocore for validation that the tvlLimit <= totalSupply. + // to remove a configured tvl limit, set it (close to) the total supply (as on Exocore). + // note that, since each message has an increasing nonce, multiple tvl updates may be in flight at once. + // they will be processed in order. + bytes memory actionArgs = abi.encodePacked(bytes32(bytes20(token)), tvlLimit); + bytes memory encodedRequest = abi.encode(token, tvlLimit); + _processRequest(Action.REQUEST_VALIDATE_LIMITS, actionArgs, encodedRequest); + } } /// @inheritdoc IClientChainGateway @@ -149,4 +172,18 @@ contract ClientChainGateway is return (SENDER_VERSION, RECEIVER_VERSION); } + /// @notice Called after a validate-limits request is received. + /// @dev It checks that the proposed total supply >= tvlLimit. + function afterReceiveValidateLimitsRequest(uint64 lzNonce, bytes calldata requestPayload) + public + onlyCalledFromThis + whenNotPaused + { + (address token, uint256 newSupply) = _decodeTokenUint256(requestPayload, false); + IVault vault = _getVault(token); + uint256 tvlLimit = vault.getTvlLimit(); + bool success = tvlLimit <= newSupply && !tvlLimitIncreaseInFlight[token]; + _sendMsgToExocore(Action.RESPOND, abi.encodePacked(lzNonce, success)); + } + } diff --git a/src/core/ClientGatewayLzReceiver.sol b/src/core/ClientGatewayLzReceiver.sol index 892b4c99..2c753775 100644 --- a/src/core/ClientGatewayLzReceiver.sol +++ b/src/core/ClientGatewayLzReceiver.sol @@ -67,11 +67,12 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp } (bool success, bytes memory reason) = - address(this).call(abi.encodePacked(selector_, abi.encode(payload[1:]))); + address(this).call(abi.encodePacked(selector_, abi.encode(_origin.nonce, payload[1:]))); if (!success) { revert RequestOrResponseExecuteFailed(act, _origin.nonce, reason); } } + emit MessageExecuted(act, _origin.nonce); } /// @inheritdoc OAppReceiverUpgradeable @@ -100,6 +101,16 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp if (_expectBasicResponse(requestAct)) { success = _decodeBasicResponse(response); + if (requestAct == Action.REQUEST_VALIDATE_LIMITS) { + (address token,,, uint256 tvlLimit) = _decodeCachedRequest(requestAct, cachedRequest); + if (success) { + // since the request was sent by us, the token can never be native restaking and must be whitelisted + IVault vault = _getVault(token); + vault.setTvlLimit(tvlLimit); + } + // remove the lock regardless of success + tvlLimitIncreaseInFlight[token] = false; + } } else if (_expectBalanceResponse(requestAct)) { (address token, address staker,, uint256 amount) = _decodeCachedRequest(requestAct, cachedRequest); (success, updatedBalance) = _decodeBalanceResponse(response); @@ -177,7 +188,8 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp // Basic response only includes request execution status, no other informations like balance update // and it is typically the response of a staking only operations. function _expectBasicResponse(Action action) internal pure returns (bool) { - return action == Action.REQUEST_DELEGATE_TO || action == Action.REQUEST_UNDELEGATE_FROM; + return action == Action.REQUEST_DELEGATE_TO || action == Action.REQUEST_UNDELEGATE_FROM + || action == Action.REQUEST_VALIDATE_LIMITS; } /// @dev Checks if the action is a balance response. @@ -229,6 +241,8 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp (token, staker, amount) = abi.decode(cachedRequest, (address, address, uint256)); } else if (_isStakingOperationRequest(requestAct)) { (token, staker, operator, amount) = abi.decode(cachedRequest, (address, address, string, uint256)); + } else if (requestAct == Action.REQUEST_VALIDATE_LIMITS) { + (token, amount) = abi.decode(cachedRequest, (address, uint256)); } else { revert UnsupportedRequest(requestAct); } @@ -296,20 +310,12 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp // `Vault` contract belongs to Exocore and we could make sure its implementation does not have dangerous behavior // like reentrancy. // slither-disable-next-line reentrancy-no-eth - function afterReceiveAddWhitelistTokenRequest(bytes calldata requestPayload) + function afterReceiveAddWhitelistTokenRequest(uint64, bytes calldata requestPayload) public onlyCalledFromThis whenNotPaused { - (bytes32 tokenAsBytes32, uint256 tvlLimit) = abi.decode(requestPayload, (bytes32, uint256)); - address token = address(bytes20(tokenAsBytes32)); - if (token == address(0)) { - revert Errors.ZeroAddress(); - } - if (isWhitelistedToken[token]) { - // grave error, should never happen - revert Errors.ClientChainGatewayAlreadyWhitelisted(token); - } + (address token, uint256 tvlLimit) = _decodeTokenUint256(requestPayload, true); isWhitelistedToken[token] = true; whitelistTokens.push(token); // since tokens cannot be removed from the whitelist, it is not possible for a vault @@ -325,8 +331,26 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp /// @dev Since the contract is already bootstrapped (if we are here), there is nothing to do. /// @dev Failing this, however, will cause a nonce mismatch resulting in a system halt. /// Hence, we silently ignore this call. - function afterReceiveMarkBootstrapRequest() public onlyCalledFromThis whenNotPaused { + function afterReceiveMarkBootstrapRequest(uint64) public onlyCalledFromThis whenNotPaused { emit BootstrappedAlready(); } + function _decodeTokenUint256(bytes calldata payload, bool failIfWhitelisted) + internal + view + returns (address, uint256) + { + (bytes32 tokenAsBytes32, uint256 value) = abi.decode(payload, (bytes32, uint256)); + address token = address(bytes20(tokenAsBytes32)); + if (token == address(0)) { + // cannot happen since ExocoreGateway checks for this + revert Errors.ZeroAddress(); + } + if ((failIfWhitelisted) && (isWhitelistedToken[token])) { + // grave error, should never happen + revert Errors.ClientChainGatewayAlreadyWhitelisted(token); + } + return (token, value); + } + } diff --git a/src/core/ExocoreGateway.sol b/src/core/ExocoreGateway.sol index 8bc01cbb..afa5aafa 100644 --- a/src/core/ExocoreGateway.sol +++ b/src/core/ExocoreGateway.sol @@ -86,6 +86,7 @@ contract ExocoreGateway is this.requestAssociateOperatorWithStaker.selector; _whiteListFunctionSelectors[Action.REQUEST_DISSOCIATE_OPERATOR] = this.requestDissociateOperatorFromStaker.selector; + _whiteListFunctionSelectors[Action.REQUEST_VALIDATE_LIMITS] = this.requestValidateLimits.selector; } /// @notice Pauses the contract. @@ -184,6 +185,7 @@ contract ExocoreGateway is require(bytes(name).length != 0, "ExocoreGateway: name cannot be empty"); require(bytes(metaData).length != 0, "ExocoreGateway: meta data cannot be empty"); require(bytes(oracleInfo).length != 0, "ExocoreGateway: oracleInfo cannot be empty"); + require(totalSupply >= tvlLimit, "ExocoreGateway: total supply should be greater than or equal to TVL limit"); // setting a TVL limit of 0 is permitted to simply add an inactive token, which may // be activated later by updating the TVL limit on the client chain @@ -209,22 +211,58 @@ contract ExocoreGateway is /// @inheritdoc IExocoreGateway function updateWhitelistToken(uint32 clientChainId, bytes32 token, uint256 totalSupply, string calldata metaData) external + payable onlyOwner whenNotPaused nonReentrant { require(clientChainId != 0, "ExocoreGateway: client chain id cannot be zero"); require(token != bytes32(0), "ExocoreGateway: token cannot be zero address"); - require(totalSupply > 0, "ExocoreGateway: total supply should not be zero"); + // it is possible to set total supply to 0 if the tvl limit on the client chain gateway is 0, and if there + // are no deposits at all. // empty metaData indicates that the token's metadata should not be updated - bool success = ASSETS_CONTRACT.updateToken(clientChainId, abi.encodePacked(token), totalSupply, metaData); - if (success) { - emit WhitelistTokenUpdated(clientChainId, token); + (bool success, uint256 previousSupply) = ASSETS_CONTRACT.getTotalSupply(clientChainId, abi.encodePacked(token)); + if (!success) { + // safe to revert since this is not an LZ message so far + revert FailedToGetTotalSupply(clientChainId, token); + } + if (totalSupply >= previousSupply) { + // supply increase is always permitted without any checks + if (msg.value > 0) { + revert Errors.NonZeroValue(); + } + success = ASSETS_CONTRACT.updateToken(clientChainId, abi.encodePacked(token), totalSupply, metaData); + if (success) { + emit WhitelistTokenUpdated(clientChainId, token); + } else { + revert UpdateWhitelistTokenFailed(clientChainId, token); + } } else { - revert UpdateWhitelistTokenFailed(clientChainId, token); + require(bytes(metaData).length == 0, "ExocoreGateway: metadata should be empty for supply decrease"); + // supply decrease is only permitted if tvl limit <= total supply + supplyDecreaseInFlight[clientChainId][token] = true; + uint64 requestNonce = _sendInterchainMsg( + clientChainId, Action.REQUEST_VALIDATE_LIMITS, abi.encodePacked(token, totalSupply), false + ); + // there is only one type of outgoing request for which we expect a response so no need to store + // too much information + _registeredRequests[requestNonce] = abi.encode(token, totalSupply); } } + /// @inheritdoc IExocoreGateway + // This is just a helper function to get the total supply (as stored in Exocore's precompiles) of a token + // that lives on the client chain. Slither doesn't seem to appreciate that this function just forwards the call + // to the precompile, and hence, it thinks that the return value is unused. + // slither-disable-next-line unused-return + function getTotalSupply(uint32 clientChainId, bytes32 token) + external + view + returns (bool success, uint256 totalSupply) + { + return ASSETS_CONTRACT.getTotalSupply(clientChainId, abi.encodePacked(token)); + } + /** * @notice Associate an Exocore operator with an EVM staker(msg.sender), and this would count staker's delegation * as operator's self-delegation when staker delegates to operator. @@ -305,16 +343,74 @@ contract ExocoreGateway is _verifyAndUpdateNonce(_origin.srcEid, _origin.sender, _origin.nonce); Action act = Action(uint8(payload[0])); - bytes4 selector_ = _whiteListFunctionSelectors[act]; - if (selector_ == bytes4(0)) { - revert UnsupportedRequest(act); + if (act == Action.RESPOND) { + _handleResponse(_origin.srcEid, payload[1:]); + } else { + bytes4 selector_ = _whiteListFunctionSelectors[act]; + if (selector_ == bytes4(0)) { + revert UnsupportedRequest(act); + } + + (bool success, bytes memory responseOrReason) = + address(this).call(abi.encodePacked(selector_, abi.encode(_origin.srcEid, _origin.nonce, payload[1:]))); + if (!success) { + revert RequestExecuteFailed(act, _origin.nonce, responseOrReason); + } } - (bool success, bytes memory responseOrReason) = - address(this).call(abi.encodePacked(selector_, abi.encode(_origin.srcEid, _origin.nonce, payload[1:]))); - if (!success) { - revert RequestExecuteFailed(act, _origin.nonce, responseOrReason); + emit MessageExecuted(act, _origin.nonce); + } + + /// @dev Handles the response provided by the client chain to an outgoing LZ message. + /// @param response The response, without the action byte in the beginning. + // The only call made by this function is to a precompiled contract, and hence, there is no need to worry about + // reentrancy attacks. + // slither-disable-next-line reentrancy-no-eth + function _handleResponse(uint32 clientChainId, bytes calldata response) internal { + // only one type of response is supported + _validatePayloadLength(response, VALIDATE_LIMITS_RESPONSE_LENGTH, Action.RESPOND); + uint64 lzNonce = uint64(bytes8(response[0:8])); + (bytes32 token, uint256 totalSupply) = abi.decode(_registeredRequests[lzNonce], (bytes32, uint256)); + if (uint8(bytes1(response[8])) == 1) { + // the validation succeeded, so apply the edit to total supply + bool updated = ASSETS_CONTRACT.updateToken(clientChainId, abi.encodePacked(token), totalSupply, ""); + if (!updated) { + emit UpdateWhitelistTokenFailedOnResponse(clientChainId, token); + } else { + emit WhitelistTokenUpdated(clientChainId, token); + } + } else { + emit WhitelistTokenNotUpdated(clientChainId, token); } + delete _registeredRequests[lzNonce]; + supplyDecreaseInFlight[clientChainId][token] = false; + return; + } + + /// @notice Responds to a validate-limits request from a client chain. + /// @dev Can only be called from this contract via low-level call. + /// @param srcChainId The source chain id. + /// @param lzNonce The layer zero nonce. + /// @param payload The request payload. + function requestValidateLimits(uint32 srcChainId, uint64 lzNonce, bytes calldata payload) + public + onlyCalledFromThis + { + _validatePayloadLength(payload, VALIDATE_LIMITS_REQUEST_LENGTH, Action.REQUEST_VALIDATE_LIMITS); + + bytes memory token = payload[:32]; + uint256 tvlLimit = uint256(bytes32(payload[32:64])); + + (bool success, uint256 totalSupply) = ASSETS_CONTRACT.getTotalSupply(srcChainId, token); + + _sendInterchainMsg( + srcChainId, + Action.RESPOND, + abi.encodePacked( + lzNonce, success && tvlLimit <= totalSupply && !supplyDecreaseInFlight[srcChainId][bytes32(token)] + ), + true + ); } /// @notice Responds to a deposit request from a client chain. @@ -567,6 +663,7 @@ contract ExocoreGateway is function _sendInterchainMsg(uint32 srcChainId, Action act, bytes memory actionArgs, bool payByApp) internal whenNotPaused + returns (uint64) { bytes memory payload = abi.encodePacked(act, actionArgs); bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption( @@ -578,6 +675,8 @@ contract ExocoreGateway is MessagingReceipt memory receipt = _lzSend(srcChainId, payload, options, MessagingFee(fee.nativeFee, 0), refundAddress, payByApp); emit MessageSent(act, receipt.guid, receipt.nonce, receipt.fee.nativeFee); + + return receipt.nonce; } /// @inheritdoc IExocoreGateway diff --git a/src/core/Vault.sol b/src/core/Vault.sol index f3238fce..9c13edab 100644 --- a/src/core/Vault.sol +++ b/src/core/Vault.sol @@ -14,7 +14,7 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol /// @author ExocoreNetwork /// @notice Implementation of IVault, used to store user tokens. Each Vault is unique to an /// underlying token and is controlled by a gateway. -contract Vault is Initializable, VaultStorage, IVault { +contract Vault is Initializable, IVault, VaultStorage { using SafeERC20 for IERC20; @@ -75,6 +75,7 @@ contract Vault is Initializable, VaultStorage, IVault { underlyingToken.safeTransfer(recipient, amount); emit WithdrawalSuccess(withdrawer, recipient, amount); + emit ConsumedTvlChanged(consumedTvl); } /// @inheritdoc IVault @@ -92,6 +93,7 @@ contract Vault is Initializable, VaultStorage, IVault { // small proportion, and, the tvl limit is only for risk management. revert Errors.VaultTvlLimitExceeded(); } + emit ConsumedTvlChanged(consumedTvl); } /// @inheritdoc IVault @@ -129,6 +131,7 @@ contract Vault is Initializable, VaultStorage, IVault { } /// @inheritdoc IVault + // The caller must ensure that tvlLimit <= totalSupply as on Exocore. function setTvlLimit(uint256 tvlLimit_) external onlyGateway { tvlLimit = tvlLimit_; emit TvlLimitUpdated(tvlLimit); diff --git a/src/interfaces/IExocoreGateway.sol b/src/interfaces/IExocoreGateway.sol index 594befb8..c832a3cb 100644 --- a/src/interfaces/IExocoreGateway.sol +++ b/src/interfaces/IExocoreGateway.sol @@ -71,11 +71,22 @@ interface IExocoreGateway is IOAppReceiver, IOAppCore { /// @param totalSupply The new total supply of the token. /// @param metaData The new meta information of the token. /// @dev The token must exist in the whitelist before updating. - /// @dev Since this function does not send a cross chain message, it is not payable. + /// @dev Since this function may send a cross chain message to validate that totalSupply >= tvlLimit, it is payable. /// @dev The @param totalSupply should be set accurately, since that is the only point where deposits can fail and /// produce a system halt. function updateWhitelistToken(uint32 clientChainId, bytes32 token, uint256 totalSupply, string calldata metaData) - external; + external + payable; + + /// @dev Returns the token total supply for a given token on a client chain. + /// @return success true if the query is successful + /// @param clientChainId The LayerZero chain id of the client chain. + /// @param token The address of the token on the client chain as a bytes32. + /// @return totalSupply the total supply of the token + function getTotalSupply(uint32 clientChainId, bytes32 token) + external + view + returns (bool success, uint256 totalSupply); /// @notice Marks the network as bootstrapped, on the client chain. /// @dev Causes an upgrade of the Bootstrap contract to the ClientChainGateway contract. diff --git a/src/interfaces/ITokenWhitelister.sol b/src/interfaces/ITokenWhitelister.sol index 3c21fa87..63aead2f 100644 --- a/src/interfaces/ITokenWhitelister.sol +++ b/src/interfaces/ITokenWhitelister.sol @@ -16,10 +16,10 @@ interface ITokenWhitelister { /// @return The count of whitelisted tokens. function getWhitelistedTokensCount() external returns (uint256); - /// @notice Updates the TVL limits for a list of tokens. - /// @dev The tokens must be whitelisted before. - /// @param tokens The list of token addresses. - /// @param tvlLimits The list of corresponding TVL limits. - function updateTvlLimits(address[] calldata tokens, uint256[] calldata tvlLimits) external; + /// @notice Updates the TVL limit for a token. + /// @dev The token must be whitelisted before. + /// @param token The token address. + /// @param tvlLimit The new TVL limit for the token. + function updateTvlLimit(address token, uint256 tvlLimit) external payable; } diff --git a/src/interfaces/IVault.sol b/src/interfaces/IVault.sol index fcd23a9b..71b28472 100644 --- a/src/interfaces/IVault.sol +++ b/src/interfaces/IVault.sol @@ -48,10 +48,12 @@ interface IVault { /// @notice Gets the TVL limit for the vault. /// @return The TVL limit for the vault. + // This is a function so that IVault can be used in other contracts without importing the Vault contract. function getTvlLimit() external returns (uint256); /// @notice Gets the total value locked in the vault. /// @return The total value locked in the vault. + // This is a function so that IVault can be used in other contracts without importing the Vault contract. function getConsumedTvl() external returns (uint256); } diff --git a/src/interfaces/precompiles/IAssets.sol b/src/interfaces/precompiles/IAssets.sol index c5eaab60..1578a67c 100644 --- a/src/interfaces/precompiles/IAssets.sol +++ b/src/interfaces/precompiles/IAssets.sol @@ -101,4 +101,14 @@ interface IAssets { /// @return isRegistered true if the client chain is registered function isRegisteredClientChain(uint32 clientChainID) external view returns (bool success, bool isRegistered); + /// @dev Returns the token total supply for a given token on a client chain. + /// @param clientChainId The LayerZero chain id of the client chain. + /// @param token The address of the token on the client chain as a bytes32. + /// @return success true if the query is successful + /// @return totalSupply the total supply of the token + function getTotalSupply(uint32 clientChainId, bytes calldata token) + external + view + returns (bool success, uint256 totalSupply); + } diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 3d0e852c..3c897ba0 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -109,6 +109,9 @@ library Errors { /// @dev Bootstrap: validator name length is zero error BootstrapValidatorNameLengthZero(); + /// @dev Bootstrap: TVL limit exceeds total supply + error BootstrapTvlLimitExceedsTotalSupply(); + ////////////////////////////////// // BootstrapLzReceiver Errors // ////////////////////////////////// @@ -216,4 +219,7 @@ library Errors { /// @dev Vault: forbid to deploy vault for the virtual token address representing natively staked ETH error ForbidToDeployVault(); + /// @dev Vault TVL limit exceeds total supply + error VaultTvlLimitExceedsTotalSupply(); + } diff --git a/src/storage/BootstrapStorage.sol b/src/storage/BootstrapStorage.sol index cccbb160..623126d6 100644 --- a/src/storage/BootstrapStorage.sol +++ b/src/storage/BootstrapStorage.sol @@ -381,27 +381,6 @@ contract BootstrapStorage is GatewayStorage { return vault; } - /// @dev Internal version of updateTvlLimits; shared between Bootstrap and ClientChainGateway - /// @param tokens The list of token addresses for which the TVL is being updated. - /// @param tvlLimits The new TVL in the same order as the token addresses. - function _updateTvlLimits(address[] calldata tokens, uint256[] calldata tvlLimits) internal { - if (tokens.length != tvlLimits.length) { - revert Errors.ArrayLengthMismatch(); - } - for (uint256 i = 0; i < tokens.length; ++i) { - address token = tokens[i]; - uint256 tvlLimit = tvlLimits[i]; - if (!isWhitelistedToken[token]) { - revert Errors.TokenNotWhitelisted(token); - } - if (token == VIRTUAL_STAKED_ETH_ADDRESS) { - revert Errors.NoTvlLimitForNativeRestaking(); - } - IVault vault = _getVault(token); - vault.setTvlLimit(tvlLimit); - } - } - /// @dev Internal version of getWhitelistedTokensCount; shared between Bootstrap and ClientChainGateway /// @dev Looks a bit redundant because it is, but at least this way, the implementation is shared. function _getWhitelistedTokensCount() internal view returns (uint256) { diff --git a/src/storage/ClientChainGatewayStorage.sol b/src/storage/ClientChainGatewayStorage.sol index aa527663..1f95134b 100644 --- a/src/storage/ClientChainGatewayStorage.sol +++ b/src/storage/ClientChainGatewayStorage.sol @@ -16,6 +16,11 @@ import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; /// ClientChainGateway contract. Shared items should be kept in BootstrapStorage. contract ClientChainGatewayStorage is BootstrapStorage { + /// @notice Mapping to indicate whether a message to update the tvl limit is currently in flight. + /// @dev This is used to ensure that a tvl increase and a total supply decrease aren't applied together, since + /// we need to keep tvl <= total supply. + mapping(address token => bool isInFlight) public tvlLimitIncreaseInFlight; + /// @notice Mapping of owner addresses to their corresponding ExoCapsule contracts. mapping(address => IExoCapsule) public ownerToCapsule; diff --git a/src/storage/ExocoreGatewayStorage.sol b/src/storage/ExocoreGatewayStorage.sol index 3f758feb..8ef53c57 100644 --- a/src/storage/ExocoreGatewayStorage.sol +++ b/src/storage/ExocoreGatewayStorage.sol @@ -8,6 +8,14 @@ import {GatewayStorage} from "./GatewayStorage.sol"; /// @author ExocoreNetwork contract ExocoreGatewayStorage is GatewayStorage { + /// @notice Maps a chain ID to the token address to whether a supply decrease is in flight. + /// @dev It is used to ensure that a tvl increase and a total supply decrease aren't applied together, since + /// we need to keep tvl <= total supply. + mapping(uint32 chainId => mapping(bytes32 token => bool)) public supplyDecreaseInFlight; + + /// @dev Mapping of request IDs to their corresponding request data. + mapping(uint64 => bytes) internal _registeredRequests; + /// @dev The length of a deposit request, in bytes. // bytes32 token + bytes32 depositor + uint256 amount uint256 internal constant DEPOSIT_REQUEST_LENGTH = 96; @@ -35,6 +43,10 @@ contract ExocoreGatewayStorage is GatewayStorage { uint256 internal constant ASSOCIATE_OPERATOR_REQUEST_LENGTH = 74; // bytes32 staker uint256 internal constant DISSOCIATE_OPERATOR_REQUEST_LENGTH = 32; + // bytes32 token + uint256 newTvlLimit + uint256 internal constant VALIDATE_LIMITS_REQUEST_LENGTH = 64; + // uint64 lzNonce + bool success + uint256 internal constant VALIDATE_LIMITS_RESPONSE_LENGTH = 9; // constants used for layerzero messaging /// @dev The gas limit for all the destination chains. @@ -66,6 +78,12 @@ contract ExocoreGatewayStorage is GatewayStorage { /// @param token The address of the token. event WhitelistTokenUpdated(uint32 clientChainId, bytes32 token); + /// @notice Emitted when a token update is not applied because the supply would be lower + /// than the TVL limit, or, there's a TVL reduction in flight. + /// @param clientChainId The LayerZero chain ID of the client chain. + /// @param token The address of the token. + event WhitelistTokenNotUpdated(uint32 clientChainId, bytes32 token); + /* --------- asset operations results and staking operations results -------- */ /// @notice Emitted when reward is withdrawn. /// @param success Whether the withdrawal was successful. @@ -125,6 +143,11 @@ contract ExocoreGatewayStorage is GatewayStorage { /// @param clientChainId The LayerZero chain ID of chain to which it is destined. event BootstrapRequestSent(uint32 clientChainId); + /// @dev Emitted when, after a response from the client chain, the token update fails. + /// @param clientChainId The LayerZero chain ID of the client chain. + /// @param token The address of the token. + event UpdateWhitelistTokenFailedOnResponse(uint32 clientChainId, bytes32 token); + /// @notice Thrown when the execution of a request fails /// @param act The action that failed. /// @param nonce The LayerZero nonce. @@ -174,6 +197,9 @@ contract ExocoreGatewayStorage is GatewayStorage { /// @notice Thrown when dissociateOperatorFromEVMStaker failed error DissociateOperatorFailed(uint32 clientChainId, address staker); + /// @dev Thrown when the gateway fails to retrieve the total supply of a token. + error FailedToGetTotalSupply(uint32 clientChainId, bytes32 token); + /// @dev Storage gap to allow for future upgrades. uint256[40] private __gap; diff --git a/src/storage/GatewayStorage.sol b/src/storage/GatewayStorage.sol index d117acf4..8429cc80 100644 --- a/src/storage/GatewayStorage.sol +++ b/src/storage/GatewayStorage.sol @@ -18,6 +18,7 @@ contract GatewayStorage { REQUEST_ADD_WHITELIST_TOKEN, REQUEST_ASSOCIATE_OPERATOR, REQUEST_DISSOCIATE_OPERATOR, + REQUEST_VALIDATE_LIMITS, RESPOND } /// @notice the human readable prefix for Exocore bech32 encoded address. @@ -40,6 +41,11 @@ contract GatewayStorage { /// @param nativeFee The native fee paid for the message. event MessageSent(Action indexed act, bytes32 packetId, uint64 nonce, uint256 nativeFee); + /// @notice Emitted when a message is received and successfully executed. + /// @param act The action being performed. + /// @param nonce The nonce associated with the message. + event MessageExecuted(Action indexed act, uint64 nonce); + /// @notice Error thrown when an unsupported request is made. /// @param act The unsupported action. error UnsupportedRequest(Action act); diff --git a/src/storage/VaultStorage.sol b/src/storage/VaultStorage.sol index bada065b..5affad06 100644 --- a/src/storage/VaultStorage.sol +++ b/src/storage/VaultStorage.sol @@ -62,6 +62,10 @@ contract VaultStorage { /// @param newTvlLimit The new TVL limit. event TvlLimitUpdated(uint256 newTvlLimit); + /// @notice Emitted when the TVL limit consumed so far changes. + /// @param consumed The total amount consumed, including the current transaction. + event ConsumedTvlChanged(uint256 consumed); + /// @dev Storage gap to allow for future upgrades. uint256[40] private __gap; diff --git a/test/foundry/Delegation.t.sol b/test/foundry/Delegation.t.sol index ee1f92c3..6a274345 100644 --- a/test/foundry/Delegation.t.sol +++ b/test/foundry/Delegation.t.sol @@ -66,6 +66,7 @@ contract DelegateTest is ExocoreDeployer { test_AddWhitelistTokens(); _testDelegate(delegateAmount); + _validateNonces(); } function test_Undelegation() public { @@ -79,6 +80,7 @@ contract DelegateTest is ExocoreDeployer { _testDelegate(delegateAmount); _testUndelegate(undelegateAmount); + _validateNonces(); } function _testDelegate(uint256 delegateAmount) internal { @@ -87,7 +89,6 @@ contract DelegateTest is ExocoreDeployer { // 1. first user call client chain gateway to delegate /// estimate the messaging fee that would be charged from user - uint64 delegateRequestNonce = 1; bytes memory delegateRequestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_DELEGATE_TO, abi.encodePacked(bytes32(bytes20(address(restakeToken)))), @@ -96,7 +97,7 @@ contract DelegateTest is ExocoreDeployer { delegateAmount ); uint256 requestNativeFee = clientGateway.quote(delegateRequestPayload); - bytes32 requestId = generateUID(delegateRequestNonce, true); + bytes32 requestId = generateUID(outboundNonces[clientChainId], true); /// layerzero endpoint should emit the message packet including delegate payload. vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); @@ -104,13 +105,15 @@ contract DelegateTest is ExocoreDeployer { exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), - delegateRequestNonce, + outboundNonces[clientChainId], delegateRequestPayload ); /// clientGateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit MessageSent(GatewayStorage.Action.REQUEST_DELEGATE_TO, requestId, delegateRequestNonce, requestNativeFee); + emit MessageSent( + GatewayStorage.Action.REQUEST_DELEGATE_TO, requestId, outboundNonces[clientChainId]++, requestNativeFee + ); /// delegator call clientGateway to send delegation request vm.startPrank(delegator.addr); @@ -120,17 +123,16 @@ contract DelegateTest is ExocoreDeployer { // 2. second layerzero relayers should watch the request message packet and relay the message to destination // endpoint - uint64 delegateResponseNonce = 3; bytes memory delegateResponsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, delegateRequestNonce, true); + abi.encodePacked(GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true); uint256 responseNativeFee = exocoreGateway.quote(clientChainId, delegateResponsePayload); - bytes32 responseId = generateUID(delegateResponseNonce, false); + bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); /// DelegationMock contract should receive correct message payload vm.expectEmit(true, true, true, true, DELEGATION_PRECOMPILE_ADDRESS); emit DelegateRequestProcessed( clientChainId, - delegateRequestNonce, + outboundNonces[clientChainId] - 1, abi.encodePacked(bytes32(bytes20(address(restakeToken)))), abi.encodePacked(bytes32(bytes20(delegator.addr))), operatorAddress, @@ -143,13 +145,13 @@ contract DelegateTest is ExocoreDeployer { clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - delegateResponseNonce, + outboundNonces[exocoreChainId], delegateResponsePayload ); /// exocoreGateway should emit MessageSent event after finishing sending response vm.expectEmit(true, true, true, true, address(exocoreGateway)); - emit MessageSent(GatewayStorage.Action.RESPOND, responseId, delegateResponseNonce, responseNativeFee); + emit MessageSent(GatewayStorage.Action.RESPOND, responseId, outboundNonces[exocoreChainId]++, responseNativeFee); /// exocoreGateway contract should emit DelegateResult event vm.expectEmit(true, true, true, true, address(exocoreGateway)); @@ -160,11 +162,13 @@ contract DelegateTest is ExocoreDeployer { operatorAddress, delegateAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_DELEGATE_TO, inboundNonces[exocoreChainId]++); /// relayer call layerzero endpoint to deliver request messages and generate response message vm.startPrank(relayer.addr); exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), delegateRequestNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), requestId, delegateRequestPayload, @@ -184,12 +188,18 @@ contract DelegateTest is ExocoreDeployer { /// after relayer relay the response message back to client chain, clientGateway should emit RequestFinished /// event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_DELEGATE_TO, delegateRequestNonce, true); + emit RequestFinished( + GatewayStorage.Action.REQUEST_DELEGATE_TO, + outboundNonces[clientChainId] - 1, // request id + true + ); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); /// relayer should watch the response message and relay it back to client chain vm.startPrank(relayer.addr); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), delegateResponseNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, delegateResponsePayload, @@ -208,7 +218,6 @@ contract DelegateTest is ExocoreDeployer { // 1. first user call client chain gateway to undelegate /// estimate the messaging fee that would be charged from user - uint64 undelegateRequestNonce = 2; bytes memory undelegateRequestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_UNDELEGATE_FROM, abi.encodePacked(bytes32(bytes20(address(restakeToken)))), @@ -217,7 +226,7 @@ contract DelegateTest is ExocoreDeployer { undelegateAmount ); uint256 requestNativeFee = clientGateway.quote(undelegateRequestPayload); - bytes32 requestId = generateUID(undelegateRequestNonce, true); + bytes32 requestId = generateUID(outboundNonces[clientChainId], true); /// layerzero endpoint should emit the message packet including undelegate payload. vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); @@ -225,14 +234,14 @@ contract DelegateTest is ExocoreDeployer { exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), - undelegateRequestNonce, + outboundNonces[clientChainId], undelegateRequestPayload ); /// clientGateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(clientGateway)); emit MessageSent( - GatewayStorage.Action.REQUEST_UNDELEGATE_FROM, requestId, undelegateRequestNonce, requestNativeFee + GatewayStorage.Action.REQUEST_UNDELEGATE_FROM, requestId, outboundNonces[clientChainId]++, requestNativeFee ); /// delegator call clientGateway to send undelegation request @@ -243,17 +252,16 @@ contract DelegateTest is ExocoreDeployer { // 2. second layerzero relayers should watch the request message packet and relay the message to destination // endpoint - uint64 undelegateResponseNonce = 4; bytes memory undelegateResponsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, undelegateRequestNonce, true); + abi.encodePacked(GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true); uint256 responseNativeFee = exocoreGateway.quote(clientChainId, undelegateResponsePayload); - bytes32 responseId = generateUID(undelegateResponseNonce, false); + bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); /// DelegationMock contract should receive correct message payload vm.expectEmit(true, true, true, true, DELEGATION_PRECOMPILE_ADDRESS); emit UndelegateRequestProcessed( clientChainId, - undelegateRequestNonce, + outboundNonces[clientChainId] - 1, abi.encodePacked(bytes32(bytes20(address(restakeToken)))), abi.encodePacked(bytes32(bytes20(delegator.addr))), operatorAddress, @@ -266,13 +274,13 @@ contract DelegateTest is ExocoreDeployer { clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - undelegateResponseNonce, + outboundNonces[exocoreChainId], undelegateResponsePayload ); /// exocoreGateway should emit MessageSent event after finishing sending response vm.expectEmit(true, true, true, true, address(exocoreGateway)); - emit MessageSent(GatewayStorage.Action.RESPOND, responseId, undelegateResponseNonce, responseNativeFee); + emit MessageSent(GatewayStorage.Action.RESPOND, responseId, outboundNonces[exocoreChainId]++, responseNativeFee); /// exocoreGateway contract should emit UndelegateResult event vm.expectEmit(true, true, true, true, address(exocoreGateway)); @@ -283,11 +291,13 @@ contract DelegateTest is ExocoreDeployer { operatorAddress, undelegateAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_UNDELEGATE_FROM, inboundNonces[exocoreChainId]++); /// relayer call layerzero endpoint to deliver request messages and generate response message vm.startPrank(relayer.addr); exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), undelegateRequestNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), requestId, undelegateRequestPayload, @@ -306,12 +316,18 @@ contract DelegateTest is ExocoreDeployer { /// after relayer relay the response message back to client chain, clientGateway should emit RequestFinished /// event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_UNDELEGATE_FROM, undelegateRequestNonce, true); + emit RequestFinished( + GatewayStorage.Action.REQUEST_UNDELEGATE_FROM, + outboundNonces[clientChainId] - 1, // request id + true + ); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); /// relayer should watch the response message and relay it back to client chain vm.startPrank(relayer.addr); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), undelegateResponseNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, undelegateResponsePayload, diff --git a/test/foundry/DepositThenDelegateTo.t.sol b/test/foundry/DepositThenDelegateTo.t.sol index e4d25e6f..5835d060 100644 --- a/test/foundry/DepositThenDelegateTo.t.sol +++ b/test/foundry/DepositThenDelegateTo.t.sol @@ -45,8 +45,6 @@ contract DepositThenDelegateToTest is ExocoreDeployer { deal(delegator, 1e22); deal(address(exocoreGateway), 1e22); - uint64 requestLzNonce = 1; - uint64 responseLzNonce = 3; // 2 tokens are whitelisted, 3 is response uint256 delegateAmount = 10_000; // before all operations we should add whitelist tokens @@ -62,18 +60,8 @@ contract DepositThenDelegateToTest is ExocoreDeployer { restakeToken.approve(address(vault), delegateAmount); vm.stopPrank(); - (bytes32 requestId, bytes memory requestPayload) = - _testRequest(delegator, operatorAddress, requestLzNonce, delegateAmount); - _testResponse( - requestId, - requestPayload, - delegator, - relayer, - operatorAddress, - requestLzNonce, - responseLzNonce, - delegateAmount - ); + (bytes32 requestId, bytes memory requestPayload) = _testRequest(delegator, operatorAddress, delegateAmount); + _testResponse(requestId, requestPayload, delegator, relayer, operatorAddress, delegateAmount); } function test_BalanceUpdatedWhen_DepositThenDelegateToResponseNotSuccess() public { @@ -84,8 +72,6 @@ contract DepositThenDelegateToTest is ExocoreDeployer { deal(delegator, 1e22); deal(address(exocoreGateway), 1e22); - uint64 requestLzNonce = 1; - uint64 responseLzNonce = 3; uint256 delegateAmount = 10_000; // before all operations we should add whitelist tokens @@ -101,12 +87,11 @@ contract DepositThenDelegateToTest is ExocoreDeployer { restakeToken.approve(address(vault), delegateAmount); vm.stopPrank(); - (bytes32 requestId, bytes memory requestPayload) = - _testRequest(delegator, operatorAddress, requestLzNonce, delegateAmount); - _testFailureResponse(delegator, relayer, requestLzNonce, responseLzNonce, delegateAmount); + (bytes32 requestId, bytes memory requestPayload) = _testRequest(delegator, operatorAddress, delegateAmount); + _testFailureResponse(delegator, relayer, delegateAmount); } - function _testRequest(address delegator, string memory operatorAddress, uint64 lzNonce, uint256 delegateAmount) + function _testRequest(address delegator, string memory operatorAddress, uint256 delegateAmount) private returns (bytes32 requestId, bytes memory requestPayload) { @@ -121,18 +106,27 @@ contract DepositThenDelegateToTest is ExocoreDeployer { delegateAmount ); uint256 requestNativeFee = clientGateway.quote(requestPayload); - requestId = generateUID(lzNonce, true); + requestId = generateUID(outboundNonces[clientChainId], true); vm.expectEmit(address(restakeToken)); emit IERC20.Transfer(delegator, address(vault), delegateAmount); vm.expectEmit(address(clientChainLzEndpoint)); emit NewPacket( - exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), lzNonce, requestPayload + exocoreChainId, + address(clientGateway), + address(exocoreGateway).toBytes32(), + outboundNonces[clientChainId], + requestPayload ); vm.expectEmit(address(clientGateway)); - emit MessageSent(GatewayStorage.Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO, requestId, lzNonce, requestNativeFee); + emit MessageSent( + GatewayStorage.Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO, + requestId, + outboundNonces[clientChainId]++, + requestNativeFee + ); vm.startPrank(delegator); clientGateway.depositThenDelegateTo{value: requestNativeFee}( @@ -155,14 +149,12 @@ contract DepositThenDelegateToTest is ExocoreDeployer { address delegator, address relayer, string memory operatorAddress, - uint64 requestLzNonce, - uint64 responseLzNonce, uint256 delegateAmount ) private { bytes memory responsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, requestLzNonce, true, delegateAmount); + abi.encodePacked(GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, delegateAmount); uint256 responseNativeFee = exocoreGateway.quote(clientChainId, responsePayload); - bytes32 responseId = generateUID(responseLzNonce, false); + bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); // deposit request is firstly handled and its event is firstly emitted vm.expectEmit(address(exocoreGateway)); @@ -172,7 +164,7 @@ contract DepositThenDelegateToTest is ExocoreDeployer { vm.expectEmit(DELEGATION_PRECOMPILE_ADDRESS); emit DelegateRequestProcessed( clientChainId, - requestLzNonce, + outboundNonces[clientChainId] - 1, abi.encodePacked(bytes32(bytes20(address(restakeToken)))), abi.encodePacked(bytes32(bytes20(delegator))), operatorAddress, @@ -185,21 +177,23 @@ contract DepositThenDelegateToTest is ExocoreDeployer { clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - responseLzNonce, // outbound nonce not inbound, only equals because it's the first tx + outboundNonces[exocoreChainId], responsePayload ); vm.expectEmit(address(exocoreGateway)); - emit MessageSent(GatewayStorage.Action.RESPOND, responseId, responseLzNonce, responseNativeFee); + emit MessageSent(GatewayStorage.Action.RESPOND, responseId, outboundNonces[exocoreChainId]++, responseNativeFee); vm.expectEmit(address(exocoreGateway)); emit DelegateResult( true, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(delegator)), operatorAddress, delegateAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO, inboundNonces[exocoreChainId]++); vm.startPrank(relayer); exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), requestLzNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), requestId, requestPayload, @@ -226,11 +220,15 @@ contract DepositThenDelegateToTest is ExocoreDeployer { assertEq(actualDelegateAmount, delegateAmount); vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO, requestLzNonce, true); + emit RequestFinished( + GatewayStorage.Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO, outboundNonces[clientChainId] - 1, true + ); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); vm.startPrank(relayer); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), responseLzNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, responsePayload, @@ -239,27 +237,26 @@ contract DepositThenDelegateToTest is ExocoreDeployer { vm.stopPrank(); } - function _testFailureResponse( - address delegator, - address relayer, - uint64 requestLzNonce, - uint64 responseLzNonce, - uint256 delegateAmount - ) private { + function _testFailureResponse(address delegator, address relayer, uint256 delegateAmount) private { // we assume delegation failed for some reason bool delegateSuccess = false; - bytes memory responsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, requestLzNonce, delegateSuccess, delegateAmount); + bytes memory responsePayload = abi.encodePacked( + GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, delegateSuccess, delegateAmount + ); uint256 responseNativeFee = exocoreGateway.quote(clientChainId, responsePayload); - bytes32 responseId = generateUID(responseLzNonce, false); + bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); // request finished with successful deposit and failed delegation vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO, requestLzNonce, delegateSuccess); + emit RequestFinished( + GatewayStorage.Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO, outboundNonces[clientChainId] - 1, delegateSuccess + ); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); vm.startPrank(relayer); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), responseLzNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, responsePayload, diff --git a/test/foundry/DepositWithdrawPrinciple.t.sol b/test/foundry/DepositWithdrawPrinciple.t.sol index 9c29a90f..6a8d5f78 100644 --- a/test/foundry/DepositWithdrawPrinciple.t.sol +++ b/test/foundry/DepositWithdrawPrinciple.t.sol @@ -24,7 +24,6 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { bool indexed success, bytes32 indexed token, bytes32 indexed withdrawer, uint256 amount ); event Transfer(address indexed from, address indexed to, uint256 amount); - event MessageProcessed(uint32 _srcChainId, bytes _srcAddress, uint64 _nonce, bytes _payload); event CapsuleCreated(address owner, address capsule); event StakedWithCapsule(address staker, address capsule); @@ -77,7 +76,6 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // first user call client chain gateway to deposit // estimate l0 relay fee that the user should pay - uint64 depositRequestNonce = 1; bytes memory depositRequestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_DEPOSIT, bytes32(bytes20(address(restakeToken))), @@ -85,7 +83,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { depositAmount ); uint256 depositRequestNativeFee = clientGateway.quote(depositRequestPayload); - bytes32 depositRequestId = generateUID(depositRequestNonce, true); + bytes32 depositRequestId = generateUID(outboundNonces[clientChainId], true); // depositor should transfer deposited token to vault vm.expectEmit(true, true, false, true, address(restakeToken)); emit Transfer(depositor.addr, address(vault), depositAmount); @@ -95,13 +93,16 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), - depositRequestNonce, + outboundNonces[clientChainId], depositRequestPayload ); // client chain gateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(clientGateway)); emit MessageSent( - GatewayStorage.Action.REQUEST_DEPOSIT, depositRequestId, depositRequestNonce, depositRequestNativeFee + GatewayStorage.Action.REQUEST_DEPOSIT, + depositRequestId, + outboundNonces[clientChainId]++, + depositRequestNativeFee ); clientGateway.deposit{value: depositRequestNativeFee}(address(restakeToken), depositAmount); @@ -109,31 +110,34 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // endpoint // exocore gateway should return response message to exocore network layerzero endpoint - vm.expectEmit(true, true, true, true, address(exocoreLzEndpoint)); + // lastlyUpdatedPrincipalBalance += depositAmount; - uint64 depositResponseNonce = 3; - bytes memory depositResponsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, depositRequestNonce, true, lastlyUpdatedPrincipalBalance); + bytes memory depositResponsePayload = abi.encodePacked( + GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, lastlyUpdatedPrincipalBalance + ); uint256 depositResponseNativeFee = exocoreGateway.quote(clientChainId, depositResponsePayload); - bytes32 depositResponseId = generateUID(depositResponseNonce, false); + bytes32 depositResponseId = generateUID(outboundNonces[exocoreChainId], false); + vm.expectEmit(address(exocoreLzEndpoint)); emit NewPacket( clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - depositResponseNonce, + outboundNonces[exocoreChainId], depositResponsePayload ); - // exocore gateway should emit MessageSent event - vm.expectEmit(true, true, true, true, address(exocoreGateway)); + vm.expectEmit(address(exocoreGateway)); emit MessageSent( - GatewayStorage.Action.RESPOND, depositResponseId, depositResponseNonce, depositResponseNativeFee + GatewayStorage.Action.RESPOND, depositResponseId, outboundNonces[exocoreChainId]++, depositResponseNativeFee ); - vm.expectEmit(true, true, true, true, address(exocoreGateway)); + vm.expectEmit(address(exocoreGateway)); emit DepositResult( true, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(depositor.addr)), depositAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_DEPOSIT, inboundNonces[exocoreChainId]++); + // inboundNonces[exocoreChainId]++; exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), depositRequestNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), depositRequestId, depositRequestPayload, @@ -145,9 +149,11 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // client chain gateway should execute the response hook and emit RequestFinished event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_DEPOSIT, depositRequestNonce, true); + emit RequestFinished(GatewayStorage.Action.REQUEST_DEPOSIT, outboundNonces[clientChainId] - 1, true); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), depositResponseNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), depositResponseId, depositResponsePayload, @@ -163,7 +169,6 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // first user call client chain gateway to withdraw // estimate l0 relay fee that the user should pay - uint64 withdrawRequestNonce = 2; bytes memory withdrawRequestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, bytes32(bytes20(address(restakeToken))), @@ -171,14 +176,14 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { withdrawAmount ); uint256 withdrawRequestNativeFee = clientGateway.quote(withdrawRequestPayload); - bytes32 withdrawRequestId = generateUID(withdrawRequestNonce, true); + bytes32 withdrawRequestId = generateUID(outboundNonces[clientChainId], true); // client chain layerzero endpoint should emit the message packet including withdraw payload. vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); emit NewPacket( exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), - withdrawRequestNonce, + outboundNonces[clientChainId], withdrawRequestPayload ); // client chain gateway should emit MessageSent event @@ -186,7 +191,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { emit MessageSent( GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, withdrawRequestId, - withdrawRequestNonce, + outboundNonces[clientChainId]++, withdrawRequestNativeFee ); clientGateway.withdrawPrincipalFromExocore{value: withdrawRequestNativeFee}( @@ -196,12 +201,12 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // second layerzero relayers should watch the request message packet and relay the message to destination // endpoint - uint64 withdrawResponseNonce = 4; lastlyUpdatedPrincipalBalance -= withdrawAmount; - bytes memory withdrawResponsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, withdrawRequestNonce, true, lastlyUpdatedPrincipalBalance); + bytes memory withdrawResponsePayload = abi.encodePacked( + GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, lastlyUpdatedPrincipalBalance + ); uint256 withdrawResponseNativeFee = exocoreGateway.quote(clientChainId, withdrawResponsePayload); - bytes32 withdrawResponseId = generateUID(withdrawResponseNonce, false); + bytes32 withdrawResponseId = generateUID(outboundNonces[exocoreChainId], false); // exocore gateway should return response message to exocore network layerzero endpoint vm.expectEmit(true, true, true, true, address(exocoreLzEndpoint)); @@ -209,20 +214,27 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - withdrawResponseNonce, + outboundNonces[exocoreChainId], withdrawResponsePayload ); // exocore gateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(exocoreGateway)); emit MessageSent( - GatewayStorage.Action.RESPOND, withdrawResponseId, withdrawResponseNonce, withdrawResponseNativeFee + GatewayStorage.Action.RESPOND, + withdrawResponseId, + outboundNonces[exocoreChainId]++, + withdrawResponseNativeFee ); vm.expectEmit(true, true, true, true, address(exocoreGateway)); emit WithdrawPrincipalResult( true, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(withdrawer.addr)), withdrawAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, inboundNonces[exocoreChainId]++ + ); exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), withdrawRequestNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), withdrawRequestId, withdrawRequestPayload, @@ -234,9 +246,13 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // client chain gateway should execute the response hook and emit RequestFinished event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, withdrawRequestNonce, true); + emit RequestFinished( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, outboundNonces[clientChainId] - 1, true + ); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), withdrawResponseNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), withdrawResponseId, withdrawResponsePayload, @@ -305,7 +321,6 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // through layerzero /// client chain layerzero endpoint should emit the message packet including deposit payload. - uint64 depositRequestNonce = 1; uint256 depositAmount = uint256(_getEffectiveBalance(validatorContainer)) * GWEI_TO_WEI; // Cap to 32 ether if (depositAmount >= 32 ether) { @@ -319,20 +334,23 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { depositAmount ); uint256 depositRequestNativeFee = clientGateway.quote(depositRequestPayload); - bytes32 depositRequestId = generateUID(depositRequestNonce, true); + bytes32 depositRequestId = generateUID(outboundNonces[clientChainId], true); vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); emit NewPacket( exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), - depositRequestNonce, + outboundNonces[clientChainId], depositRequestPayload ); /// client chain gateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(clientGateway)); emit MessageSent( - GatewayStorage.Action.REQUEST_DEPOSIT, depositRequestId, depositRequestNonce, depositRequestNativeFee + GatewayStorage.Action.REQUEST_DEPOSIT, + depositRequestId, + outboundNonces[clientChainId]++, + depositRequestNativeFee ); /// call depositBeaconChainValidator to see if these events are emitted as expected @@ -344,26 +362,26 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // endpoint /// exocore gateway should return response message to exocore network layerzero endpoint - uint64 depositResponseNonce = 3; lastlyUpdatedPrincipalBalance += depositAmount; - bytes memory depositResponsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, depositRequestNonce, true, lastlyUpdatedPrincipalBalance); + bytes memory depositResponsePayload = abi.encodePacked( + GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, lastlyUpdatedPrincipalBalance + ); uint256 depositResponseNativeFee = exocoreGateway.quote(clientChainId, depositResponsePayload); - bytes32 depositResponseId = generateUID(depositResponseNonce, false); + bytes32 depositResponseId = generateUID(outboundNonces[exocoreChainId], false); vm.expectEmit(true, true, true, true, address(exocoreLzEndpoint)); emit NewPacket( clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - depositResponseNonce, + outboundNonces[exocoreChainId], depositResponsePayload ); /// exocore gateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(exocoreGateway)); emit MessageSent( - GatewayStorage.Action.RESPOND, depositResponseId, depositResponseNonce, depositResponseNativeFee + GatewayStorage.Action.RESPOND, depositResponseId, outboundNonces[exocoreChainId]++, depositResponseNativeFee ); /// exocore gateway should emit DepositResult event @@ -372,10 +390,13 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { true, bytes32(bytes20(VIRTUAL_STAKED_ETH_ADDRESS)), bytes32(bytes20(depositor.addr)), depositAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_DEPOSIT, inboundNonces[exocoreChainId]++); + /// relayer catches the request message packet by listening to client chain event and feed it to Exocore network vm.startPrank(relayer.addr); exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), depositRequestNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), depositRequestId, depositRequestPayload, @@ -388,12 +409,15 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { /// client chain gateway should execute the response hook and emit RequestFinished event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_DEPOSIT, depositRequestNonce, true); + emit RequestFinished(GatewayStorage.Action.REQUEST_DEPOSIT, outboundNonces[clientChainId] - 1, true); + + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); /// relayer catches the response message packet by listening to Exocore event and feed it to client chain vm.startPrank(relayer.addr); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), depositResponseNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), depositResponseId, depositResponsePayload, @@ -416,6 +440,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { ); vm.expectEmit(true, true, true, true, address(clientGateway)); emit CapsuleCreated(depositor.addr, address(expectedCapsule)); + vm.expectEmit(address(clientGateway)); emit StakedWithCapsule(depositor.addr, address(expectedCapsule)); vm.startPrank(depositor.addr); @@ -470,7 +495,6 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // 1. withdrawer will call clientGateway.processBeaconChainWithdrawal to withdraw from Exocore thru layerzero /// client chain layerzero endpoint should emit the message packet including deposit payload. - uint64 withdrawRequestNonce = 2; uint64 withdrawalAmountGwei = _getWithdrawalAmount(withdrawalContainer); uint256 withdrawalAmount; if (withdrawalAmountGwei > MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR) { @@ -485,7 +509,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { withdrawalAmount ); uint256 withdrawRequestNativeFee = clientGateway.quote(withdrawRequestPayload); - bytes32 withdrawRequestId = generateUID(withdrawRequestNonce, true); + bytes32 withdrawRequestId = generateUID(outboundNonces[clientChainId], true); // client chain layerzero endpoint should emit the message packet including withdraw payload. vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); @@ -493,7 +517,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), - withdrawRequestNonce, + outboundNonces[clientChainId], withdrawRequestPayload ); // client chain gateway should emit MessageSent event @@ -501,7 +525,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { emit MessageSent( GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, withdrawRequestId, - withdrawRequestNonce, + outboundNonces[clientChainId]++, withdrawRequestNativeFee ); @@ -512,12 +536,12 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { vm.stopPrank(); /// exocore gateway should return response message to exocore network layerzero endpoint - uint64 withdrawResponseNonce = 4; lastlyUpdatedPrincipalBalance -= withdrawalAmount; - bytes memory withdrawResponsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, withdrawRequestNonce, true, lastlyUpdatedPrincipalBalance); + bytes memory withdrawResponsePayload = abi.encodePacked( + GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, lastlyUpdatedPrincipalBalance + ); uint256 withdrawResponseNativeFee = exocoreGateway.quote(clientChainId, withdrawResponsePayload); - bytes32 withdrawResponseId = generateUID(withdrawResponseNonce, false); + bytes32 withdrawResponseId = generateUID(outboundNonces[exocoreChainId], false); // exocore gateway should return response message to exocore network layerzero endpoint vm.expectEmit(true, true, true, true, address(exocoreLzEndpoint)); @@ -525,13 +549,16 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - withdrawResponseNonce, + outboundNonces[exocoreChainId], withdrawResponsePayload ); // exocore gateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(exocoreGateway)); emit MessageSent( - GatewayStorage.Action.RESPOND, withdrawResponseId, withdrawResponseNonce, withdrawResponseNativeFee + GatewayStorage.Action.RESPOND, + withdrawResponseId, + outboundNonces[exocoreChainId]++, + withdrawResponseNativeFee ); // exocore gateway should emit WithdrawPrincipalResult event @@ -540,8 +567,13 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { true, bytes32(bytes20(VIRTUAL_STAKED_ETH_ADDRESS)), bytes32(bytes20(withdrawer.addr)), withdrawalAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, inboundNonces[exocoreChainId]++ + ); + exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), withdrawRequestNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), withdrawRequestId, withdrawRequestPayload, @@ -550,9 +582,15 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // client chain gateway should execute the response hook and emit RequestFinished event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, withdrawRequestNonce, true); + emit RequestFinished( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, outboundNonces[clientChainId] - 1, true + ); + + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); + clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), withdrawResponseNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), withdrawResponseId, withdrawResponsePayload, @@ -591,76 +629,140 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { restakeToken.transfer(addr, 1_000_000); vm.stopPrank(); - // must be divisble by 4 to avoid rounding errors - uint256 balance = restakeToken.balanceOf(addr); + uint256 depositAmount = restakeToken.balanceOf(addr); uint256 principalBalance = 0; - uint256 withdrawAmount = balance / 4; + uint256 withdrawAmount = depositAmount / 4; + uint256 consumedTvl = 0; + assertEq(withdrawAmount * 4, depositAmount); // must be divisble by 4 vm.startPrank(addr); restakeToken.approve(address(vault), type(uint256).max); - bytes memory payload = abi.encodePacked( + bytes memory requestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_DEPOSIT, - abi.encodePacked(bytes32(bytes20(address(restakeToken))), bytes32(bytes20(addr)), balance) + abi.encodePacked(bytes32(bytes20(address(restakeToken))), bytes32(bytes20(addr)), depositAmount) ); - uint256 nativeFee = clientGateway.quote(payload); - clientGateway.deposit{value: nativeFee}(address(restakeToken), balance); + bytes32 requestId = generateUID(outboundNonces[clientChainId], true); + uint256 nativeFee = clientGateway.quote(requestPayload); + vm.expectEmit(address(restakeToken)); + emit Transfer(addr, address(vault), depositAmount); + vm.expectEmit(address(clientGateway)); + emit MessageSent(GatewayStorage.Action.REQUEST_DEPOSIT, requestId, outboundNonces[clientChainId]++, nativeFee); + clientGateway.deposit{value: nativeFee}(address(restakeToken), depositAmount); + consumedTvl += depositAmount; vm.stopPrank(); - // execute the LZ response - uint64 requestNonce = 1; - uint64 responseNonce = 3; - principalBalance += balance; + // deposit succeeded on client chain + assertTrue(vault.getConsumedTvl() == consumedTvl); + + deal(address(exocoreGateway), 1e22); // for lz fees + + // run the message on the Exocore gateway + principalBalance += depositAmount; bytes memory responsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, requestNonce++, true, principalBalance); - bytes32 responseId = generateUID(responseNonce, false); + abi.encodePacked(GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, principalBalance); + bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); + vm.expectEmit(address(exocoreGateway)); + emit MessageSent( + GatewayStorage.Action.RESPOND, + responseId, + outboundNonces[exocoreChainId]++, + exocoreGateway.quote(clientChainId, responsePayload) + ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_DEPOSIT, inboundNonces[exocoreChainId]++); + exocoreLzEndpoint.lzReceive( + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), + address(exocoreGateway), + requestId, + requestPayload, + bytes("") + ); + // given that the above transaction went through, the deposit succeeded on Exocore + + // run the response on the client chain + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), responseNonce++), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, responsePayload, bytes("") ); - assertTrue(vault.getConsumedTvl() == balance); - - // reduce the TVL limit below the total deposited amount - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = address(restakeToken); - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = balance / 2; - + uint256 newTvlLimit = depositAmount / 2; // divisible by 4 so no need to check for 2 vm.startPrank(exocoreValidatorSet.addr); - clientGateway.updateTvlLimits(whitelistTokens, tvlLimits); + // a reduction is always allowed + clientGateway.updateTvlLimit(address(restakeToken), newTvlLimit); vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == balance); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getConsumedTvl() == consumedTvl); + assertTrue(vault.getTvlLimit() == newTvlLimit); // now attempt to withdraw, which should go through vm.startPrank(addr); - payload = abi.encodePacked( + requestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, abi.encodePacked(bytes32(bytes20(address(restakeToken))), bytes32(bytes20(addr)), withdrawAmount) ); - nativeFee = clientGateway.quote(payload); + requestId = generateUID(outboundNonces[clientChainId], true); + nativeFee = clientGateway.quote(requestPayload); + vm.expectEmit(address(clientGateway)); + emit MessageSent( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, + requestId, + outboundNonces[clientChainId]++, + nativeFee + ); clientGateway.withdrawPrincipalFromExocore{value: nativeFee}(address(restakeToken), withdrawAmount); - assertTrue(vault.getConsumedTvl() == balance); // no change till claimed - // we have to execute the msg received from Exocore before we can claim + vm.stopPrank(); + principalBalance -= withdrawAmount; - responsePayload = abi.encodePacked(GatewayStorage.Action.RESPOND, requestNonce++, true, principalBalance); - responseId = generateUID(responseNonce, false); + responsePayload = + abi.encodePacked(GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, principalBalance); + responseId = generateUID(outboundNonces[exocoreChainId], false); + vm.expectEmit(address(exocoreGateway)); + emit MessageSent( + GatewayStorage.Action.RESPOND, + responseId, + outboundNonces[exocoreChainId]++, + exocoreGateway.quote(clientChainId, responsePayload) + ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, inboundNonces[exocoreChainId]++ + ); + exocoreLzEndpoint.lzReceive( + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), + address(exocoreGateway), + requestId, + requestPayload, + bytes("") + ); + // run the response on the client chain + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), responseNonce++), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, responsePayload, bytes("") ); + vm.stopPrank(); + // until claimed, the consumed tvl does not change + assertTrue(vault.getConsumedTvl() == consumedTvl); + assertTrue(vault.getTvlLimit() == newTvlLimit); + + vm.startPrank(addr); + vm.expectEmit(address(restakeToken)); + emit Transfer(address(vault), addr, withdrawAmount); clientGateway.claim(address(restakeToken), withdrawAmount, addr); vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == balance - withdrawAmount); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + consumedTvl -= withdrawAmount; + assertTrue(vault.getConsumedTvl() == consumedTvl); + assertTrue(vault.getTvlLimit() == newTvlLimit); // try to deposit, which will fail vm.startPrank(addr); @@ -668,47 +770,98 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { clientGateway.deposit(address(restakeToken), withdrawAmount); vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == balance - withdrawAmount); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getConsumedTvl() == consumedTvl); + assertTrue(vault.getTvlLimit() == newTvlLimit); // withdraw to get just below tvl limit withdrawAmount = vault.getConsumedTvl() - vault.getTvlLimit() + 1; principalBalance -= withdrawAmount; vm.startPrank(addr); - payload = abi.encodePacked( + requestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, abi.encodePacked(bytes32(bytes20(address(restakeToken))), bytes32(bytes20(addr)), withdrawAmount) ); - nativeFee = clientGateway.quote(payload); + requestId = generateUID(outboundNonces[clientChainId], true); + nativeFee = clientGateway.quote(requestPayload); + vm.expectEmit(address(clientGateway)); + emit MessageSent( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, + requestId, + outboundNonces[clientChainId]++, + nativeFee + ); clientGateway.withdrawPrincipalFromExocore{value: nativeFee}(address(restakeToken), withdrawAmount); - responsePayload = abi.encodePacked(GatewayStorage.Action.RESPOND, requestNonce++, true, principalBalance); - responseId = generateUID(responseNonce, false); + + // obtain the response + responsePayload = + abi.encodePacked(GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, principalBalance); + responseId = generateUID(outboundNonces[exocoreChainId], false); + vm.expectEmit(address(exocoreGateway)); + emit MessageSent( + GatewayStorage.Action.RESPOND, + responseId, + outboundNonces[exocoreChainId]++, + exocoreGateway.quote(clientChainId, responsePayload) + ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted( + GatewayStorage.Action.REQUEST_WITHDRAW_PRINCIPAL_FROM_EXOCORE, inboundNonces[exocoreChainId]++ + ); + exocoreLzEndpoint.lzReceive( + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), + address(exocoreGateway), + requestId, + requestPayload, + bytes("") + ); + + // execute the response + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), responseNonce++), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, responsePayload, bytes("") ); + vm.stopPrank(); + + // until claimed, the tvl limit does not change + assertTrue(vault.getConsumedTvl() == consumedTvl); + assertTrue(vault.getTvlLimit() == newTvlLimit); + + // claim now + vm.startPrank(addr); + vm.expectEmit(address(restakeToken)); + emit Transfer(address(vault), addr, withdrawAmount); clientGateway.claim(address(restakeToken), withdrawAmount, addr); + consumedTvl -= withdrawAmount; vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == tvlLimits[0] - 1); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(consumedTvl == vault.getTvlLimit() - 1); + assertTrue(vault.getConsumedTvl() == consumedTvl); + assertTrue(vault.getTvlLimit() == newTvlLimit); - // // then deposit a single unit, which should go through - uint256 depositAmount = 1; + // then deposit a single unit, which should go through + depositAmount = 1; vm.startPrank(addr); - payload = abi.encodePacked( + requestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_DEPOSIT, abi.encodePacked(bytes32(bytes20(address(restakeToken))), bytes32(bytes20(addr)), depositAmount) ); - nativeFee = clientGateway.quote(payload); + requestId = generateUID(outboundNonces[clientChainId], true); + nativeFee = clientGateway.quote(requestPayload); + vm.expectEmit(address(restakeToken)); + emit Transfer(addr, address(vault), depositAmount); + vm.expectEmit(address(clientGateway)); + emit MessageSent(GatewayStorage.Action.REQUEST_DEPOSIT, requestId, outboundNonces[clientChainId]++, nativeFee); clientGateway.deposit{value: nativeFee}(address(restakeToken), depositAmount); + consumedTvl += depositAmount; vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == tvlLimits[0]); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getConsumedTvl() == consumedTvl); + assertTrue(vault.getTvlLimit() == newTvlLimit); // no more deposits should be allowed vm.startPrank(addr); @@ -717,8 +870,8 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { clientGateway.deposit(address(restakeToken), 1); vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == tvlLimits[0]); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getConsumedTvl() == newTvlLimit); + assertTrue(vault.getTvlLimit() == newTvlLimit); } } diff --git a/test/foundry/ExocoreDeployer.t.sol b/test/foundry/ExocoreDeployer.t.sol index 854780df..7205356a 100644 --- a/test/foundry/ExocoreDeployer.t.sol +++ b/test/foundry/ExocoreDeployer.t.sol @@ -94,6 +94,15 @@ contract ExocoreDeployer is Test { uint32 exocoreChainId = 2; uint32 clientChainId = 1; + // the nonces to use for sending messages, incremented when there is a MessageSent event + mapping(uint32 chainId => uint64 nextOutboundNonce) outboundNonces; + // the nonces to use for receiving messages, incremented when there is a MessageExecuted event + // the inboundNonces aren't just the outboundNonces - 1 because there may be multiple + // outbound messages that have not yet been executed on the destination chain + mapping(uint32 chainId => uint64 nextInboundNonce) inboundNonces; + + bool tokensWhitelisted = false; + struct Player { uint256 privateKey; address addr; @@ -104,8 +113,15 @@ contract ExocoreDeployer is Test { event WhitelistTokenAdded(address _token); event VaultCreated(address underlyingToken, address vault); event RequestFinished(GatewayStorage.Action indexed action, uint64 indexed requestId, bool indexed success); + event MessageExecuted(GatewayStorage.Action indexed act, uint64 nonce); function setUp() public virtual { + // the nonces start from 1 + outboundNonces[exocoreChainId] = 1; + outboundNonces[clientChainId] = 1; + inboundNonces[exocoreChainId] = 1; + inboundNonces[clientChainId] = 1; + players.push(Player({privateKey: uint256(0x1), addr: vm.addr(uint256(0x1))})); players.push(Player({privateKey: uint256(0x2), addr: vm.addr(uint256(0x2))})); players.push(Player({privateKey: uint256(0x3), addr: vm.addr(uint256(0x3))})); @@ -130,6 +146,10 @@ contract ExocoreDeployer is Test { } function test_AddWhitelistTokens() public { + if (tokensWhitelisted) { + return; + } + // transfer some gas fee to the owner / deployer deal(exocoreValidatorSet.addr, 1e22); @@ -144,7 +164,7 @@ contract ExocoreDeployer is Test { whitelistTokens.push(bytes32(bytes20(address(restakeToken)))); decimals[0] = 18; - supplies[0] = 1e8 ether; + supplies[0] = restakeToken.totalSupply(); names[0] = "RestakeToken"; metaDatas[0] = "ERC20 LST token"; oracleInfos[0] = "{'a': 'b'}"; @@ -163,7 +183,8 @@ contract ExocoreDeployer is Test { // first user call exocore gateway to add whitelist tokens vm.startPrank(exocoreValidatorSet.addr); uint256 nativeFee; - for (uint256 i = 0; i < whitelistTokens.length; i++) { + for (; outboundNonces[exocoreChainId] < whitelistTokens.length + 1; outboundNonces[exocoreChainId]++) { + uint256 i = outboundNonces[exocoreChainId] - 1; // only one var in the loop is allowed // estimate the fee from the payload payloads[i] = abi.encodePacked( GatewayStorage.Action.REQUEST_ADD_WHITELIST_TOKEN, abi.encodePacked(whitelistTokens[i], tvlLimits[i]) @@ -208,9 +229,12 @@ contract ExocoreDeployer is Test { ); vm.expectEmit(address(clientGateway)); emit VaultCreated(address(restakeToken), expectedVault); + vm.expectEmit(address(clientGateway)); emit WhitelistTokenAdded(address(restakeToken)); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_ADD_WHITELIST_TOKEN, inboundNonces[clientChainId]++); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), uint64(1)), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), requestIds[0], payloads[0], @@ -219,8 +243,10 @@ contract ExocoreDeployer is Test { vm.expectEmit(address(clientGateway)); emit WhitelistTokenAdded(VIRTUAL_STAKED_ETH_ADDRESS); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_ADD_WHITELIST_TOKEN, inboundNonces[clientChainId]++); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), uint64(2)), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), requestIds[1], payloads[1], @@ -236,6 +262,18 @@ contract ExocoreDeployer is Test { assertTrue(address(clientGateway.tokenToVault(address(VIRTUAL_STAKED_ETH_ADDRESS))) == address(0)); vm.stopPrank(); + + _validateNonces(); + + tokensWhitelisted = true; + } + + function _validateNonces() internal { + // at the end of it, we should have executed outbound nonces from chain A on chain B + // this helps check that all outbound messages were executed on the destination chain, within the test, and + // also validates the nonce incrementing logic in the contracts + assertEq(outboundNonces[exocoreChainId], inboundNonces[clientChainId]); + assertEq(outboundNonces[clientChainId], inboundNonces[exocoreChainId]); } function _loadValidatorContainer(string memory validatorInfo) internal { diff --git a/test/foundry/TvlLimits.t.sol b/test/foundry/TvlLimits.t.sol index 3cd8d3ae..a0f816bb 100644 --- a/test/foundry/TvlLimits.t.sol +++ b/test/foundry/TvlLimits.t.sol @@ -16,43 +16,51 @@ import "@openzeppelin/contracts/utils/Create2.sol"; contract TvlLimitsTest is ExocoreDeployer { - function test_UpdateTvlLimits() public { + function setUp() public virtual override { + super.setUp(); test_AddWhitelistTokens(); + } + + using AddressCast for address; + + // a decrease in tvl limit is always permitted + function test_DecreaseTvlLimit() public { IVault vault = clientGateway.tokenToVault(address(restakeToken)); assertTrue(address(vault) != address(0)); - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = address(restakeToken); - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = vault.getTvlLimit() * 2; // double the TVL limit + uint256 tvlLimit = vault.getTvlLimit() / 2; // halve the TVL limit vm.prank(exocoreValidatorSet.addr); - clientGateway.updateTvlLimits(whitelistTokens, tvlLimits); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + clientGateway.updateTvlLimit(address(restakeToken), tvlLimit); + assertTrue(vault.getTvlLimit() == tvlLimit); } + // a decrease in tvl limit does not require LZ fee + function test_DecreaseTvlLimit_FailWithValue() public { + IVault vault = clientGateway.tokenToVault(address(restakeToken)); + assertTrue(address(vault) != address(0)); + uint256 tvlLimit = vault.getTvlLimit() / 2; // halve the TVL limit + vm.startPrank(exocoreValidatorSet.addr); + vm.expectRevert(Errors.NonZeroValue.selector); + clientGateway.updateTvlLimit{value: 5}(address(restakeToken), tvlLimit); + } + + // for a token that is not whitelisted, nothing should happen function test07_UpdateTvlLimits_NotWhitelisted() public { - test_AddWhitelistTokens(); - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = address(0xa); - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = 500; + address addr = address(0xa); vm.startPrank(exocoreValidatorSet.addr); - vm.expectRevert(abi.encodeWithSelector(Errors.TokenNotWhitelisted.selector, whitelistTokens[0])); - clientGateway.updateTvlLimits(whitelistTokens, tvlLimits); + vm.expectRevert(abi.encodeWithSelector(Errors.TokenNotWhitelisted.selector, addr)); + clientGateway.updateTvlLimit(addr, 500); vm.stopPrank(); } + // native restaking does not have a TVL limit function test07_UpdateTvlLimits_NativeEth() public { - test_AddWhitelistTokens(); - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = VIRTUAL_STAKED_ETH_ADDRESS; - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = 500; vm.startPrank(exocoreValidatorSet.addr); vm.expectRevert(Errors.NoTvlLimitForNativeRestaking.selector); - clientGateway.updateTvlLimits(whitelistTokens, tvlLimits); + clientGateway.updateTvlLimit(VIRTUAL_STAKED_ETH_ADDRESS, 500); vm.stopPrank(); } + // whitelist tokens should be added before updating tvl limits function test08_AddWhitelistTokens_NotPermitted() public { address[] memory tokens = new address[](1); tokens[0] = address(restakeToken); @@ -63,4 +71,221 @@ contract TvlLimitsTest is ExocoreDeployer { clientGateway.addWhitelistTokens(tokens, tvlLimits); } + // helper function to increase the tvl limit + function _increaseTvlLimitOnClientChain(uint256 proposedTvlLimitFactor) + internal + returns (uint256, bytes32, bytes memory) + { + IVault vault = clientGateway.tokenToVault(address(restakeToken)); + assertTrue(address(vault) != address(0)); + uint256 tvlLimit = vault.getTvlLimit(); + uint256 proposedTvlLimit = tvlLimit * proposedTvlLimitFactor; + vm.startPrank(exocoreValidatorSet.addr); + bytes memory payload = abi.encodePacked( + GatewayStorage.Action.REQUEST_VALIDATE_LIMITS, + abi.encodePacked(bytes32(bytes20(address(restakeToken))), proposedTvlLimit) + ); + uint256 nativeFee = clientGateway.quote(payload); + bytes32 requestId = generateUID(outboundNonces[clientChainId], true); + vm.expectEmit(address(clientGateway)); + emit MessageSent( + GatewayStorage.Action.REQUEST_VALIDATE_LIMITS, requestId, outboundNonces[clientChainId]++, nativeFee + ); + clientGateway.updateTvlLimit{value: nativeFee}(address(restakeToken), proposedTvlLimit); + return (tvlLimit, requestId, payload); + } + + function _handleTvlLimitIncreaseOnExocore(bool expect, bytes32 requestId, bytes memory requestPayload) + internal + returns (bytes32, bytes memory) + { + // take this message to Exocore and obtain the response, which should be true + bytes memory responsePayload = + abi.encodePacked(GatewayStorage.Action.RESPOND, abi.encodePacked(outboundNonces[clientChainId] - 1, expect)); + bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); + // fund the gateway to respond + deal(address(exocoreGateway), 1e22); + uint256 nativeFee = exocoreGateway.quote(clientChainId, responsePayload); + vm.expectEmit(address(exocoreLzEndpoint)); + emit NewPacket( + clientChainId, + address(exocoreGateway), + address(clientGateway).toBytes32(), + outboundNonces[exocoreChainId], + responsePayload + ); + vm.expectEmit(address(exocoreGateway)); + emit MessageSent(GatewayStorage.Action.RESPOND, responseId, outboundNonces[exocoreChainId]++, nativeFee); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_VALIDATE_LIMITS, inboundNonces[exocoreChainId]++); + exocoreLzEndpoint.lzReceive( + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), + address(exocoreGateway), + requestId, + requestPayload, + bytes("") + ); + return (responseId, responsePayload); + } + + function _handleTvlLimitResponseOnClientChain(bool success, bytes32 responseId, bytes memory responsePayload) + internal + { + vm.expectEmit(address(clientGateway)); + emit RequestFinished( + GatewayStorage.Action.REQUEST_VALIDATE_LIMITS, + outboundNonces[clientChainId] - 1, // request id + success + ); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); + + clientChainLzEndpoint.lzReceive( + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), + address(clientGateway), + responseId, + responsePayload, + bytes("") + ); + vm.stopPrank(); + } + + function _testTvlLimitIncreaseE2E(uint256 limitFactor, bool success) internal { + (uint256 prevLimit, bytes32 requestId, bytes memory requestPayload) = + _increaseTvlLimitOnClientChain(limitFactor); + (bytes32 responseId, bytes memory responsePayload) = + _handleTvlLimitIncreaseOnExocore(success, requestId, requestPayload); + _handleTvlLimitResponseOnClientChain(success, responseId, responsePayload); + IVault vault = clientGateway.tokenToVault(address(restakeToken)); + assertTrue(vault.getTvlLimit() == prevLimit * (success ? limitFactor : uint256(1))); + } + + function test_IncreaseTvlLimit() public { + _testTvlLimitIncreaseE2E(20, true); + } + + function test_IncreaseTvlLimit_TooHigh() public { + _testTvlLimitIncreaseE2E(21, false); + } + + function test_IncreaseTvlLimit_SupplyDecrease() public { + uint256 limitFactor = 19; + (uint256 prevLimit, bytes32 requestId, bytes memory requestPayload) = + _increaseTvlLimitOnClientChain(limitFactor); + bool success = false; + _decreaseTotalSupplyOnExocore(restakeToken.totalSupply() - 1); + (bytes32 responseId, bytes memory responsePayload) = + _handleTvlLimitIncreaseOnExocore(success, requestId, requestPayload); + _handleTvlLimitResponseOnClientChain(success, responseId, responsePayload); + IVault vault = clientGateway.tokenToVault(address(restakeToken)); + assertTrue(vault.getTvlLimit() == prevLimit * (success ? limitFactor : uint256(1))); + } + + function test_IncreaseTotalSupply() public { + // always permitted + uint256 newSupply = restakeToken.totalSupply() + 1; + vm.prank(exocoreValidatorSet.addr); + exocoreGateway.updateWhitelistToken(clientChainId, bytes32(bytes20(address(restakeToken))), newSupply, ""); + } + + function _decreaseTotalSupplyOnExocore(uint256 newSupply) internal returns (bytes32, bytes memory) { + vm.startPrank(exocoreValidatorSet.addr); + bytes memory payload = abi.encodePacked( + GatewayStorage.Action.REQUEST_VALIDATE_LIMITS, + abi.encodePacked(bytes32(bytes20(address(restakeToken))), newSupply) + ); + uint256 nativeFee = exocoreGateway.quote(clientChainId, payload); + bytes32 requestId = generateUID(outboundNonces[exocoreChainId], false); + vm.expectEmit(address(exocoreGateway)); + emit MessageSent( + GatewayStorage.Action.REQUEST_VALIDATE_LIMITS, requestId, outboundNonces[exocoreChainId]++, nativeFee + ); + exocoreGateway.updateWhitelistToken{value: nativeFee}( + clientChainId, bytes32(bytes20(address(restakeToken))), newSupply, "" + ); + return (requestId, payload); + } + + function _handleTotalSupplyDecreaseOnClientChain(bool expect, bytes32 requestId, bytes memory requestPayload) + internal + returns (bytes32, bytes memory) + { + bytes memory responsePayload = abi.encodePacked( + GatewayStorage.Action.RESPOND, abi.encodePacked(outboundNonces[exocoreChainId] - 1, expect) + ); + bytes32 responseId = generateUID(outboundNonces[clientChainId], true); + deal(address(clientGateway), 1e22); + uint256 nativeFee = clientGateway.quote(responsePayload); + vm.expectEmit(address(clientGateway)); + emit MessageSent(GatewayStorage.Action.RESPOND, responseId, outboundNonces[clientChainId]++, nativeFee); + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.REQUEST_VALIDATE_LIMITS, inboundNonces[clientChainId]++); + clientChainLzEndpoint.lzReceive( + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), + address(clientGateway), + requestId, + requestPayload, + bytes("") + ); + return (responseId, responsePayload); + } + + function _handleSupplyResponseOnExocore(bool success, bytes32 responseId, bytes memory responsePayload) internal { + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[exocoreChainId]++); + exocoreLzEndpoint.lzReceive( + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), + address(exocoreGateway), + responseId, + responsePayload, + bytes("") + ); + } + + function test_DecreaseTotalSupply() public { + bytes32 tokenAddr = bytes32(bytes20(address(restakeToken))); + (bool supplied, uint256 prevSupply) = exocoreGateway.getTotalSupply(clientChainId, tokenAddr); + assertTrue(supplied); + uint256 newSupply = prevSupply - 1; + bool expect = true; + (bytes32 requestId, bytes memory requestPayload) = _decreaseTotalSupplyOnExocore(newSupply); + (bytes32 responseId, bytes memory responsePayload) = + _handleTotalSupplyDecreaseOnClientChain(expect, requestId, requestPayload); + _handleSupplyResponseOnExocore(expect, responseId, responsePayload); + (bool supplied2, uint256 gotSupply) = exocoreGateway.getTotalSupply(clientChainId, tokenAddr); + assertTrue(supplied2); + assertTrue(newSupply == gotSupply); + } + + function test_DecreaseTotalSupply_TooLow() public { + bytes32 tokenAddr = bytes32(bytes20(address(restakeToken))); + (bool supplied, uint256 prevSupply) = exocoreGateway.getTotalSupply(clientChainId, tokenAddr); + assertTrue(supplied); + uint256 newSupply = prevSupply / 500; + bool expect = false; + (bytes32 requestId, bytes memory requestPayload) = _decreaseTotalSupplyOnExocore(newSupply); + (bytes32 responseId, bytes memory responsePayload) = + _handleTotalSupplyDecreaseOnClientChain(expect, requestId, requestPayload); + _handleSupplyResponseOnExocore(expect, responseId, responsePayload); + (bool supplied2, uint256 gotSupply) = exocoreGateway.getTotalSupply(clientChainId, tokenAddr); + assertTrue(supplied2); + assertTrue(prevSupply == gotSupply); + } + + function test_DecreaseTotalSupply_TvlLimitIncrease() public { + _increaseTvlLimitOnClientChain(19); + bytes32 tokenAddr = bytes32(bytes20(address(restakeToken))); + (bool supplied, uint256 prevSupply) = exocoreGateway.getTotalSupply(clientChainId, tokenAddr); + assertTrue(supplied); + uint256 newSupply = prevSupply - 1; + bool expect = false; + (bytes32 requestId, bytes memory requestPayload) = _decreaseTotalSupplyOnExocore(newSupply); + (bytes32 responseId, bytes memory responsePayload) = + _handleTotalSupplyDecreaseOnClientChain(expect, requestId, requestPayload); + _handleSupplyResponseOnExocore(expect, responseId, responsePayload); + (bool supplied2, uint256 gotSupply) = exocoreGateway.getTotalSupply(clientChainId, tokenAddr); + assertTrue(supplied2); + assertTrue(prevSupply == gotSupply); + } + } diff --git a/test/foundry/WithdrawReward.t.sol b/test/foundry/WithdrawReward.t.sol index 6336111b..48487b24 100644 --- a/test/foundry/WithdrawReward.t.sol +++ b/test/foundry/WithdrawReward.t.sol @@ -15,7 +15,6 @@ contract WithdrawRewardTest is ExocoreDeployer { event WithdrawRewardResult(bool indexed success, bytes32 indexed token, bytes32 indexed withdrawer, uint256 amount); event Transfer(address indexed from, address indexed to, uint256 amount); - event MessageProcessed(uint16 _srcChainId, bytes _srcAddress, uint64 _nonce, bytes _payload); uint256 constant DEFAULT_ENDPOINT_CALL_GAS_LIMIT = 200_000; @@ -36,7 +35,6 @@ contract WithdrawRewardTest is ExocoreDeployer { // first user call client chain gateway to withdraw // estimate l0 relay fee that the user should pay - uint64 withdrawRequestNonce = 1; bytes memory withdrawRequestPayload = abi.encodePacked( GatewayStorage.Action.REQUEST_WITHDRAW_REWARD_FROM_EXOCORE, bytes32(bytes20(address(restakeToken))), @@ -44,14 +42,14 @@ contract WithdrawRewardTest is ExocoreDeployer { withdrawAmount ); uint256 requestNativeFee = clientGateway.quote(withdrawRequestPayload); - bytes32 requestId = generateUID(withdrawRequestNonce, true); + bytes32 requestId = generateUID(outboundNonces[clientChainId], true); // client chain layerzero endpoint should emit the message packet including withdraw payload. vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); emit NewPacket( exocoreChainId, address(clientGateway), address(exocoreGateway).toBytes32(), - withdrawRequestNonce, + outboundNonces[clientChainId], withdrawRequestPayload ); // client chain gateway should emit MessageSent event @@ -59,7 +57,7 @@ contract WithdrawRewardTest is ExocoreDeployer { emit MessageSent( GatewayStorage.Action.REQUEST_WITHDRAW_REWARD_FROM_EXOCORE, requestId, - withdrawRequestNonce, + outboundNonces[clientChainId]++, requestNativeFee ); @@ -71,23 +69,22 @@ contract WithdrawRewardTest is ExocoreDeployer { // endpoint // exocore gateway should return response message to exocore network layerzero endpoint - uint64 withdrawResponseNonce = 3; bytes memory withdrawResponsePayload = - abi.encodePacked(GatewayStorage.Action.RESPOND, withdrawRequestNonce, true, uint256(1234)); + abi.encodePacked(GatewayStorage.Action.RESPOND, outboundNonces[clientChainId] - 1, true, uint256(1234)); uint256 responseNativeFee = exocoreGateway.quote(clientChainId, withdrawResponsePayload); - bytes32 responseId = generateUID(withdrawResponseNonce, false); + bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); vm.expectEmit(true, true, true, true, address(exocoreLzEndpoint)); emit NewPacket( clientChainId, address(exocoreGateway), address(clientGateway).toBytes32(), - withdrawResponseNonce, + outboundNonces[exocoreChainId], withdrawResponsePayload ); // exocore gateway should emit MessageSent event vm.expectEmit(true, true, true, true, address(exocoreGateway)); - emit MessageSent(GatewayStorage.Action.RESPOND, responseId, withdrawResponseNonce, responseNativeFee); + emit MessageSent(GatewayStorage.Action.RESPOND, responseId, outboundNonces[exocoreChainId]++, responseNativeFee); // exocore gateway should emit WithdrawRewardResult event vm.expectEmit(true, true, true, true, address(exocoreGateway)); @@ -95,9 +92,14 @@ contract WithdrawRewardTest is ExocoreDeployer { true, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(withdrawer.addr)), withdrawAmount ); + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted( + GatewayStorage.Action.REQUEST_WITHDRAW_REWARD_FROM_EXOCORE, inboundNonces[exocoreChainId]++ + ); + vm.startPrank(relayer.addr); exocoreLzEndpoint.lzReceive( - Origin(clientChainId, address(clientGateway).toBytes32(), withdrawRequestNonce), + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), address(exocoreGateway), requestId, withdrawRequestPayload, @@ -110,11 +112,16 @@ contract WithdrawRewardTest is ExocoreDeployer { // client chain gateway should execute the response hook and emit RequestFinished event vm.expectEmit(true, true, true, true, address(clientGateway)); - emit RequestFinished(GatewayStorage.Action.REQUEST_WITHDRAW_REWARD_FROM_EXOCORE, withdrawRequestNonce, true); + emit RequestFinished( + GatewayStorage.Action.REQUEST_WITHDRAW_REWARD_FROM_EXOCORE, outboundNonces[clientChainId] - 1, true + ); + + vm.expectEmit(address(clientGateway)); + emit MessageExecuted(GatewayStorage.Action.RESPOND, inboundNonces[clientChainId]++); vm.startPrank(relayer.addr); clientChainLzEndpoint.lzReceive( - Origin(exocoreChainId, address(exocoreGateway).toBytes32(), withdrawResponseNonce), + Origin(exocoreChainId, address(exocoreGateway).toBytes32(), inboundNonces[clientChainId] - 1), address(clientGateway), responseId, withdrawResponsePayload, diff --git a/test/foundry/unit/Bootstrap.t.sol b/test/foundry/unit/Bootstrap.t.sol index 280e7b9d..334c8e07 100644 --- a/test/foundry/unit/Bootstrap.t.sol +++ b/test/foundry/unit/Bootstrap.t.sol @@ -352,12 +352,8 @@ contract BootstrapTest is Test { address addr = addrs[0]; uint256 balance = myToken.balanceOf(addr); // reduce the TVL limit - vm.startPrank(deployer); - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = address(myToken); - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = balance / 2; - bootstrap.updateTvlLimits(whitelistTokens, tvlLimits); + vm.prank(deployer); + bootstrap.updateTvlLimit(address(myToken), balance / 2); // first approve the vault for more than the TVL limit to ensure that the error // cause isn't due to lack of approval vm.startPrank(addr); @@ -366,6 +362,7 @@ contract BootstrapTest is Test { // now attempt to deposit vm.expectRevert(Errors.VaultTvlLimitExceeded.selector); bootstrap.deposit(address(myToken), balance); + vm.stopPrank(); } // This tests whether the TVL limit is enforced correctly when the TVL limit is updated @@ -385,17 +382,14 @@ contract BootstrapTest is Test { assertTrue(vault.getConsumedTvl() == balance); // reduce the TVL limit below the total deposited amount - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = address(myToken); - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = balance / 2; + uint256 newTvlLimit = balance / 2; vm.startPrank(deployer); - bootstrap.updateTvlLimits(whitelistTokens, tvlLimits); + bootstrap.updateTvlLimit(address(myToken), newTvlLimit); vm.stopPrank(); assertTrue(vault.getConsumedTvl() == balance); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getTvlLimit() == newTvlLimit); // now attempt to withdraw, which should go through vm.startPrank(addr); @@ -404,7 +398,7 @@ contract BootstrapTest is Test { vm.stopPrank(); assertTrue(vault.getConsumedTvl() == balance - withdrawAmount); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getTvlLimit() == newTvlLimit); // try to deposit, which will fail vm.startPrank(addr); @@ -413,7 +407,7 @@ contract BootstrapTest is Test { vm.stopPrank(); assertTrue(vault.getConsumedTvl() == balance - withdrawAmount); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getTvlLimit() == newTvlLimit); // withdraw to get just below tvl limit withdrawAmount = vault.getConsumedTvl() - vault.getTvlLimit() + 1; @@ -422,16 +416,16 @@ contract BootstrapTest is Test { bootstrap.claim(address(myToken), withdrawAmount, addr); vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == tvlLimits[0] - 1); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getConsumedTvl() == newTvlLimit - 1); + assertTrue(vault.getTvlLimit() == newTvlLimit); // then deposit a single unit, which should go through vm.startPrank(addr); bootstrap.deposit(address(myToken), 1); vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == tvlLimits[0]); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getConsumedTvl() == newTvlLimit); + assertTrue(vault.getTvlLimit() == newTvlLimit); // no more deposits should be allowed vm.startPrank(addr); @@ -439,8 +433,8 @@ contract BootstrapTest is Test { bootstrap.deposit(address(myToken), 1); vm.stopPrank(); - assertTrue(vault.getConsumedTvl() == tvlLimits[0]); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + assertTrue(vault.getConsumedTvl() == newTvlLimit); + assertTrue(vault.getTvlLimit() == newTvlLimit); } function test03_RegisterValidator() public { @@ -716,24 +710,17 @@ contract BootstrapTest is Test { function test07_UpdateTvlLimits() public { IVault vault = bootstrap.tokenToVault(address(myToken)); - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = address(myToken); - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = vault.getTvlLimit() * 2; // double the TVL limit - vm.startPrank(deployer); - bootstrap.updateTvlLimits(whitelistTokens, tvlLimits); - vm.stopPrank(); - assertTrue(vault.getTvlLimit() == tvlLimits[0]); + uint256 newLimit = vault.getTvlLimit() * 2; // double the TVL limit + vm.prank(deployer); + bootstrap.updateTvlLimit(address(myToken), newLimit); + assertTrue(vault.getTvlLimit() == newLimit); } function test07_UpdateTvlLimits_NotWhitelisted() public { - address[] memory whitelistTokens = new address[](1); - whitelistTokens[0] = address(0xa); - uint256[] memory tvlLimits = new uint256[](1); - tvlLimits[0] = 500; vm.startPrank(deployer); - vm.expectRevert(abi.encodeWithSelector(Errors.TokenNotWhitelisted.selector, whitelistTokens[0])); - bootstrap.updateTvlLimits(whitelistTokens, tvlLimits); + address addr = address(0xa); + vm.expectRevert(abi.encodeWithSelector(Errors.TokenNotWhitelisted.selector, addr)); + bootstrap.updateTvlLimit(addr, 5); vm.stopPrank(); } @@ -746,7 +733,7 @@ contract BootstrapTest is Test { // first add token to whitelist bootstrap.addWhitelistTokens(whitelistTokens, tvlLimits); vm.expectRevert(Errors.NoTvlLimitForNativeRestaking.selector); - bootstrap.updateTvlLimits(whitelistTokens, tvlLimits); + bootstrap.updateTvlLimit(whitelistTokens[0], tvlLimits[0] * 2); vm.stopPrank(); } @@ -823,18 +810,12 @@ contract BootstrapTest is Test { bootstrap.delegateTo("exo13hasr43vvq8v44xpzh0l6yuym4kca98f87j7ac", address(0xa), amounts[0]); } - function test09_DelegateTo_NotEnoughBlance() public { + function test09_DelegateTo_NotEnoughBalance() public { test03_RegisterValidator(); - vm.startPrank(deployer); - address[] memory addedWhitelistTokens = new address[](1); - addedWhitelistTokens[0] = address(0xa); - uint256[] memory addedTvlLimits = new uint256[](1); - addedTvlLimits[0] = 1000 * 10 ** 18; - bootstrap.addWhitelistTokens(addedWhitelistTokens, addedTvlLimits); - vm.stopPrank(); + MyToken myToken = test01_AddWhitelistToken(); vm.startPrank(addrs[0]); vm.expectRevert(Errors.BootstrapInsufficientWithdrawableBalance.selector); - bootstrap.delegateTo("exo13hasr43vvq8v44xpzh0l6yuym4kca98f87j7ac", address(0xa), amounts[0]); + bootstrap.delegateTo("exo13hasr43vvq8v44xpzh0l6yuym4kca98f87j7ac", address(myToken), amounts[0]); } function test09_DelegateTo_ZeroAmount() public { @@ -919,16 +900,10 @@ contract BootstrapTest is Test { function test10_UndelegateFrom_NotEnoughBalance() public { test03_RegisterValidator(); - vm.startPrank(deployer); - address[] memory addedWhitelistTokens = new address[](1); - addedWhitelistTokens[0] = address(0xa); - uint256[] memory addedTvlLimits = new uint256[](1); - addedTvlLimits[0] = 1000 * 10 ** 18; - bootstrap.addWhitelistTokens(addedWhitelistTokens, addedTvlLimits); - vm.stopPrank(); + MyToken myToken = test01_AddWhitelistToken(); vm.startPrank(addrs[0]); vm.expectRevert(Errors.BootstrapInsufficientDelegatedBalance.selector); - bootstrap.undelegateFrom("exo13hasr43vvq8v44xpzh0l6yuym4kca98f87j7ac", address(0xa), amounts[0]); + bootstrap.undelegateFrom("exo13hasr43vvq8v44xpzh0l6yuym4kca98f87j7ac", address(myToken), amounts[0]); } function test10_UndelegateFrom_ZeroAmount() public { diff --git a/test/foundry/unit/ExocoreGateway.t.sol b/test/foundry/unit/ExocoreGateway.t.sol index 5d68eb11..288b1ab8 100644 --- a/test/foundry/unit/ExocoreGateway.t.sol +++ b/test/foundry/unit/ExocoreGateway.t.sol @@ -590,6 +590,7 @@ contract AddWhitelistTokens is SetUp { vm.startPrank(exocoreValidatorSet.addr); vm.expectEmit(address(exocoreGateway)); emit WhitelistTokenAdded(clientChainId, bytes32(bytes20(address(restakeToken)))); + vm.expectEmit(address(exocoreGateway)); emit MessageSent(GatewayStorage.Action.REQUEST_ADD_WHITELIST_TOKEN, generateUID(1, false), 1, nativeFee); exocoreGateway.addWhitelistToken{value: nativeFee}( clientChainId, @@ -631,6 +632,7 @@ contract UpdateWhitelistTokens is SetUp { vm.startPrank(exocoreValidatorSet.addr); vm.expectEmit(address(exocoreGateway)); emit WhitelistTokenAdded(clientChainId, bytes32(bytes20(address(restakeToken)))); + vm.expectEmit(address(exocoreGateway)); emit MessageSent(GatewayStorage.Action.REQUEST_ADD_WHITELIST_TOKEN, generateUID(1, false), 1, nativeFee); exocoreGateway.addWhitelistToken{value: nativeFee}( clientChainId, diff --git a/test/mocks/AssetsMock.sol b/test/mocks/AssetsMock.sol index 0bd5592e..75e24cda 100644 --- a/test/mocks/AssetsMock.sol +++ b/test/mocks/AssetsMock.sol @@ -11,6 +11,7 @@ contract AssetsMock is IAssets { uint32[] internal chainIds; mapping(uint32 chainId => bool registered) public isRegisteredChain; mapping(uint32 chainId => mapping(bytes token => bool registered)) public isRegisteredToken; + mapping(uint32 chainId => mapping(bytes token => uint256 totalSuppy)) public totalSupplies; function depositTo(uint32 clientChainLzId, bytes memory assetsAddress, bytes memory stakerAddress, uint256 opAmount) external @@ -69,7 +70,7 @@ contract AssetsMock is IAssets { uint32 clientChainId, bytes calldata token, uint8 decimals, - uint256 tvlLimit, + uint256 totalSupply, string calldata name, string calldata metaData, string calldata oracleInfo @@ -80,11 +81,11 @@ contract AssetsMock is IAssets { return false; } isRegisteredToken[clientChainId][token] = true; - + totalSupplies[clientChainId][token] = totalSupply; return true; } - function updateToken(uint32 clientChainId, bytes calldata token, uint256 tvlLimit, string calldata metaData) + function updateToken(uint32 clientChainId, bytes calldata token, uint256 totalSupply, string calldata metaData) external returns (bool success) { @@ -94,6 +95,8 @@ contract AssetsMock is IAssets { return false; } + totalSupplies[clientChainId][token] = totalSupply; + return true; } @@ -113,4 +116,15 @@ contract AssetsMock is IAssets { return (true, isRegisteredChain[clientChainID]); } + function getTotalSupply(uint32 clientChainId, bytes calldata token) + external + view + returns (bool success, uint256 totalSupply) + { + if (!isRegisteredToken[clientChainId][token]) { + return (false, 0); + } + return (true, totalSupplies[clientChainId][token]); + } + } diff --git a/test/mocks/ExocoreGatewayMock.sol b/test/mocks/ExocoreGatewayMock.sol index 1198b05a..fe8f467b 100644 --- a/test/mocks/ExocoreGatewayMock.sol +++ b/test/mocks/ExocoreGatewayMock.sol @@ -185,6 +185,7 @@ contract ExocoreGatewayMock is require(bytes(name).length != 0, "ExocoreGateway: name cannot be empty"); require(bytes(metaData).length != 0, "ExocoreGateway: meta data cannot be empty"); require(bytes(oracleInfo).length != 0, "ExocoreGateway: oracleInfo cannot be empty"); + require(totalSupply >= tvlLimit, "ExocoreGateway: total supply should be greater than or equal to TVL limit"); // setting a tvl limit of 0 is psermitted to add an inactive token, which will be later // activated on the client chain @@ -209,22 +210,54 @@ contract ExocoreGatewayMock is function updateWhitelistToken(uint32 clientChainId, bytes32 token, uint256 totalSupply, string calldata metaData) external + payable onlyOwner whenNotPaused nonReentrant { require(clientChainId != 0, "ExocoreGateway: client chain id cannot be zero"); require(token != bytes32(0), "ExocoreGateway: token cannot be zero address"); - require(totalSupply > 0, "ExocoreGateway: total supply should not be zero"); + // it is possible to set total supply to 0 if the tvl limit on the client chain gateway is 0, and if there + // are no deposits at all. // empty metaData indicates that the token's metadata should not be updated - bool success = ASSETS_CONTRACT.updateToken(clientChainId, abi.encodePacked(token), totalSupply, metaData); - if (success) { - emit WhitelistTokenUpdated(clientChainId, token); + (bool success, uint256 previousSupply) = ASSETS_CONTRACT.getTotalSupply(clientChainId, abi.encodePacked(token)); + if (!success) { + // safe to revert since this is not an LZ message so far + revert FailedToGetTotalSupply(clientChainId, token); + } + if (totalSupply >= previousSupply) { + // supply increase is always permitted without any checks + if (msg.value > 0) { + revert Errors.NonZeroValue(); + } + success = ASSETS_CONTRACT.updateToken(clientChainId, abi.encodePacked(token), totalSupply, metaData); + if (success) { + emit WhitelistTokenUpdated(clientChainId, token); + } else { + revert UpdateWhitelistTokenFailed(clientChainId, token); + } } else { - revert UpdateWhitelistTokenFailed(clientChainId, token); + require(bytes(metaData).length == 0, "ExocoreGateway: metadata should be empty for supply decrease"); + // supply decrease is only permitted if tvl limit <= total supply + supplyDecreaseInFlight[clientChainId][token] = true; + uint64 requestNonce = _sendInterchainMsg( + clientChainId, Action.REQUEST_VALIDATE_LIMITS, abi.encodePacked(token, totalSupply), false + ); + // there is only one type of outgoing request for which we expect a response so no need to store + // too much information + _registeredRequests[requestNonce] = abi.encode(token, totalSupply); } } + /// @inheritdoc IExocoreGateway + function getTotalSupply(uint32 clientChainId, bytes32 token) + external + view + returns (bool success, uint256 totalSupply) + { + return ASSETS_CONTRACT.getTotalSupply(clientChainId, abi.encodePacked(token)); + } + function _validateClientChainIdRegistered(uint32 clientChainId) internal view { (bool success, bool isRegistered) = ASSETS_CONTRACT.isRegisteredClientChain(clientChainId); if (!success) { @@ -260,16 +293,64 @@ contract ExocoreGatewayMock is _verifyAndUpdateNonce(_origin.srcEid, _origin.sender, _origin.nonce); Action act = Action(uint8(payload[0])); - bytes4 selector_ = _whiteListFunctionSelectors[act]; - if (selector_ == bytes4(0)) { - revert UnsupportedRequest(act); + if (act == Action.RESPOND) { + _handleResponse(_origin.srcEid, payload[1:]); + } else { + bytes4 selector_ = _whiteListFunctionSelectors[act]; + if (selector_ == bytes4(0)) { + revert UnsupportedRequest(act); + } + + (bool success, bytes memory responseOrReason) = + address(this).call(abi.encodePacked(selector_, abi.encode(_origin.srcEid, _origin.nonce, payload[1:]))); + if (!success) { + revert RequestExecuteFailed(act, _origin.nonce, responseOrReason); + } } - (bool success, bytes memory responseOrReason) = - address(this).call(abi.encodePacked(selector_, abi.encode(_origin.srcEid, _origin.nonce, payload[1:]))); - if (!success) { - revert RequestExecuteFailed(act, _origin.nonce, responseOrReason); + emit MessageExecuted(act, _origin.nonce); + } + + function _handleResponse(uint32 clientChainId, bytes calldata response) internal { + // only one type of response is supported + _validatePayloadLength(response, VALIDATE_LIMITS_RESPONSE_LENGTH, Action.RESPOND); + uint64 lzNonce = uint64(bytes8(response[0:8])); + (bytes32 token, uint256 totalSupply) = abi.decode(_registeredRequests[lzNonce], (bytes32, uint256)); + if (uint8(bytes1(response[8])) == 1) { + // the validation succeeded, so apply the edit to total supply + bool updated = ASSETS_CONTRACT.updateToken(clientChainId, abi.encodePacked(token), totalSupply, ""); + if (!updated) { + emit UpdateWhitelistTokenFailedOnResponse(clientChainId, token); + } else { + emit WhitelistTokenUpdated(clientChainId, token); + } + } else { + emit WhitelistTokenNotUpdated(clientChainId, token); } + delete _registeredRequests[lzNonce]; + supplyDecreaseInFlight[clientChainId][token] = false; + return; + } + + function requestValidateLimits(uint32 srcChainId, uint64 lzNonce, bytes calldata payload) + public + onlyCalledFromThis + { + _validatePayloadLength(payload, VALIDATE_LIMITS_REQUEST_LENGTH, Action.REQUEST_VALIDATE_LIMITS); + + bytes memory token = payload[:32]; + uint256 tvlLimit = uint256(bytes32(payload[32:64])); + + (bool success, uint256 totalSupply) = ASSETS_CONTRACT.getTotalSupply(srcChainId, token); + + _sendInterchainMsg( + srcChainId, + Action.RESPOND, + abi.encodePacked( + lzNonce, success && tvlLimit <= totalSupply && !supplyDecreaseInFlight[srcChainId][bytes32(token)] + ), + true + ); } function requestDeposit(uint32 srcChainId, uint64 lzNonce, bytes calldata payload) public onlyCalledFromThis { @@ -412,6 +493,7 @@ contract ExocoreGatewayMock is function _sendInterchainMsg(uint32 srcChainId, Action act, bytes memory actionArgs, bool payByApp) internal whenNotPaused + returns (uint64) { bytes memory payload = abi.encodePacked(act, actionArgs); bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption( @@ -422,6 +504,8 @@ contract ExocoreGatewayMock is MessagingReceipt memory receipt = _lzSend(srcChainId, payload, options, MessagingFee(fee.nativeFee, 0), msg.sender, payByApp); emit MessageSent(act, receipt.guid, receipt.nonce, receipt.fee.nativeFee); + + return receipt.nonce; } function quote(uint32 srcChainid, bytes memory _message) public view returns (uint256 nativeFee) {