Skip to content

Commit

Permalink
feat: tvl limits on the client chain (#93)
Browse files Browse the repository at this point in the history
* feat(exo-gateway): add oracleInfo

ExocoreNetwork/exocore#159 adds a new `oracleInfo` parameter to the
`IAssets.sol` precompile when registering tokens. This PR follows that
change.

Previously, multiple tokens could be registered with a single call to
ExocoreGateway. However, that resulted in too many variables (and too
much gas) for Solidity to handle within a single function. To that end,
and keeping in mind that addition of new tokens isn't likely to be a
frequent occurrence, the function's capabilities have been tempered to
support only a single token within the transaction.

As a side effect, this fixes #80

* chore(fmt): forge fmt with newer nightly

* doc: update comments

* fix(test): update test from merge

* forge fmt

* feat(assets): split precompile into add/update

Since only certain parameters can be updated (TVL limit and metadata),
it does not make sense to use the same function for token additions and
updates

* fix: accept empty metadata

* fix: remove superfluous check

* forge fmt

* doc: fix outdated comments

* feat(tvl-limit): impose tvl limit on client chain

We can consider the TVL limits to be of two types: one is the logical
TVL limit which disallows deposits from exceeding total supply and the
other is the operational TVL limit which is used for risk management
during the launch phase of the network.

The former is imposed by Exocore's precompiles while the latter is
imposed by the client chain's gateway contracts. Both fields are
editable by the contract owners: the total supply in response to minting
and burning and the deposit limits with time to larger values.

As a consequence of the introduction of a limit on the client chain, it
must now be provided at the time of token registration. It can be
updated or reduced unilaterally on the client chain.

While editing the `totalSupply` on Exocore, it is best to be fully
accurate. Reducing the `totalSupply` to lower than the TVL limit
configured on the client chain will result in failed deposits which may
produce a system halt. Even if the TVL limit is reduced on the client
chain after this, it can impact the messages that are already in-flight.

* fix(doc): update IAssets comment

* 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.

* test: validate nonces once done

* feat(bootstrap): reject if tvl limit > supply

* fix: use a count of in-flight messages, not bool

* fix: native restake limit + non whitelist check

* fix: index requests by chainId and nonce

Mistakenly, only the nonce was used but that is not accurate

* fix: simplify TVL limit and total supply limit

Based on the discussion, it was decided to remove the total supply limit
enforced by Exocore. Instead, the TVL limit will be imposed on the
client chain, which will also enforce that any transfers more than the
total supply will fail.

It is possible to set a value of infinite TVL limit by using
`type(uin256).max`

* fix(script): print ProxyAdmin address to JSON

* feat(ci): fail compilation if code size is large

Within Foundry's configuration, the `deny_warnings` boolean may be set
to `true` to force it to report a compilation failure in the case of
warnings. With the `code-size` warning enabled, this will cause a build
failure and report to the developer.

* fix: remove unused variables

* fix: remove some more unused fields

* feat: use `uint128` for tvl limit on exo gateway
  • Loading branch information
MaxMustermann2 authored Sep 14, 2024
1 parent 4849b27 commit 9b5e7e0
Show file tree
Hide file tree
Showing 35 changed files with 1,116 additions and 347 deletions.
3 changes: 3 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ evm_version = "paris"
ignored_error_codes = [5667]
# ignore warnings from script folder and test folder
ignored_warnings_from = ["script", "test"]
# fail compilation if the warnings are not fixed.
# this is super useful for the code size warning.
deny_warnings = true

[rpc_endpoints]
ethereum_local_rpc = "${ETHEREUM_LOCAL_RPC}"
Expand Down
1 change: 1 addition & 0 deletions script/10_DeployExocoreGatewayOnly.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ contract DeployExocoreGatewayOnly is BaseScript {
string memory exocoreContracts = "exocoreContracts";
vm.serializeAddress(exocoreContracts, "lzEndpoint", address(exocoreLzEndpoint));
vm.serializeAddress(exocoreContracts, "exocoreGatewayLogic", address(exocoreGatewayLogic));
vm.serializeAddress(exocoreContracts, "exocoreProxyAdmin", address(exocoreProxyAdmin));
string memory exocoreContractsOutput =
vm.serializeAddress(exocoreContracts, "exocoreGateway", address(exocoreGateway));

Expand Down
2 changes: 2 additions & 0 deletions script/14_CorrectBootstrapErrors.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ contract CorrectBootstrapErrors is BaseScript {

function run() public {
address[] memory emptyList;
uint256[] memory emptyListUint;

vm.selectFork(clientChain);
vm.startBroadcast(exocoreValidatorSet.privateKey);
Expand All @@ -86,6 +87,7 @@ contract CorrectBootstrapErrors is BaseScript {
block.timestamp + 168 hours,
2 seconds,
emptyList,
emptyListUint,
address(proxyAdmin),
address(clientGateway),
initialization
Expand Down
10 changes: 5 additions & 5 deletions script/3_Setup.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -94,26 +94,26 @@ contract SetupScript is BaseScript {
// first we read decimals from client chain ERC20 token contract to prepare for token data
bytes32[] memory whitelistTokensBytes32 = new bytes32[](2);
uint8[] memory decimals = new uint8[](2);
uint256[] memory tvlLimits = new uint256[](2);
string[] memory names = new string[](2);
string[] memory metaDatas = new string[](2);
string[] memory oracleInfos = new string[](2);
uint128[] memory tvlLimits = new uint128[](2);

// this stands for LST restaking for restakeToken
whitelistTokensBytes32[0] = bytes32(bytes20(address(restakeToken)));
decimals[0] = restakeToken.decimals();
tvlLimits[0] = 1e10 ether;
names[0] = "RestakeToken";
metaDatas[0] = "ERC20 LST token";
oracleInfos[0] = "{'a': 'b'}";
tvlLimits[0] = uint128(restakeToken.totalSupply() / 5); // in phases of 20%

// this stands for Native Restaking for ETH
whitelistTokensBytes32[1] = bytes32(bytes20(VIRTUAL_STAKED_ETH_ADDRESS));
decimals[1] = 18;
tvlLimits[1] = 1e8 ether;
names[1] = "StakedETH";
metaDatas[1] = "natively staked ETH on Ethereum";
oracleInfos[1] = "{'b': 'a'}";
tvlLimits[1] = 0; // irrelevant for native restaking

// second add whitelist tokens and their meta data on Exocore side to enable LST Restaking and Native Restaking,
// and this would also add token addresses to client chain gateway's whitelist
Expand All @@ -129,10 +129,10 @@ contract SetupScript is BaseScript {
clientChainId,
whitelistTokensBytes32[i],
decimals[i],
tvlLimits[i],
names[i],
metaDatas[i],
oracleInfos[i]
oracleInfos[i],
tvlLimits[i]
);
}
vm.stopBroadcast();
Expand Down
4 changes: 4 additions & 0 deletions script/7_DeployBootstrap.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ contract DeployBootstrapOnly is BaseScript {
vm.selectFork(clientChain);
vm.startBroadcast(exocoreValidatorSet.privateKey);
whitelistTokens.push(address(restakeToken));
tvlLimits.push(restakeToken.totalSupply() / 20);
whitelistTokens.push(wstETH);
// doesn't matter if it's actually ERC20PresetFixedSupply, just need the total supply
tvlLimits.push(ERC20PresetFixedSupply(wstETH).totalSupply() / 20);

// proxy deployment
CustomProxyAdmin proxyAdmin = new CustomProxyAdmin();
Expand Down Expand Up @@ -91,6 +94,7 @@ contract DeployBootstrapOnly is BaseScript {
block.timestamp + 168 hours,
2 seconds,
whitelistTokens, // vault is auto deployed
tvlLimits,
address(proxyAdmin),
address(clientGatewayLogic),
initialization
Expand Down
2 changes: 1 addition & 1 deletion script/BaseScript.sol
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ contract BaseScript is Script, StdCheats {
string exocoreRPCURL;

address[] whitelistTokens;
address[] vaults;
uint256[] tvlLimits;

IClientChainGateway clientGateway;
IVault vault;
Expand Down
1 change: 1 addition & 0 deletions script/deployedExocoreGatewayOnly.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"exocore": {
"exocoreGateway": "0xbf5497Deb7C409623Ad58D798E73a865a80873DD",
"exocoreGatewayLogic": "0xdd359906110Ae307eeCdE5d1179ab59461152348",
"exocoreProxyAdmin": "0xbf75076c383a8dede075faa250de4abdb6a15d76",
"lzEndpoint": "0x6EDCE65403992e310A62460808c4b910D972f10f"
}
}
3 changes: 3 additions & 0 deletions script/integration/1_DeployBootstrap.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ contract DeployContracts is Script {
uint256[] tokenDeployers;
uint8[2] decimals = [18, 6];
address[] whitelistTokens;
uint256[] tvlLimits;
Vault[] vaults;
CustomProxyAdmin proxyAdmin;

Expand Down Expand Up @@ -97,6 +98,7 @@ contract DeployContracts is Script {
vm.startBroadcast(tokenDeployers[i]);
MyToken myToken = new MyToken(names[i], symbols[i], decimals[i], initialAddresses, initialBalances[i]);
whitelistTokens.push(address(myToken));
tvlLimits.push(myToken.totalSupply() / ((i + 1) * 20));
vm.stopBroadcast();
}
}
Expand Down Expand Up @@ -131,6 +133,7 @@ contract DeployContracts is Script {
block.timestamp + 3 minutes,
1 seconds,
whitelistTokens,
tvlLimits,
address(proxyAdmin),
address(0x1), // these values don't matter for the localnet generate.js test
bytes("123456")
Expand Down
1 change: 0 additions & 1 deletion src/core/BaseRestakingController.sol
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ 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;
}
Expand Down
46 changes: 38 additions & 8 deletions src/core/Bootstrap.sol
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,14 @@ contract Bootstrap is
/// @param spawnTime_ The spawn time of the Exocore chain.
/// @param offsetDuration_ The offset duration before the spawn time.
/// @param whitelistTokens_ The list of whitelisted tokens.
/// @param tvlLimits_ The list of TVL limits for the tokens, in the same order as the whitelist.
/// @param customProxyAdmin_ The address of the custom proxy admin.
function initialize(
address owner,
uint256 spawnTime_,
uint256 offsetDuration_,
address[] calldata whitelistTokens_,
uint256[] calldata tvlLimits_,
address customProxyAdmin_,
address clientChainGatewayLogic_,
bytes calldata clientChainInitializationData_
Expand All @@ -79,7 +81,7 @@ contract Bootstrap is
revert Errors.ZeroAddress();
}

_addWhitelistTokens(whitelistTokens_);
_addWhitelistTokens(whitelistTokens_, tvlLimits_);

_whiteListFunctionSelectors[Action.REQUEST_MARK_BOOTSTRAP] = this.markBootstrapped.selector;

Expand Down Expand Up @@ -175,26 +177,35 @@ contract Bootstrap is
}

/// @inheritdoc ITokenWhitelister
function addWhitelistTokens(address[] calldata tokens) external beforeLocked onlyOwner whenNotPaused {
_addWhitelistTokens(tokens);
function addWhitelistTokens(address[] calldata tokens, uint256[] calldata tvlLimits)
external
beforeLocked
onlyOwner
whenNotPaused
{
_addWhitelistTokens(tokens, tvlLimits);
}

/// @dev The internal function to add tokens to the whitelist.
/// @param tokens The list of token addresses to be added to the whitelist.
/// @param tvlLimits The list of TVL limits for the corresponding tokens.
// Though `_deployVault` would make external call to newly created `Vault` contract and initialize it,
// `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 _addWhitelistTokens(address[] calldata tokens) internal {
function _addWhitelistTokens(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 (token == address(0)) {
revert Errors.ZeroAddress();
}
if (isWhitelistedToken[token]) {
revert Errors.BootstrapAlreadyWhitelisted(token);
}

whitelistTokens.push(token);
isWhitelistedToken[token] = true;

Expand All @@ -203,7 +214,10 @@ 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) {
_deployVault(token);
// setting a tvlLimit higher than the supply is permitted.
// it allows for some margin for minting of the token, and lets us use
// a value of type(uint256).max to indicate no limit.
_deployVault(token, tvlLimit);
}

emit WhitelistTokenAdded(token);
Expand All @@ -212,7 +226,19 @@ contract Bootstrap is

/// @inheritdoc ITokenWhitelister
function getWhitelistedTokensCount() external view returns (uint256) {
return whitelistTokens.length;
return _getWhitelistedTokensCount();
}

/// @inheritdoc ITokenWhitelister
function updateTvlLimit(address token, uint256 tvlLimit) external beforeLocked onlyOwner whenNotPaused {
if (!isWhitelistedToken[token]) {
revert Errors.TokenNotWhitelisted(token);
}
if (token == VIRTUAL_STAKED_ETH_ADDRESS) {
revert Errors.NoTvlLimitForNativeRestaking();
}
IVault vault = _getVault(token);
vault.setTvlLimit(tvlLimit);
}

/// @inheritdoc IValidatorRegistry
Expand Down Expand Up @@ -425,6 +451,11 @@ contract Bootstrap is
isValidAmount(amount)
nonReentrant // because it interacts with vault
{
if (recipient == address(0)) {
revert Errors.ZeroAddress();
}
// getting a vault for native restaked token will fail so no need to check that.
// if native restaking is supported in Bootstrap someday, that will change.
IVault vault = _getVault(token);
vault.withdraw(msg.sender, recipient, amount);
}
Expand Down Expand Up @@ -650,7 +681,6 @@ contract Bootstrap is
symbol: token.symbol(),
tokenAddress: tokenAddress,
decimals: token.decimals(),
totalSupply: token.totalSupply(),
depositAmount: depositsByToken[tokenAddress]
});
}
Expand Down
28 changes: 25 additions & 3 deletions src/core/ClientChainGateway.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
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";
Expand All @@ -26,6 +29,7 @@ contract ClientChainGateway is
PausableUpgradeable,
OwnableUpgradeable,
IClientChainGateway,
ITokenWhitelister,
LSTRestakingController,
NativeRestakingController,
ClientGatewayLzReceiver
Expand Down Expand Up @@ -108,10 +112,28 @@ contract ClientChainGateway is
_unpause();
}

/// @notice Gets the count of whitelisted tokens.
/// @return The count of whitelisted tokens.
/// @inheritdoc ITokenWhitelister
function getWhitelistedTokensCount() external view returns (uint256) {
return whitelistTokens.length;
return _getWhitelistedTokensCount();
}

/// @inheritdoc ITokenWhitelister
function addWhitelistTokens(address[] calldata, uint256[] calldata) external view onlyOwner whenNotPaused {
revert Errors.ClientChainGatewayTokenAdditionViaExocore();
}

/// @inheritdoc ITokenWhitelister
function updateTvlLimit(address token, uint256 tvlLimit) external 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);
vault.setTvlLimit(tvlLimit);
}

/// @inheritdoc IClientChainGateway
Expand Down
43 changes: 27 additions & 16 deletions src/core/ClientGatewayLzReceiver.sol
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp
revert RequestOrResponseExecuteFailed(act, _origin.nonce, reason);
}
}
emit MessageExecuted(act, _origin.nonce);
}

/// @inheritdoc OAppReceiverUpgradeable
Expand Down Expand Up @@ -291,31 +292,21 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp
}

/// @notice Called after an add-whitelist-token response is received.
/// @param requestPayload The request payload.
/// @param payload The request payload.
// Though `_deployVault` would make external call to newly created `Vault` contract and initialize it,
// `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)
public
onlyCalledFromThis
whenNotPaused
{
address token = address(bytes20(abi.decode(requestPayload, (bytes32))));
if (token == address(0)) {
revert Errors.ZeroAddress();
}
if (isWhitelistedToken[token]) {
// grave error, should never happen
revert Errors.ClientChainGatewayAlreadyWhitelisted(token);
}
function afterReceiveAddWhitelistTokenRequest(bytes calldata payload) public onlyCalledFromThis whenNotPaused {
_validatePayloadLength(payload, ADD_TOKEN_WHITELIST_REQUEST_LENGTH, Action.REQUEST_ADD_WHITELIST_TOKEN);
(address token, uint128 tvlLimit) = _decodeTokenUint128(payload);
isWhitelistedToken[token] = true;
whitelistTokens.push(token);
// since tokens cannot be removed from the whitelist, it is not possible for a vault
// to already exist. however, we should still ensure that a vault is not deployed for
// restaking native staked eth
// restaking native staked eth. in this case, the tvlLimit is ignored.
if (token != VIRTUAL_STAKED_ETH_ADDRESS) {
_deployVault(token);
_deployVault(token, uint256(tvlLimit));
}
emit WhitelistTokenAdded(token);
}
Expand All @@ -328,4 +319,24 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp
emit BootstrappedAlready();
}

/// @dev Decodes a token and a uint128 from a payload. If the token isn't whitelisted, it
/// reverts.
/// @param payload The payload to decode.
/// @return token The token address
/// @return value The uint128 value
function _decodeTokenUint128(bytes calldata payload) internal view returns (address, uint128) {
bytes32 tokenAsBytes32 = bytes32(payload[:32]);
address token = address(bytes20(tokenAsBytes32));
if (token == address(0)) {
// cannot happen since the precompiles check for this
revert Errors.ZeroAddress();
}
if (isWhitelistedToken[token]) {
// we are receiving a request to whitelist a token that is already whitelisted
revert Errors.ClientChainGatewayAlreadyWhitelisted(token);
}
uint128 value = uint128(bytes16(payload[32:]));
return (token, value);
}

}
Loading

0 comments on commit 9b5e7e0

Please sign in to comment.