diff --git a/.gitignore b/.gitignore index 359ea6a55..597dee77d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.devcontainer .gitignore .env node_modules @@ -26,4 +27,4 @@ gambit_out/ test-mutation/mutant_test_env/ # bridged usdc deployment script -registerUsdcGatewayTx.json \ No newline at end of file +registerUsdcGatewayTx.json diff --git a/README.md b/README.md index 2fade72f6..7ca011d44 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,4 @@ Check [this doc](./docs/deployment.md) for instructions on deployment and verifi Discord: [Arbitrum](https://discord.com/invite/5KE54JwyTs) Twitter: [Arbitrum](https://twitter.com/arbitrum) + diff --git a/contracts/tokenbridge/arbitrum/L2AtomicTokenBridgeFactory.sol b/contracts/tokenbridge/arbitrum/L2AtomicTokenBridgeFactory.sol index 2b09c40b4..c50723517 100644 --- a/contracts/tokenbridge/arbitrum/L2AtomicTokenBridgeFactory.sol +++ b/contracts/tokenbridge/arbitrum/L2AtomicTokenBridgeFactory.sol @@ -305,6 +305,7 @@ library OrbitSalts { bytes internal constant L1_STANDARD_GATEWAY = bytes("L1SGW"); bytes internal constant L1_CUSTOM_GATEWAY = bytes("L1CGW"); bytes internal constant L1_WETH_GATEWAY = bytes("L1WGW"); + bytes internal constant L1_MASTER_VAULT_FACTORY = bytes("L1MVF"); bytes internal constant L2_PROXY_ADMIN = bytes("L2PA"); bytes internal constant L2_ROUTER = bytes("L2R"); diff --git a/contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol b/contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol index 571568cc5..5f720acc7 100644 --- a/contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol +++ b/contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol @@ -13,12 +13,8 @@ import { SafeERC20 } from "./L1TokenBridgeRetryableSender.sol"; import {L1GatewayRouter} from "./gateway/L1GatewayRouter.sol"; -import {L1ERC20Gateway} from "./gateway/L1ERC20Gateway.sol"; -import {L1CustomGateway} from "./gateway/L1CustomGateway.sol"; -import {L1WethGateway} from "./gateway/L1WethGateway.sol"; import {L1OrbitGatewayRouter} from "./gateway/L1OrbitGatewayRouter.sol"; -import {L1OrbitERC20Gateway} from "./gateway/L1OrbitERC20Gateway.sol"; -import {L1OrbitCustomGateway} from "./gateway/L1OrbitCustomGateway.sol"; +import {L1GatewayDeployer} from "./L1GatewayDeployer.sol"; import { L2AtomicTokenBridgeFactory, OrbitSalts, @@ -31,18 +27,20 @@ import { UpgradeExecutor } from "@offchainlabs/upgrade-executor/src/UpgradeExecutor.sol"; import {AddressAliasHelper} from "../libraries/AddressAliasHelper.sol"; -import {IInbox, IBridge, IOwnable} from "@arbitrum/nitro-contracts/src/bridge/IInbox.sol"; +import {IInbox} from "@arbitrum/nitro-contracts/src/bridge/IInbox.sol"; import {ArbMulticall2} from "../../rpc-utils/MulticallV2.sol"; -import {BeaconProxyFactory, ClonableBeaconProxy} from "../libraries/ClonableBeaconProxy.sol"; +import {BeaconProxyFactory} from "../libraries/ClonableBeaconProxy.sol"; import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; import { Initializable, OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import {TransparentUpgradeableProxy} from - "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {IAccessControlUpgradeable} from - "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import { + TransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { + IAccessControlUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; /** * @title Layer1 token bridge creator @@ -73,15 +71,32 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { struct L1Templates { L1GatewayRouter routerTemplate; - L1ERC20Gateway standardGatewayTemplate; - L1CustomGateway customGatewayTemplate; - L1WethGateway wethGatewayTemplate; + address standardGatewayTemplate; + address customGatewayTemplate; + address wethGatewayTemplate; L1OrbitGatewayRouter feeTokenBasedRouterTemplate; - L1OrbitERC20Gateway feeTokenBasedStandardGatewayTemplate; - L1OrbitCustomGateway feeTokenBasedCustomGatewayTemplate; + address feeTokenBasedStandardGatewayTemplate; + address feeTokenBasedCustomGatewayTemplate; IUpgradeExecutor upgradeExecutor; } + struct YbbL1Templates { + address ybbStandardGatewayTemplate; + address ybbCustomGatewayTemplate; + address feeTokenBasedYbbStandardGatewayTemplate; + address feeTokenBasedYbbCustomGatewayTemplate; + address masterVaultFactoryTemplate; + address masterVaultTemplate; + } + + struct CreateTokenBridgeArgs { + address inbox; + address rollupOwner; + uint256 maxGasForContracts; + uint256 gasPriceBid; + bool isYieldBearingBridge; + } + // use separate mapping to allow appending to the struct in the future // and workaround some stack too deep issues mapping(address => L1DeploymentAddresses) public inboxToL1Deployment; @@ -117,6 +132,8 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { // other canonical addresses (dependent on L2 template implementations) can be fetched through `_predictL2***Address` functions address public canonicalL2FactoryAddress; + YbbL1Templates public ybbL1Templates; + constructor() { _disableInitializers(); } @@ -174,6 +191,14 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { emit OrbitTokenBridgeTemplatesUpdated(); } + /** + * @notice Set addresses of YBB L1 logic contracts (standard, custom, fee-token-based, and vault templates). + */ + function setYbbTemplates(YbbL1Templates calldata _ybbL1Templates) external onlyOwner { + ybbL1Templates = _ybbL1Templates; + emit OrbitTokenBridgeTemplatesUpdated(); + } + /** * @notice Deploy and initialize token bridge, both L1 and L2 sides, as part of a single TX. * @dev This is a single entrypoint of L1 token bridge creator. Function deploys L1 side of token bridge and then uses @@ -195,6 +220,32 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { uint256 maxGasForContracts, uint256 gasPriceBid ) external payable { + // slither-disable-next-line out-of-order-retryable + _createTokenBridge( + CreateTokenBridgeArgs(inbox, rollupOwner, maxGasForContracts, gasPriceBid, false) + ); + } + + /** + * @notice Deploy and initialize token bridge with yield bearing bridge support. + * @dev Same as createTokenBridge but with additional isYieldBearingBridge flag. + * When isYieldBearingBridge is true, the bridge will use MasterVaults to generate yield + * on escrowed tokens. + */ + function createYbbTokenBridge( + address inbox, + address rollupOwner, + uint256 maxGasForContracts, + uint256 gasPriceBid + ) external payable { + // slither-disable-next-line out-of-order-retryable + _createTokenBridge( + CreateTokenBridgeArgs(inbox, rollupOwner, maxGasForContracts, gasPriceBid, true) + ); + } + + // slither-disable-next-line out-of-order-retryable + function _createTokenBridge(CreateTokenBridgeArgs memory args) internal { // templates have to be in place if (address(l1Templates.routerTemplate) == address(0)) { revert L1AtomicTokenBridgeCreator_TemplatesNotSet(); @@ -202,12 +253,9 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { // Check that the rollupOwner account has EXECUTOR role // on the upgrade executor which is the owner of the rollup - address upgradeExecutor = IInbox(inbox).bridge().rollup().owner(); - if ( - !IAccessControlUpgradeable(upgradeExecutor).hasRole( - UpgradeExecutor(upgradeExecutor).EXECUTOR_ROLE(), rollupOwner - ) - ) { + address upgradeExecutor = IInbox(args.inbox).bridge().rollup().owner(); + if (!IAccessControlUpgradeable(upgradeExecutor) + .hasRole(UpgradeExecutor(upgradeExecutor).EXECUTOR_ROLE(), args.rollupOwner)) { revert L1AtomicTokenBridgeCreator_RollupOwnershipMisconfig(); } @@ -215,22 +263,24 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { // this is useful to recover from expired or out-of-order retryables // in case of resend, we assume L1 contracts already exist and we just need to deploy L2 contracts // deployment mappings should not be updated in case of resend - bool isResend = (inboxToL1Deployment[inbox].router != address(0)); + bool isResend = (inboxToL1Deployment[args.inbox].router != address(0)); - address feeToken = _getFeeToken(inbox); + address feeToken = _getFeeToken(args.inbox); // store L2 addresses before deployments + // slither-disable-next-line uninitialized-local L1DeploymentAddresses memory l1Deployment; + // slither-disable-next-line uninitialized-local L2DeploymentAddresses memory l2Deployment; // if resend, we use the existing l1 deployment if (isResend) { - l1Deployment = inboxToL1Deployment[inbox]; + l1Deployment = inboxToL1Deployment[args.inbox]; } { // store L2 addresses which are proxies - uint256 chainId = IRollupCore(address(IInbox(inbox).bridge().rollup())).chainId(); + uint256 chainId = IRollupCore(address(IInbox(args.inbox).bridge().rollup())).chainId(); l2Deployment.router = _getProxyAddress(OrbitSalts.L2_ROUTER, chainId); l2Deployment.standardGateway = _getProxyAddress(OrbitSalts.L2_STANDARD_GATEWAY, chainId); l2Deployment.customGateway = _getProxyAddress(OrbitSalts.L2_CUSTOM_GATEWAY, chainId); @@ -248,7 +298,7 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { // deploy L1 side of token bridge // get existing proxy admin and upgrade executor - address proxyAdmin = IInboxProxyAdmin(inbox).getProxyAdmin(); + address proxyAdmin = IInboxProxyAdmin(args.inbox).getProxyAdmin(); if (proxyAdmin == address(0)) { revert L1AtomicTokenBridgeCreator_ProxyAdminNotFound(); } @@ -261,103 +311,126 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { ? address(l1Templates.feeTokenBasedRouterTemplate) : address(l1Templates.routerTemplate); l1Deployment.router = _deployProxyWithSalt( - _getL1Salt(OrbitSalts.L1_ROUTER, inbox), routerTemplate, proxyAdmin + _getL1Salt(OrbitSalts.L1_ROUTER, args.inbox), routerTemplate, proxyAdmin ); } - // l1 standard gateway deployment block - { - address template = feeToken != address(0) - ? address(l1Templates.feeTokenBasedStandardGatewayTemplate) - : address(l1Templates.standardGatewayTemplate); - - L1ERC20Gateway standardGateway = L1ERC20Gateway( - _deployProxyWithSalt( - _getL1Salt(OrbitSalts.L1_STANDARD_GATEWAY, inbox), template, proxyAdmin - ) + if (args.isYieldBearingBridge) { + // Delegate YBB deployment to library + L1GatewayDeployer.YbbDeploymentResult memory ybbResult = + L1GatewayDeployer.deployYbbGateways( + L1GatewayDeployer.YbbDeploymentParams({ + inbox: args.inbox, + proxyAdmin: proxyAdmin, + upgradeExecutor: upgradeExecutor, + router: l1Deployment.router, + l2StandardGateway: l2Deployment.standardGateway, + l2CustomGateway: l2Deployment.customGateway, + l2BeaconProxyFactory: l2Deployment.beaconProxyFactory, + isFeeTokenBased: feeToken != address(0) + }), + L1GatewayDeployer.YbbTemplates({ + ybbStandardGatewayTemplate: ybbL1Templates.ybbStandardGatewayTemplate, + ybbCustomGatewayTemplate: ybbL1Templates.ybbCustomGatewayTemplate, + feeTokenBasedYbbStandardGatewayTemplate: ybbL1Templates.feeTokenBasedYbbStandardGatewayTemplate, + feeTokenBasedYbbCustomGatewayTemplate: ybbL1Templates.feeTokenBasedYbbCustomGatewayTemplate, + masterVaultFactoryTemplate: ybbL1Templates.masterVaultFactoryTemplate + }), + _getL1Salt(OrbitSalts.L1_MASTER_VAULT_FACTORY, args.inbox), + _getL1Salt(OrbitSalts.L1_STANDARD_GATEWAY, args.inbox), + _getL1Salt(OrbitSalts.L1_CUSTOM_GATEWAY, args.inbox) + ); + + // Initialize MasterVaultFactory after router is deployed + L1GatewayDeployer.initializeMasterVaultFactory( + ybbResult.masterVaultFactory, + ybbL1Templates.masterVaultTemplate, + upgradeExecutor, + l1Deployment.router ); - standardGateway.initialize( - l2Deployment.standardGateway, - l1Deployment.router, - inbox, - keccak256(type(ClonableBeaconProxy).creationCode), - l2Deployment.beaconProxyFactory - ); - - l1Deployment.standardGateway = address(standardGateway); - } - - // l1 custom gateway deployment block - { - address template = feeToken != address(0) - ? address(l1Templates.feeTokenBasedCustomGatewayTemplate) - : address(l1Templates.customGatewayTemplate); - - L1CustomGateway customGateway = L1CustomGateway( - _deployProxyWithSalt( - _getL1Salt(OrbitSalts.L1_CUSTOM_GATEWAY, inbox), template, proxyAdmin - ) - ); - - customGateway.initialize( - l2Deployment.customGateway, l1Deployment.router, inbox, upgradeExecutor - ); - - l1Deployment.customGateway = address(customGateway); + l1Deployment.standardGateway = ybbResult.standardGateway; + l1Deployment.customGateway = ybbResult.customGateway; + } else { + // Delegate standard gateway deployment to library + L1GatewayDeployer.StandardDeploymentResult memory standardResult = + L1GatewayDeployer.deployStandardGateways( + L1GatewayDeployer.StandardDeploymentParams({ + inbox: args.inbox, + proxyAdmin: proxyAdmin, + upgradeExecutor: upgradeExecutor, + router: l1Deployment.router, + l2StandardGateway: l2Deployment.standardGateway, + l2CustomGateway: l2Deployment.customGateway, + l2BeaconProxyFactory: l2Deployment.beaconProxyFactory, + isFeeTokenBased: feeToken != address(0) + }), + L1GatewayDeployer.StandardTemplates({ + standardGatewayTemplate: l1Templates.standardGatewayTemplate, + feeTokenBasedStandardGatewayTemplate: l1Templates.feeTokenBasedStandardGatewayTemplate, + customGatewayTemplate: l1Templates.customGatewayTemplate, + feeTokenBasedCustomGatewayTemplate: l1Templates.feeTokenBasedCustomGatewayTemplate + }), + _getL1Salt(OrbitSalts.L1_STANDARD_GATEWAY, args.inbox), + _getL1Salt(OrbitSalts.L1_CUSTOM_GATEWAY, args.inbox) + ); + + l1Deployment.standardGateway = standardResult.standardGateway; + l1Deployment.customGateway = standardResult.customGateway; } // l1 weth gateway deployment block if (feeToken == address(0)) { - L1WethGateway wethGateway = L1WethGateway( - payable( - _deployProxyWithSalt( - _getL1Salt(OrbitSalts.L1_WETH_GATEWAY, inbox), - address(l1Templates.wethGatewayTemplate), - proxyAdmin - ) - ) - ); - - wethGateway.initialize( - l2Deployment.wethGateway, l1Deployment.router, inbox, l1Weth, l2Deployment.weth - ); - - l1Deployment.wethGateway = address(wethGateway); + L1GatewayDeployer.WethDeploymentResult memory wethResult = + L1GatewayDeployer.deployWethGateway( + L1GatewayDeployer.WethDeploymentParams({ + inbox: args.inbox, + proxyAdmin: proxyAdmin, + router: l1Deployment.router, + l2WethGateway: l2Deployment.wethGateway, + l1Weth: l1Weth, + l2Weth: l2Deployment.weth + }), + l1Templates.wethGatewayTemplate, + _getL1Salt(OrbitSalts.L1_WETH_GATEWAY, args.inbox) + ); + + l1Deployment.wethGateway = wethResult.wethGateway; l1Deployment.weth = l1Weth; } // init router - L1GatewayRouter(l1Deployment.router).initialize( - upgradeExecutor, - l1Deployment.standardGateway, - address(0), - l2Deployment.router, - inbox - ); + L1GatewayRouter(l1Deployment.router) + .initialize( + upgradeExecutor, + l1Deployment.standardGateway, + address(0), + l2Deployment.router, + args.inbox + ); } // deploy factory and then L2 contracts through L2 factory, using 2 retryables calls // we do not care if it is a resend or not, if the L2 deployment already exists it will simply fail on L2 - _deployL2Factory(inbox, gasPriceBid, feeToken); + // slither-disable-next-line out-of-order-retryable + _deployL2Factory(args.inbox, args.gasPriceBid, feeToken); RetryableParams memory retryableParams = RetryableParams( - inbox, + args.inbox, canonicalL2FactoryAddress, msg.sender, msg.sender, - maxGasForContracts, - gasPriceBid, + args.maxGasForContracts, + args.gasPriceBid, 0 ); if (feeToken != address(0)) { // transfer fee tokens to inbox to pay for 2nd retryable retryableParams.feeTokenTotalFeeAmount = - _getScaledAmount(feeToken, maxGasForContracts * gasPriceBid); - IERC20(feeToken).safeTransferFrom( - msg.sender, inbox, retryableParams.feeTokenTotalFeeAmount - ); + _getScaledAmount(feeToken, args.maxGasForContracts * args.gasPriceBid); + IERC20(feeToken) + .safeTransferFrom(msg.sender, args.inbox, retryableParams.feeTokenTotalFeeAmount); } L2TemplateAddresses memory l2TemplateAddress = L2TemplateAddresses( @@ -371,9 +444,9 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { ); // alias rollup owner if it is a contract - address l2RollupOwner = rollupOwner.code.length == 0 - ? rollupOwner - : AddressAliasHelper.applyL1ToL2Alias(rollupOwner); + address l2RollupOwner = args.rollupOwner.code.length == 0 + ? args.rollupOwner + : AddressAliasHelper.applyL1ToL2Alias(args.rollupOwner); // sweep the balance to send the retryable and refund the difference // it is known that any eth previously in this contract can be extracted @@ -390,13 +463,19 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { // deployment mappings should not be updated in case of resend if (!isResend) { emit OrbitTokenBridgeCreated( - inbox, rollupOwner, l1Deployment, l2Deployment, proxyAdmin, upgradeExecutor + args.inbox, + args.rollupOwner, + l1Deployment, + l2Deployment, + proxyAdmin, + upgradeExecutor ); - inboxToL1Deployment[inbox] = l1Deployment; - inboxToL2Deployment[inbox] = l2Deployment; + inboxToL1Deployment[args.inbox] = l1Deployment; + inboxToL2Deployment[args.inbox] = l2Deployment; } } + // slither-disable-next-line arbitrary-send-eth function _sendRetryableToCreateContracts( RetryableParams memory retryableParams, L2TemplateAddresses memory l2TemplateAddress, @@ -444,6 +523,8 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { return inboxToL1Deployment[inbox].router; } + // slither-disable-next-line arbitrary-send-eth + // slither-disable-next-line out-of-order-retryable function _deployL2Factory(address inbox, uint256 gasPriceBid, address feeToken) internal { // encode L2 factory bytecode bytes memory deploymentData = @@ -455,22 +536,25 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { uint256 scaledRetryableFee = _getScaledAmount(feeToken, retryableFee); IERC20(feeToken).safeTransferFrom(msg.sender, inbox, scaledRetryableFee); - IERC20Inbox(inbox).createRetryableTicket( - address(0), - 0, - 0, - msg.sender, - msg.sender, - gasLimitForL2FactoryDeployment, - gasPriceBid, - scaledRetryableFee, - deploymentData - ); + // slither-disable-next-line unused-return + IERC20Inbox(inbox) + .createRetryableTicket( + address(0), + 0, + 0, + msg.sender, + msg.sender, + gasLimitForL2FactoryDeployment, + gasPriceBid, + scaledRetryableFee, + deploymentData + ); } else { uint256 maxSubmissionCost = IInbox(inbox).calculateRetryableSubmissionFee(deploymentData.length, 0); uint256 retryableFee = maxSubmissionCost + gasLimitForL2FactoryDeployment * gasPriceBid; + // slither-disable-next-line unused-return,arbitrary-send-eth IInbox(inbox).createRetryableTicket{value: retryableFee}( address(0), 0, @@ -601,6 +685,7 @@ contract L1AtomicTokenBridgeCreator is Initializable, OwnableUpgradeable { /** * @notice Scale amount to the fee token's decimals. Ie. amount of 1e18 will be scaled to 1e6 if fee token has 6 decimals like USDC. */ + // slither-disable-next-line divide-before-multiply function _getScaledAmount(address feeToken, uint256 amount) internal view returns (uint256) { uint8 decimals = ERC20(feeToken).decimals(); if (decimals == 18) { diff --git a/contracts/tokenbridge/ethereum/L1GatewayDeployer.sol b/contracts/tokenbridge/ethereum/L1GatewayDeployer.sol new file mode 100644 index 000000000..ae8404956 --- /dev/null +++ b/contracts/tokenbridge/ethereum/L1GatewayDeployer.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.4; + +import {L1ERC20Gateway} from "./gateway/L1ERC20Gateway.sol"; +import {L1CustomGateway} from "./gateway/L1CustomGateway.sol"; +import {L1WethGateway} from "./gateway/L1WethGateway.sol"; +import {L1OrbitERC20Gateway} from "./gateway/L1OrbitERC20Gateway.sol"; +import {L1OrbitCustomGateway} from "./gateway/L1OrbitCustomGateway.sol"; +import {L1YbbERC20Gateway} from "./gateway/L1YbbERC20Gateway.sol"; +import {L1YbbCustomGateway} from "./gateway/L1YbbCustomGateway.sol"; +import {L1OrbitYbbERC20Gateway} from "./gateway/L1OrbitYbbERC20Gateway.sol"; +import {L1OrbitYbbCustomGateway} from "./gateway/L1OrbitYbbCustomGateway.sol"; +import {IMasterVaultFactory} from "../libraries/vault/IMasterVaultFactory.sol"; +import {IGatewayRouter} from "../libraries/gateway/IGatewayRouter.sol"; +import {ClonableBeaconProxy} from "../libraries/ClonableBeaconProxy.sol"; +import { + TransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +/** + * @title L1GatewayDeployer + * @notice Library for deploying all L1 gateway components (standard, custom, WETH, and YBB) + */ +library L1GatewayDeployer { + // ============ Standard Gateway Structs ============ + + struct StandardDeploymentParams { + address inbox; + address proxyAdmin; + address upgradeExecutor; + address router; + address l2StandardGateway; + address l2CustomGateway; + address l2BeaconProxyFactory; + bool isFeeTokenBased; + } + + struct StandardTemplates { + address standardGatewayTemplate; + address feeTokenBasedStandardGatewayTemplate; + address customGatewayTemplate; + address feeTokenBasedCustomGatewayTemplate; + } + + struct StandardDeploymentResult { + address standardGateway; + address customGateway; + } + + // ============ WETH Gateway Structs ============ + + struct WethDeploymentParams { + address inbox; + address proxyAdmin; + address router; + address l2WethGateway; + address l1Weth; + address l2Weth; + } + + struct WethDeploymentResult { + address wethGateway; + } + + // ============ YBB Gateway Structs ============ + + struct YbbDeploymentParams { + address inbox; + address proxyAdmin; + address upgradeExecutor; + address router; + address l2StandardGateway; + address l2CustomGateway; + address l2BeaconProxyFactory; + bool isFeeTokenBased; + } + + struct YbbTemplates { + address ybbStandardGatewayTemplate; + address ybbCustomGatewayTemplate; + address feeTokenBasedYbbStandardGatewayTemplate; + address feeTokenBasedYbbCustomGatewayTemplate; + address masterVaultFactoryTemplate; + } + + struct YbbDeploymentResult { + address masterVaultFactory; + address standardGateway; + address customGateway; + } + + // ============ Standard Gateway Deployment ============ + + function deployStandardGateways( + StandardDeploymentParams memory params, + StandardTemplates memory templates, + bytes32 standardGatewaySalt, + bytes32 customGatewaySalt + ) external returns (StandardDeploymentResult memory result) { + { + address template = params.isFeeTokenBased + ? templates.feeTokenBasedStandardGatewayTemplate + : templates.standardGatewayTemplate; + + result.standardGateway = _deployProxy(standardGatewaySalt, template, params.proxyAdmin); + + L1ERC20Gateway(result.standardGateway) + .initialize( + params.l2StandardGateway, + params.router, + params.inbox, + keccak256(type(ClonableBeaconProxy).creationCode), + params.l2BeaconProxyFactory + ); + } + + { + address template = params.isFeeTokenBased + ? templates.feeTokenBasedCustomGatewayTemplate + : templates.customGatewayTemplate; + + result.customGateway = _deployProxy(customGatewaySalt, template, params.proxyAdmin); + + L1CustomGateway(result.customGateway) + .initialize( + params.l2CustomGateway, params.router, params.inbox, params.upgradeExecutor + ); + } + + return result; + } + + // ============ WETH Gateway Deployment ============ + + function deployWethGateway( + WethDeploymentParams memory params, + address wethGatewayTemplate, + bytes32 wethGatewaySalt + ) external returns (WethDeploymentResult memory result) { + result.wethGateway = _deployProxy(wethGatewaySalt, wethGatewayTemplate, params.proxyAdmin); + + L1WethGateway(payable(result.wethGateway)) + .initialize( + params.l2WethGateway, params.router, params.inbox, params.l1Weth, params.l2Weth + ); + + return result; + } + + // ============ YBB Gateway Deployment ============ + + function deployYbbGateways( + YbbDeploymentParams memory params, + YbbTemplates memory templates, + bytes32 masterVaultSalt, + bytes32 standardGatewaySalt, + bytes32 customGatewaySalt + ) external returns (YbbDeploymentResult memory result) { + result.masterVaultFactory = _deployProxy( + masterVaultSalt, templates.masterVaultFactoryTemplate, params.proxyAdmin + ); + + { + address template = params.isFeeTokenBased + ? templates.feeTokenBasedYbbStandardGatewayTemplate + : templates.ybbStandardGatewayTemplate; + + result.standardGateway = _deployProxy(standardGatewaySalt, template, params.proxyAdmin); + + L1YbbERC20Gateway(result.standardGateway) + .initialize( + params.l2StandardGateway, + params.router, + params.inbox, + keccak256(type(ClonableBeaconProxy).creationCode), + params.l2BeaconProxyFactory, + result.masterVaultFactory + ); + } + + { + address template = params.isFeeTokenBased + ? templates.feeTokenBasedYbbCustomGatewayTemplate + : templates.ybbCustomGatewayTemplate; + + result.customGateway = _deployProxy(customGatewaySalt, template, params.proxyAdmin); + + L1YbbCustomGateway(result.customGateway) + .initialize( + params.l2CustomGateway, + params.router, + params.inbox, + params.upgradeExecutor, + result.masterVaultFactory + ); + } + + return result; + } + + function initializeMasterVaultFactory( + address masterVaultFactory, + address masterVaultImplementation, + address admin, + address router + ) external { + IMasterVaultFactory(masterVaultFactory) + .initialize(masterVaultImplementation, admin, IGatewayRouter(router)); + } + + // ============ Internal ============ + + function _deployProxy(bytes32 salt, address logic, address admin) internal returns (address) { + return address(new TransparentUpgradeableProxy{salt: salt}(logic, admin, bytes(""))); + } +} diff --git a/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol b/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol index bbf48bab4..cf26e2826 100644 --- a/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol +++ b/contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol @@ -129,7 +129,7 @@ contract L1ERC20Gateway is L1ArbitrumExtendedGateway { address _to, uint256 _amount, bytes memory _data - ) public view override returns (bytes memory outboundCalldata) { + ) public view virtual override returns (bytes memory outboundCalldata) { // TODO: cheaper to make static calls or save isDeployed to storage? bytes memory deployData = abi.encode( callStatic(_token, ERC20.name.selector), diff --git a/contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol b/contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol new file mode 100644 index 000000000..5288e19ab --- /dev/null +++ b/contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import {L1OrbitCustomGateway} from "./L1OrbitCustomGateway.sol"; +import {L1CustomGateway} from "./L1CustomGateway.sol"; +import {YbbVaultLib} from "../../libraries/vault/YbbVaultLib.sol"; +import {IMasterVaultFactory} from "../../libraries/vault/IMasterVaultFactory.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title Layer 1 Gateway contract for bridging Custom ERC20s with YBB enabled in ERC20-based rollup + * @notice Escrows funds into MasterVaults for yield bearing bridging. + */ +contract L1OrbitYbbCustomGateway is L1OrbitCustomGateway { + using SafeERC20 for IERC20; + + /// @notice Address of the MasterVaultFactory contract + address public masterVaultFactory; + + function initialize( + address _l1Counterpart, + address _l1Router, + address _inbox, + address _owner, + address _masterVaultFactory + ) public virtual { + L1CustomGateway.initialize(_l1Counterpart, _l1Router, _inbox, _owner); + _setMasterVaultFactory(_masterVaultFactory); + } + + function inboundEscrowTransfer(address _l1Token, address _dest, uint256 _amount) + internal + override + { + YbbVaultLib.withdrawFromVault(masterVaultFactory, _l1Token, _dest, _amount); + } + + function outboundEscrowTransfer(address _l1Token, address _from, uint256 _amount) + internal + override + returns (uint256 amountReceived) + { + amountReceived = YbbVaultLib.depositToVault(masterVaultFactory, _l1Token, _from, _amount); + } + + function _setMasterVaultFactory(address _masterVaultFactory) internal { + require(_masterVaultFactory != address(0), "BAD_MASTER_VAULT_FACTORY"); + masterVaultFactory = _masterVaultFactory; + } +} diff --git a/contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol b/contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol new file mode 100644 index 000000000..41118c597 --- /dev/null +++ b/contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import {L1OrbitERC20Gateway} from "./L1OrbitERC20Gateway.sol"; +import {L1ERC20Gateway} from "./L1ERC20Gateway.sol"; +import {YbbVaultLib} from "../../libraries/vault/YbbVaultLib.sol"; +import {IMasterVaultFactory} from "../../libraries/vault/IMasterVaultFactory.sol"; +import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {GatewayMessageHandler} from "../../libraries/gateway/GatewayMessageHandler.sol"; +import {ITokenGateway} from "../../libraries/gateway/ITokenGateway.sol"; + +/** + * @title Layer 1 Gateway contract for bridging standard ERC20s with YBB enabled in ERC20-based rollup + * @notice Escrows funds into MasterVaults for yield bearing bridging. + */ +contract L1OrbitYbbERC20Gateway is L1OrbitERC20Gateway { + using SafeERC20 for IERC20; + + /// @notice Address of the MasterVaultFactory contract + address public masterVaultFactory; + + function initialize( + address _l2Counterpart, + address _router, + address _inbox, + bytes32 _cloneableProxyHash, + address _l2BeaconProxyFactory, + address _masterVaultFactory + ) public { + L1ERC20Gateway.initialize( + _l2Counterpart, _router, _inbox, _cloneableProxyHash, _l2BeaconProxyFactory + ); + _setMasterVaultFactory(_masterVaultFactory); + } + + function inboundEscrowTransfer(address _l1Token, address _dest, uint256 _amount) + internal + override + { + YbbVaultLib.withdrawFromVault(masterVaultFactory, _l1Token, _dest, _amount); + } + + function outboundEscrowTransfer(address _l1Token, address _from, uint256 _amount) + internal + override + returns (uint256 amountReceived) + { + amountReceived = YbbVaultLib.depositToVault(masterVaultFactory, _l1Token, _from, _amount); + } + + function getOutboundCalldata( + address _token, + address _from, + address _to, + uint256 _amount, + bytes memory _data + ) public view override returns (bytes memory outboundCalldata) { + address vault = IMasterVaultFactory(masterVaultFactory).calculateVaultAddress(_token); + + bytes memory deployData = abi.encode( + callStatic(_token, ERC20.name.selector), + callStatic(_token, ERC20.symbol.selector), + callStatic(vault, ERC20.decimals.selector) + ); + + outboundCalldata = abi.encodeWithSelector( + ITokenGateway.finalizeInboundTransfer.selector, + _token, + _from, + _to, + _amount, + GatewayMessageHandler.encodeToL2GatewayMsg(deployData, _data) + ); + } + + function _setMasterVaultFactory(address _masterVaultFactory) internal { + require(_masterVaultFactory != address(0), "BAD_MASTER_VAULT_FACTORY"); + masterVaultFactory = _masterVaultFactory; + } +} diff --git a/contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol b/contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol new file mode 100644 index 000000000..45dbb1f7d --- /dev/null +++ b/contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import {L1CustomGateway} from "./L1CustomGateway.sol"; +import {YbbVaultLib} from "../../libraries/vault/YbbVaultLib.sol"; +import {IMasterVaultFactory} from "../../libraries/vault/IMasterVaultFactory.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title Layer 1 Gateway contract for bridging Custom ERC20s with YBB enabled + * @notice Escrows funds into MasterVaults for yield bearing bridging. + */ +contract L1YbbCustomGateway is L1CustomGateway { + using SafeERC20 for IERC20; + + /// @notice Address of the MasterVaultFactory contract + address public masterVaultFactory; + + function initialize( + address _l1Counterpart, + address _l1Router, + address _inbox, + address _owner, + address _masterVaultFactory + ) public virtual { + L1CustomGateway.initialize(_l1Counterpart, _l1Router, _inbox, _owner); + _setMasterVaultFactory(_masterVaultFactory); + } + + function inboundEscrowTransfer(address _l1Token, address _dest, uint256 _amount) + internal + override + { + YbbVaultLib.withdrawFromVault(masterVaultFactory, _l1Token, _dest, _amount); + } + + function outboundEscrowTransfer(address _l1Token, address _from, uint256 _amount) + internal + override + returns (uint256 amountReceived) + { + amountReceived = YbbVaultLib.depositToVault(masterVaultFactory, _l1Token, _from, _amount); + } + + function _setMasterVaultFactory(address _masterVaultFactory) internal { + require(_masterVaultFactory != address(0), "BAD_MASTER_VAULT_FACTORY"); + masterVaultFactory = _masterVaultFactory; + } +} diff --git a/contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol b/contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol new file mode 100644 index 000000000..75ab323ba --- /dev/null +++ b/contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import {L1ERC20Gateway} from "./L1ERC20Gateway.sol"; +import {YbbVaultLib} from "../../libraries/vault/YbbVaultLib.sol"; +import {IMasterVaultFactory} from "../../libraries/vault/IMasterVaultFactory.sol"; +import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {GatewayMessageHandler} from "../../libraries/gateway/GatewayMessageHandler.sol"; +import {ITokenGateway} from "../../libraries/gateway/ITokenGateway.sol"; + +/** + * @title Layer 1 Gateway contract for bridging standard ERC20s with YBB enabled + * @notice Escrows funds into MasterVaults for yield bearing bridging. + */ +contract L1YbbERC20Gateway is L1ERC20Gateway { + using SafeERC20 for IERC20; + + /// @notice Address of the MasterVaultFactory contract + address public masterVaultFactory; + + function initialize( + address _l2Counterpart, + address _router, + address _inbox, + bytes32 _cloneableProxyHash, + address _l2BeaconProxyFactory, + address _masterVaultFactory + ) public { + L1ERC20Gateway.initialize( + _l2Counterpart, _router, _inbox, _cloneableProxyHash, _l2BeaconProxyFactory + ); + _setMasterVaultFactory(_masterVaultFactory); + } + + function inboundEscrowTransfer(address _l1Token, address _dest, uint256 _amount) + internal + override + { + YbbVaultLib.withdrawFromVault(masterVaultFactory, _l1Token, _dest, _amount); + } + + function outboundEscrowTransfer(address _l1Token, address _from, uint256 _amount) + internal + override + returns (uint256 amountReceived) + { + amountReceived = YbbVaultLib.depositToVault(masterVaultFactory, _l1Token, _from, _amount); + } + + function getOutboundCalldata( + address _token, + address _from, + address _to, + uint256 _amount, + bytes memory _data + ) public view override returns (bytes memory outboundCalldata) { + address vault = IMasterVaultFactory(masterVaultFactory).calculateVaultAddress(_token); + + bytes memory deployData = abi.encode( + callStatic(_token, ERC20.name.selector), + callStatic(_token, ERC20.symbol.selector), + callStatic(vault, ERC20.decimals.selector) + ); + + outboundCalldata = abi.encodeWithSelector( + ITokenGateway.finalizeInboundTransfer.selector, + _token, + _from, + _to, + _amount, + GatewayMessageHandler.encodeToL2GatewayMsg(deployData, _data) + ); + } + + function _setMasterVaultFactory(address _masterVaultFactory) internal { + require(_masterVaultFactory != address(0), "BAD_MASTER_VAULT_FACTORY"); + masterVaultFactory = _masterVaultFactory; + } +} diff --git a/contracts/tokenbridge/libraries/vault/IMasterVault.sol b/contracts/tokenbridge/libraries/vault/IMasterVault.sol new file mode 100644 index 000000000..af976a870 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/IMasterVault.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +interface IMasterVault { + function setSubVault(IERC4626 subVault) external; + function deposit(uint256 assets) external returns (uint256 shares); + function redeem(uint256 shares, uint256 minAssets) external returns (uint256 assets); +} diff --git a/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol new file mode 100644 index 000000000..e034e132c --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/IMasterVaultFactory.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../gateway/IGatewayRouter.sol"; + +interface IMasterVaultFactory { + event VaultDeployed(address indexed token, address indexed vault); + + function initialize( + address _masterVaultImplementation, + address _admin, + IGatewayRouter _gatewayRouter + ) external; + function deployVault(address token) external returns (address vault); + function calculateVaultAddress(address token) external view returns (address); + function getVault(address token) external returns (address); +} diff --git a/contracts/tokenbridge/libraries/vault/MasterVault.sol b/contracts/tokenbridge/libraries/vault/MasterVault.sol new file mode 100644 index 000000000..f8a2a68e4 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/MasterVault.sol @@ -0,0 +1,615 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {MasterVaultRoles} from "./MasterVaultRoles.sol"; +import {ERC20Upgradeable} from "contracts/tokenbridge/libraries/ERC20Upgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { + AccessControlUpgradeable, + IAccessControlUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + PausableUpgradeable +} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { + ReentrancyGuardUpgradeable +} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import {IGatewayRouter} from "../gateway/IGatewayRouter.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IMasterVault} from "./IMasterVault.sol"; + +/// @notice MasterVault is a metavault that deposits assets to an admin defined ERC4626 compliant subVault. +/// @dev The MasterVault keeps some fraction of assets idle and deposits the rest into the subVault to earn yield. +/// A 100% performance fee is always enabled and collected on demand. +/// The MasterVault mitigates the "first depositor" problem by adding EXTRA_DECIMALS (6) to the underlying asset. +/// i.e. if the underlying asset has 6 decimals, the MasterVault will have 12 decimals. +/// +/// For a subVault to be compatible with the MasterVault, it must adhere to the following: +/// - must be fully ERC4626 compliant +/// - previewMint and previewDeposit must not be manipulable +/// - deposit and withdraw must not be manipulable / sandwichable +/// - previewMint and previewDeposit must be roughly linear with respect to amounts. +/// Superlinear previewMint or sublinear previewDeposit may cause the MasterVault to overcharge on deposits and underpay on withdrawals. +/// - must not have deposit / withdrawal fees (because rebalancing can happen frequently) +/// +/// Roles are primarily managed via an external MasterVaultRoles contract, +/// which allows multiple vaults to share a common roles registry. +/// Individual MasterVaults can also have local roles assigned, which are checked in addition to the roles registry. +/// If an account is granted a role in either the local vault or the roles registry, it is considered to have that role. +contract MasterVault is + IMasterVault, + MasterVaultRoles, + ReentrancyGuardUpgradeable, + ERC20Upgradeable, + PausableUpgradeable +{ + using SafeERC20 for IERC20; + using MathUpgradeable for uint256; + using EnumerableSet for EnumerableSet.AddressSet; + + /// @notice Extra decimals added to the ERC20 decimals of the underlying asset to determine the decimals of the MasterVault + /// @dev This is done to mitigate the "first depositor" problem described in the OpenZeppelin ERC4626 documentation. + /// See https://docs.openzeppelin.com/contracts/5.x/erc4626 for more details on the mitigation. + /// Should be << 18 to maintain precision in profit calculations. + /// Should be > 0 to meaningfully mitigate the first depositor problem. + uint8 public constant EXTRA_DECIMALS = 6; + + /// @notice Default minimum rebalance amount + uint120 public constant DEFAULT_MIN_REBALANCE_AMOUNT = 1e6; + + /// @notice Minimum rebalance cooldown in seconds + uint32 public constant MIN_REBALANCE_COOLDOWN = 1; + + error SubVaultAssetMismatch(); + error BeneficiaryNotSet(); + error NotKeeper(); + error NonZeroTargetAllocation(uint256 targetAllocationWad); + error NonZeroSubVaultShares(uint256 subVaultShares); + error NotGateway(address caller); + error SubVaultNotWhitelisted(address subVault); + error RebalanceCooldownNotMet(uint256 timeSinceLastRebalance, uint256 cooldownRequired); + error TargetAllocationMet(); + error RebalanceAmountTooSmall( + bool isDeposit, uint256 amount, uint256 desiredAmount, uint256 minimumRebalanceAmount + ); + error RebalanceCooldownTooLow(uint32 requested, uint32 minimum); + error RebalanceExchRateTooLow( + int256 minExchRateWad, int256 deltaAssets, uint256 subVaultShares + ); + error RebalanceExchRateWrongSign(int256 minExchRateWad); + error InsufficientAssets(uint256 assets, uint256 minAssets); + + /* + Storage layout notes: + + We have three hot paths that should be optimized: + - deposit + - redeem + - rebalance + + Below is the list of state variables accessed in each hot path. + They are listed to see which variables should be packed together. + + deposit: + - address subVault --------------------| <- this appears in most calls, it will use a full slot even tho it needs only 20 bytes + - address asset + - address gatewayRouter + redeem: + - address subVault --------------------| + - address asset + rebalance: + - address subVault --------------------| + - uint40 lastRebalanceTime (r/w) ------| <- timestamp, uint40 gives up to year 36812 + - uint32 rebalanceCooldown | <- timer, uint32 gives up to ~136 years + - uint64 targetAllocationWad | <- <=1e18, so uint64 + - uint120 minimumRebalanceAmount ------| <- uint120 remaining, should be enough for any asset + - address asset + - address rolesRegistry + */ + + /// @notice Timestamp of the last rebalance + uint40 public lastRebalanceTime; + + /// @notice The minimum time in seconds that must pass between rebalances + /// @dev Defaults to 1 second. Cannot be 0. + uint32 public rebalanceCooldown; + + /// @notice Target allocation of assets to keep in the subvault, expressed in wad (1e18 = 100%) + /// Rebalances will attempt to maintain this allocation. + uint64 public targetAllocationWad; + + /// @notice The minimum amount of assets that must be deposited/withdrawn when rebalancing. + /// If the amount to deposit or withdraw is less than this amount, no action is taken. + /// This prevents dust rebalances. + /// @dev Defaults to 1e6, but can be set by the vault manager to any value. + uint120 public minimumRebalanceAmount; + + /// @notice The underlying asset of the vault + IERC20 public asset; + + /// @notice Gateway router used to verify deposit calls + IGatewayRouter public gatewayRouter; + + /// @notice Set of whitelisted subvaults + EnumerableSet.AddressSet private _whitelistedSubVaults; + + /// @notice Roles registry contract. This contract is checked in addition to local roles. + /// If an account has a role in either the local vault or the roles registry, it is considered to have that role. + MasterVaultRoles public rolesRegistry; + + /// @notice Address that receives performance fees + address public beneficiary; + + /// @notice The current subvault. Assets are deposited into this vault to earn yield. + IERC4626 public subVault; + + event SubvaultChanged(address indexed oldSubvault, address indexed newSubvault); + event BeneficiaryUpdated(address indexed oldBeneficiary, address indexed newBeneficiary); + event MinimumRebalanceAmountUpdated(uint256 oldAmount, uint256 newAmount); + event PerformanceFeesWithdrawn( + address indexed beneficiary, uint256 amountTransferred, uint256 amountWithdrawn + ); + event Rebalanced(bool deposited, uint256 desiredAmount, uint256 actualAmount); + event RebalancedToZero(uint256 shares, uint256 assets); + event SubVaultWhitelistUpdated(address indexed subVault, bool whitelisted); + event RebalanceCooldownUpdated(uint256 oldCooldown, uint256 newCooldown); + event TargetAllocationUpdated(uint256 oldAllocation, uint256 newAllocation); + + function initialize( + IERC4626 _subVault, + string memory _name, + string memory _symbol, + MasterVaultRoles _rolesRegistry, + IGatewayRouter _gatewayRouter + ) external initializer { + __ERC20_init(_name, _symbol); + + asset = IERC20(address(_subVault.asset())); + rolesRegistry = _rolesRegistry; + + // call decimals() to ensure underlying has reasonable decimals and we won't have overflow + decimals(); + + __ReentrancyGuard_init(); + __Pausable_init(); + __MasterVaultRoles_init(); + + gatewayRouter = _gatewayRouter; + + // mint some dead shares to avoid first depositor issues + // for more information on the mitigation: + // https://web.archive.org/web/20250609034056/https://docs.openzeppelin.com/contracts/4.x/erc4626#fees + _mint(address(1), 10 ** EXTRA_DECIMALS); + + subVault = _subVault; + _setSubVaultWhitelist(address(_subVault), true); + + minimumRebalanceAmount = DEFAULT_MIN_REBALANCE_AMOUNT; + rebalanceCooldown = MIN_REBALANCE_COOLDOWN; + } + + /// @notice Modifier to ensure only the registered gateway can call + modifier onlyGateway() { + if (gatewayRouter.getGateway(address(asset)) != msg.sender) { + revert NotGateway(msg.sender); + } + _; + } + + /// @notice Modifier to ensure only keeper + // if KEEPER_ROLE is granted to address(0), anyone can call + modifier onlyKeeper() { + if (!hasRole(KEEPER_ROLE, address(0)) && !hasRole(KEEPER_ROLE, msg.sender)) { + revert NotKeeper(); + } + _; + } + + /// @notice Deposit some underlying assets in exchange for vault shares + /// @dev Can only be called by the token bridge gateway + /// @param assets The amount of underlying assets to deposit + /// @return shares The amount of vault shares minted to the depositor + function deposit(uint256 assets) + external + whenNotPaused + nonReentrant + onlyGateway + returns (uint256 shares) + { + shares = _convertToSharesRoundDown(assets); + _mint(msg.sender, shares); + asset.safeTransferFrom(msg.sender, address(this), assets); + } + + /// @notice Redeem some vault shares in exchange for underlying assets + /// @dev Anyone can redeem their shares at any time + /// @param shares The amount of vault shares to redeem + /// @param minAssets Minimum amount of assets to receive (slippage protection), 0 to skip check + /// @return assets The amount of underlying assets transferred to the redeemer + function redeem(uint256 shares, uint256 minAssets) + external + whenNotPaused + nonReentrant + returns (uint256 assets) + { + assets = _convertToAssetsRoundDown(shares); + if (minAssets > 0 && assets < minAssets) { + revert InsufficientAssets(assets, minAssets); + } + + uint256 idleAssets = asset.balanceOf(address(this)); + _burn(msg.sender, shares); + + if (idleAssets < assets) { + uint256 assetsToWithdraw = assets - idleAssets; + // slither-disable-next-line unused-return + subVault.withdraw(assetsToWithdraw, address(this), address(this)); + } + + asset.safeTransfer(msg.sender, assets); + } + + /// @notice Rebalance assets between idle and the subvault to maintain target allocation + /// @dev Will revert if the cooldown period has not passed. + /// If targetAllocationWad is 0%, attempts to redeem all subvault shares (bypasses minimumRebalanceAmount). + /// Otherwise, deposits/withdraws to reach the target, reverting if the target is already met + /// or the amount is less than minimumRebalanceAmount. + /// @param minExchRateWad Minimum exchange rate (1e18 * deltaAssets / abs(subVaultShares)) for the deposit/withdraw operation + /// Negative indicates a masterVault -> subVault deposit (negative deltaAssets), + /// positive indicates a subVault -> masterVault withdraw (positive deltaAssets). + // slither-disable-next-line reentrancy-no-eth + function rebalance(int256 minExchRateWad) external whenNotPaused nonReentrant onlyKeeper { + uint256 timeSinceLastRebalance = block.timestamp - lastRebalanceTime; + if (timeSinceLastRebalance < rebalanceCooldown) { + revert RebalanceCooldownNotMet(timeSinceLastRebalance, rebalanceCooldown); + } + + if (targetAllocationWad == 0) { + _rebalanceDrain(minExchRateWad); + } else { + _rebalanceToTarget(minExchRateWad); + } + + lastRebalanceTime = uint40(block.timestamp); + } + + /// @dev 0% target: redeem all subvault shares. Bypasses minimumRebalanceAmount so dust can be swept. + function _rebalanceDrain(int256 minExchRateWad) private { + uint256 subVaultShares = subVault.maxRedeem(address(this)); + if (subVaultShares == 0) revert TargetAllocationMet(); + + uint256 assetsReceived = subVault.redeem(subVaultShares, address(this), address(this)); + _validateWithdrawExchRate(minExchRateWad, assetsReceived, subVaultShares); + + emit RebalancedToZero(subVaultShares, assetsReceived); + } + + /// @dev Deposit to or withdraw from the subvault to reach targetAllocationWad. + function _rebalanceToTarget(int256 minExchRateWad) private { + uint256 totalAssetsUp = _totalAssets(MathUpgradeable.Rounding.Up); + uint256 totalAssetsDown = _totalAssets(MathUpgradeable.Rounding.Down); + uint256 idleTargetUp = + totalAssetsUp.mulDiv(1e18 - targetAllocationWad, 1e18, MathUpgradeable.Rounding.Up); + uint256 idleTargetDown = + totalAssetsDown.mulDiv(1e18 - targetAllocationWad, 1e18, MathUpgradeable.Rounding.Down); + uint256 idleBalance = asset.balanceOf(address(this)); + + if (idleTargetDown <= idleBalance && idleBalance <= idleTargetUp) { + revert TargetAllocationMet(); + } + + if (idleBalance < idleTargetDown) { + uint256 desiredWithdraw = idleTargetDown - idleBalance; + uint256 maxWithdrawable = subVault.maxWithdraw(address(this)); + uint256 withdrawAmount = + desiredWithdraw < maxWithdrawable ? desiredWithdraw : maxWithdrawable; + + if (withdrawAmount < minimumRebalanceAmount) { + revert RebalanceAmountTooSmall( + false, withdrawAmount, desiredWithdraw, minimumRebalanceAmount + ); + } + + uint256 subVaultShares = subVault.withdraw(withdrawAmount, address(this), address(this)); + _validateWithdrawExchRate(minExchRateWad, withdrawAmount, subVaultShares); + + emit Rebalanced(false, desiredWithdraw, withdrawAmount); + } else { + uint256 desiredDeposit = idleBalance - idleTargetUp; + uint256 maxDepositable = subVault.maxDeposit(address(this)); + uint256 depositAmount = + desiredDeposit < maxDepositable ? desiredDeposit : maxDepositable; + + if (depositAmount < minimumRebalanceAmount) { + revert RebalanceAmountTooSmall( + true, depositAmount, desiredDeposit, minimumRebalanceAmount + ); + } + + asset.safeIncreaseAllowance(address(subVault), depositAmount); + uint256 subVaultShares = subVault.deposit(depositAmount, address(this)); + _validateDepositExchRate(minExchRateWad, depositAmount, subVaultShares); + + emit Rebalanced(true, desiredDeposit, depositAmount); + } + } + + /// @notice Distribute performance fees to the beneficiary + function distributePerformanceFee() external whenNotPaused nonReentrant onlyKeeper { + _distributePerformanceFee(); + } + + /// @notice Set a new subvault + /// @dev Target allocation must be zero and there must be no existing subvault shares held. + /// The new subvault must be whitelisted and have the same asset as this MasterVault. + /// @param _subVault The subvault to set. Must be an ERC4626 vault with the same asset as this MasterVault. + function setSubVault(IERC4626 _subVault) external nonReentrant onlyRole(GENERAL_MANAGER_ROLE) { + if (!isSubVaultWhitelisted(address(_subVault))) { + revert SubVaultNotWhitelisted(address(_subVault)); + } + if (address(_subVault.asset()) != address(asset)) revert SubVaultAssetMismatch(); + + // we ensure target allocation is zero, therefore the master vault holds no subvault shares + if (targetAllocationWad != 0) revert NonZeroTargetAllocation(targetAllocationWad); + + // sanity check to ensure we have zero subvault shares before changing + if (subVault.balanceOf(address(this)) != 0) { + revert NonZeroSubVaultShares(subVault.balanceOf(address(this))); + } + + address oldSubVault = address(subVault); + subVault = _subVault; + + emit SubvaultChanged(oldSubVault, address(_subVault)); + } + + /// @notice Set the target allocation of assets to keep in the subvault + /// @dev Target allocation must be between 0 and 1e18 (100%). + /// @param _targetAllocationWad The target allocation in wad (1e18 = 100%) + function setTargetAllocationWad(uint64 _targetAllocationWad) + external + nonReentrant + onlyRole(GENERAL_MANAGER_ROLE) + { + require(_targetAllocationWad <= 1e18, "Target allocation must be <= 100%"); + require(targetAllocationWad != _targetAllocationWad, "Allocation unchanged"); + uint256 oldAllocation = targetAllocationWad; + targetAllocationWad = _targetAllocationWad; + emit TargetAllocationUpdated(oldAllocation, _targetAllocationWad); + } + + /// @notice Set the minimum amount of assets that must be deposited/withdrawn when rebalancing + /// @param _minimumRebalanceAmount The minimum amount of assets for rebalancing + function setMinimumRebalanceAmount(uint120 _minimumRebalanceAmount) + external + onlyRole(GENERAL_MANAGER_ROLE) + { + uint256 oldAmount = minimumRebalanceAmount; + minimumRebalanceAmount = _minimumRebalanceAmount; + emit MinimumRebalanceAmountUpdated(oldAmount, _minimumRebalanceAmount); + } + + /// @notice Set the rebalance cooldown period + /// @param _rebalanceCooldown The minimum time in seconds that must pass between rebalances + function setRebalanceCooldown(uint32 _rebalanceCooldown) + external + onlyRole(GENERAL_MANAGER_ROLE) + { + if (_rebalanceCooldown < MIN_REBALANCE_COOLDOWN) { + revert RebalanceCooldownTooLow(_rebalanceCooldown, MIN_REBALANCE_COOLDOWN); + } + uint256 oldCooldown = rebalanceCooldown; + rebalanceCooldown = _rebalanceCooldown; + emit RebalanceCooldownUpdated(oldCooldown, _rebalanceCooldown); + } + + /// @notice Set the beneficiary address for performance fees + /// @param newBeneficiary Address to receive performance fees + function setBeneficiary(address newBeneficiary) external onlyRole(FEE_MANAGER_ROLE) { + address oldBeneficiary = beneficiary; + beneficiary = newBeneficiary; + emit BeneficiaryUpdated(oldBeneficiary, newBeneficiary); + } + + /// @notice Add or remove a subvault from the whitelist. + /// Malicious, misconfigured, or buggy subVaults may cause total loss of funds. + /// @param _subVault The subvault address to update + /// @param _whitelisted True to whitelist the subvault, false to remove it + function setSubVaultWhitelist(address _subVault, bool _whitelisted) + external + onlyRole(ADMIN_ROLE) + { + _setSubVaultWhitelist(_subVault, _whitelisted); + } + + /// @notice Check if a subvault is whitelisted + /// @param _subVault The subvault address to check + /// @return True if the subvault is whitelisted + function isSubVaultWhitelisted(address _subVault) public view returns (bool) { + return _whitelistedSubVaults.contains(_subVault); + } + + /// @notice Get all whitelisted subvaults + /// @return Array of all whitelisted subvault addresses + function whitelistedSubVaults() external view returns (address[] memory) { + return _whitelistedSubVaults.values(); + } + + /// @notice Pause deposits and withdrawals + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + /// @notice Unpause deposits and withdrawals + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + + /// @dev Overridden to add EXTRA_DECIMALS to the underlying asset decimals + /// @notice Requires underlying asset to implement IERC20Metadata.decimals() + function decimals() public view override returns (uint8) { + // todo: try catch in case no decimals + return IERC20Metadata(address(asset)).decimals() + EXTRA_DECIMALS; + } + + /// @notice Get the total assets managed by the vault + function totalAssets() public view returns (uint256) { + return _totalAssets(MathUpgradeable.Rounding.Down); + } + + /// @notice Get the total profit earned by the vault + function totalProfit() public view returns (uint256) { + uint256 __totalAssets = _totalAssets(MathUpgradeable.Rounding.Down); + uint256 __totalPrincipal = _totalPrincipal(MathUpgradeable.Rounding.Up); + return __totalAssets > __totalPrincipal ? __totalAssets - __totalPrincipal : 0; + } + + /// @dev Overriden to check MasterVaultRoles registry in addition to local roles + function hasRole(bytes32 role, address account) + public + view + virtual + override(AccessControlUpgradeable, IAccessControlUpgradeable) + returns (bool) + { + return super.hasRole(role, account) || rolesRegistry.hasRole(role, account); + } + + /// @dev Internal fee distribution function + /// Will revert if beneficiary is not set + function _distributePerformanceFee() internal { + if (beneficiary == address(0)) { + revert BeneficiaryNotSet(); + } + + uint256 profit = totalProfit(); + // slither-disable-next-line incorrect-equality + if (profit == 0) return; + + uint256 totalIdle = asset.balanceOf(address(this)); + + uint256 amountToTransfer = profit <= totalIdle ? profit : totalIdle; + uint256 amountToWithdraw = profit - amountToTransfer; + + if (amountToTransfer > 0) { + asset.safeTransfer(beneficiary, amountToTransfer); + } + if (amountToWithdraw > 0) { + // slither-disable-next-line unused-return + subVault.withdraw(amountToWithdraw, beneficiary, address(this)); + } + + emit PerformanceFeesWithdrawn(beneficiary, amountToTransfer, amountToWithdraw); + } + + /// @dev Internal total assets function supporting a specific rounding direction + /// We add one as part of the first deposit mitigation. + /// See for details: https://docs.openzeppelin.com/contracts/5.x/erc4626 + function _totalAssets(MathUpgradeable.Rounding rounding) internal view returns (uint256) { + return 1 + asset.balanceOf(address(this)) + + _subVaultSharesToAssets(subVault.balanceOf(address(this)), rounding); + } + + /// @dev Internal total principal function supporting a specific rounding direction + /// Total principal is totalSupply / 10^EXTRA_DECIMALS + function _totalPrincipal(MathUpgradeable.Rounding rounding) internal view returns (uint256) { + return totalSupply().mulDiv(1, 10 ** EXTRA_DECIMALS, rounding); + } + + /// @dev Converts assets to shares, rounding down. + /// Uses ideal ratio when solvent, standard formula when in loss. + function _convertToSharesRoundDown(uint256 assets) internal view returns (uint256 shares) { + // bias against the depositor by rounding DOWN totalAssets to more easily detect losses + if (_haveLoss()) { + // we have losses + return assets.mulDiv( + totalSupply(), + _totalAssets(MathUpgradeable.Rounding.Up), + MathUpgradeable.Rounding.Down + ); + } + // no losses, use ideal ratio + return assets * (10 ** EXTRA_DECIMALS); + } + + /// @dev Converts shares to assets, rounding down. + /// Uses ideal ratio when solvent, standard formula when in loss. + function _convertToAssetsRoundDown(uint256 shares) internal view returns (uint256 assets) { + // bias against the depositor by rounding DOWN totalAssets to more easily detect losses + if (_haveLoss()) { + // we have losses + return shares.mulDiv( + _totalAssets(MathUpgradeable.Rounding.Down), + totalSupply(), + MathUpgradeable.Rounding.Down + ); + } + // no losses, use ideal ratio + return shares / (10 ** EXTRA_DECIMALS); + } + + /// @dev Whether the vault has losses + function _haveLoss() internal view returns (bool) { + return _totalAssets(MathUpgradeable.Rounding.Down) * (10 ** EXTRA_DECIMALS) < totalSupply(); + } + + /// @dev Converts subvault shares to assets using the subvault's preview functions + /// If rounding is Up, uses previewMint; if Down, uses previewRedeem + function _subVaultSharesToAssets(uint256 subShares, MathUpgradeable.Rounding rounding) + internal + view + returns (uint256 assets) + { + return rounding == MathUpgradeable.Rounding.Up + ? subVault.previewMint(subShares) + : subVault.previewRedeem(subShares); + } + + /// @dev Validates exchange rate for a deposit operation (assets spent per share received) + function _validateDepositExchRate( + int256 minExchRateWad, + uint256 assetsSpent, + uint256 subVaultShares + ) internal pure { + if (minExchRateWad > 0) { + revert RebalanceExchRateWrongSign(minExchRateWad); + } + // slither-disable-next-line incorrect-equality + uint256 actualExchRate = subVaultShares == 0 + ? type(uint256).max + : assetsSpent.mulDiv(1e18, subVaultShares, MathUpgradeable.Rounding.Up); + if (actualExchRate > uint256(-minExchRateWad)) { + revert RebalanceExchRateTooLow(minExchRateWad, -int256(assetsSpent), subVaultShares); + } + } + + /// @dev Validates exchange rate for a withdraw/redeem operation + function _validateWithdrawExchRate( + int256 minExchRateWad, + uint256 assetsReceived, + uint256 subVaultShares + ) internal pure { + if (minExchRateWad < 0) { + revert RebalanceExchRateWrongSign(minExchRateWad); + } + // we do not need to check for a div by zero because a subvault would never give us assets for zero shares + uint256 actualExchRate = + assetsReceived.mulDiv(1e18, subVaultShares, MathUpgradeable.Rounding.Down); + if (actualExchRate < uint256(minExchRateWad)) { + revert RebalanceExchRateTooLow(minExchRateWad, int256(assetsReceived), subVaultShares); + } + } + + /// @dev Helper to add/remove a subvault from the whitelist + function _setSubVaultWhitelist(address _subVault, bool _whitelisted) internal { + // slither-disable-next-line unused-return + _whitelisted + ? _whitelistedSubVaults.add(_subVault) + : _whitelistedSubVaults.remove(_subVault); + emit SubVaultWhitelistUpdated(_subVault, _whitelisted); + } +} diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol new file mode 100644 index 000000000..b197e02dc --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import "../ClonableBeaconProxy.sol"; +import "./IMasterVaultFactory.sol"; +import "./MasterVault.sol"; +import "../gateway/IGatewayRouter.sol"; + +contract DefaultSubVault is ERC4626 { + address public immutable masterVault; + + constructor(address token, address _masterVault) + ERC4626(IERC20(token)) + ERC20("Default SubVault", "DSV") + { + masterVault = _masterVault; + } + + function deposit(uint256 assets, address receiver) public override returns (uint256) { + require(msg.sender == masterVault, "ONLY_MASTER_VAULT"); + return super.deposit(assets, receiver); + } + + function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) { + require(msg.sender == masterVault, "ONLY_MASTER_VAULT"); + return super.withdraw(assets, receiver, owner); + } + + function mint(uint256, address) public pure override returns (uint256) { + revert("UNSUPPORTED"); + } + + function redeem(uint256 shares, address receiver, address owner) public override returns (uint256) { + require(msg.sender == masterVault, "ONLY_MASTER_VAULT"); + return super.redeem(shares, receiver, owner); + } +} + +// todo: slim down this contract +contract MasterVaultFactory is IMasterVaultFactory, Initializable { + BeaconProxyFactory public beaconProxyFactory; + MasterVaultRoles public rolesRegistry; + IGatewayRouter public gatewayRouter; + + function initialize( + address _masterVaultImplementation, + address _admin, + IGatewayRouter _gatewayRouter + ) public initializer { + MasterVaultRoles _rolesRegistry = new MasterVaultRoles(); + _rolesRegistry.initialize(_admin); + rolesRegistry = _rolesRegistry; + + UpgradeableBeacon beacon = new UpgradeableBeacon(_masterVaultImplementation); + beacon.transferOwnership(_admin); + BeaconProxyFactory _beaconProxyFactory = new BeaconProxyFactory(); + _beaconProxyFactory.initialize(address(beacon)); + beaconProxyFactory = _beaconProxyFactory; + + gatewayRouter = _gatewayRouter; + } + + function deployVault(address token) public returns (address vault) { + bytes32 userSalt = _getUserSalt(token); + vault = beaconProxyFactory.createProxy(userSalt); + + string memory name = string(abi.encodePacked("Master ", _tryGetTokenName(token))); + string memory symbol = string(abi.encodePacked("m", _tryGetTokenSymbol(token))); + + MasterVault(vault) + .initialize( + new DefaultSubVault(token, vault), name, symbol, rolesRegistry, gatewayRouter + ); + + emit VaultDeployed(token, vault); + } + + function _getUserSalt(address token) internal pure returns (bytes32) { + return keccak256(abi.encode(token)); + } + + function calculateVaultAddress(address token) public view returns (address) { + bytes32 userSalt = _getUserSalt(token); + return beaconProxyFactory.calculateExpectedAddress(address(this), userSalt); + } + + function getVault(address token) external returns (address) { + address vault = calculateVaultAddress(token); + if (vault.code.length == 0) { + return deployVault(token); + } + return vault; + } + + function _tryGetTokenName(address token) internal view returns (string memory) { + try IERC20Metadata(token).name() returns (string memory name) { + return name; + } catch { + return ""; + } + } + + function _tryGetTokenSymbol(address token) internal view returns (string memory) { + try IERC20Metadata(token).symbol() returns (string memory symbol) { + return symbol; + } catch { + return ""; + } + } +} diff --git a/contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol b/contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol new file mode 100644 index 000000000..b3442c1d8 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { + AccessControlEnumerableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; + +//todo: document trust levels and rug abilities +/// @notice Roles system for MasterVaults. +/// Each MasterVault will have a reference to a singleton MasterVaultRoles contract, in addition to inheriting MasterVaultRoles directly. +/// This allows for easier management of roles across multiple vaults. +contract MasterVaultRoles is AccessControlEnumerableUpgradeable { + /// @notice The admin can: + /// - Grant/revoke GENERAL_MANAGER_ROLE and ADMIN_ROLE + /// - Add/remove whitelisted subvaults + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + /// @notice The general manager can: + /// - Grant/revoke FEE_MANAGER_ROLE, PAUSER_ROLE, and KEEPER_ROLE + /// - Set the subVault to any whitelisted subVault + /// - Set the target allocation + /// - Set the minimum rebalance amount + /// - Set the rebalance cooldown + bytes32 public constant GENERAL_MANAGER_ROLE = keccak256("GENERAL_MANAGER_ROLE"); + /// @notice The fee manager can: + /// - Toggle performance fees on/off + /// - Set the performance fee beneficiary + bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); + /// @notice The pauser can: + /// - pause/unpause deposits and withdrawals + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + /// @notice The keeper can: + /// - rebalance + /// - distribute performance fees + bytes32 public constant KEEPER_ROLE = keccak256("KEEPER_ROLE"); + + function _init_roles() internal { + // set ADMIN_ROLE as admin of appropriate roles + _setRoleAdmin(ADMIN_ROLE, ADMIN_ROLE); + _setRoleAdmin(GENERAL_MANAGER_ROLE, ADMIN_ROLE); + + // set GENERAL_MANAGER_ROLE as admin of appropriate roles + _setRoleAdmin(FEE_MANAGER_ROLE, GENERAL_MANAGER_ROLE); + _setRoleAdmin(PAUSER_ROLE, GENERAL_MANAGER_ROLE); + _setRoleAdmin(KEEPER_ROLE, GENERAL_MANAGER_ROLE); + } + + function __MasterVaultRoles_init() internal onlyInitializing { + __AccessControlEnumerable_init(); + _init_roles(); + } + + function initialize(address admin) external initializer { + _init_roles(); + + // grant ADMIN_ROLE to admin + _grantRole(ADMIN_ROLE, admin); + } +} diff --git a/contracts/tokenbridge/libraries/vault/YbbVaultLib.sol b/contracts/tokenbridge/libraries/vault/YbbVaultLib.sol new file mode 100644 index 000000000..c50a346b9 --- /dev/null +++ b/contracts/tokenbridge/libraries/vault/YbbVaultLib.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IMasterVault} from "./IMasterVault.sol"; +import {IMasterVaultFactory} from "./IMasterVaultFactory.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +library YbbVaultLib { + using SafeERC20 for IERC20; + + // slither-disable-next-line arbitrary-send-erc20 + function depositToVault(address masterVaultFactory, address token, address from, uint256 amount) + internal + returns (uint256 shares) + { + uint256 prevBalance = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransferFrom(from, address(this), amount); + uint256 postBalance = IERC20(token).balanceOf(address(this)); + uint256 amountReceived = postBalance - prevBalance; + + address masterVault = IMasterVaultFactory(masterVaultFactory).getVault(token); + IERC20(token).safeIncreaseAllowance(masterVault, amountReceived); + shares = IMasterVault(masterVault).deposit(amountReceived); + require(shares > 0, "ZERO_SHARES"); + } + + function withdrawFromVault( + address masterVaultFactory, + address token, + address dest, + uint256 amount + ) internal { + address masterVault = IMasterVaultFactory(masterVaultFactory).getVault(token); + IERC20(masterVault).safeTransfer(dest, amount); + } +} diff --git a/contracts/tokenbridge/test/FuzzSubVault.sol b/contracts/tokenbridge/test/FuzzSubVault.sol new file mode 100644 index 000000000..17e8e1f5d --- /dev/null +++ b/contracts/tokenbridge/test/FuzzSubVault.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @notice Minimal vault mock for fuzz/invariant testing. +/// @dev Only implements the ERC4626 subset that MasterVault actually calls: +/// asset(), deposit(), withdraw(), redeem(), maxDeposit(), maxWithdraw(), +/// maxRedeem(), previewMint(), previewRedeem(), balanceOf() (via ERC20). +/// No full ERC4626 inheritance — keeps the mock auditable. +contract FuzzSubVault is ERC20 { + using SafeERC20 for IERC20; + using Math for uint256; + + IERC20 private immutable _asset; + uint256 public maxWithdrawLimit = type(uint256).max; + uint256 public maxDepositLimit = type(uint256).max; + uint256 public maxRedeemLimit = type(uint256).max; + + uint256 public depositErrorWad; + uint256 public withdrawErrorWad; + uint256 public redeemErrorWad; + uint256 public previewMintErrorWad; + uint256 public previewRedeemErrorWad; + + constructor(IERC20 asset_, string memory _name, string memory _symbol) ERC20(_name, _symbol) { + _asset = asset_; + _mint(address(this), 1); + } + + function asset() public view returns (address) { + return address(_asset); + } + + function totalAssets() public view returns (uint256) { + return _asset.balanceOf(address(this)) + 1; + } + + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + require(assets <= maxDepositLimit, "FuzzSubVault: deposit exceeds max"); + shares = _penalizeDown(_convertToShares(assets, Math.Rounding.Down), depositErrorWad); + _asset.safeTransferFrom(msg.sender, address(this), assets); + _mint(receiver, shares); + } + + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) { + require(assets <= maxWithdrawLimit, "FuzzSubVault: withdraw exceeds max"); + shares = _penalizeUp(_convertToShares(assets, Math.Rounding.Up), withdrawErrorWad); + _burn(owner, shares); + _asset.safeTransfer(receiver, assets); + } + + function maxWithdraw(address owner) public view returns (uint256) { + uint256 ownerShares = balanceOf(owner); + if (withdrawErrorWad > 0) { + ownerShares = ownerShares.mulDiv(1e18, 1e18 + withdrawErrorWad, Math.Rounding.Down); + } + uint256 ownerAssets = _convertToAssets(ownerShares, Math.Rounding.Down); + uint256 available = totalAssets(); + uint256 natural = ownerAssets < available ? ownerAssets : available; + return natural < maxWithdrawLimit ? natural : maxWithdrawLimit; + } + + function maxRedeem(address owner) public view returns (uint256) { + uint256 bal = balanceOf(owner); + return bal < maxRedeemLimit ? bal : maxRedeemLimit; + } + + function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) { + assets = _penalizeDown(_convertToAssets(shares, Math.Rounding.Down), redeemErrorWad); + require(shares <= maxRedeemLimit, "FuzzSubVault: redeem exceeds max"); + _burn(owner, shares); + _asset.safeTransfer(receiver, assets); + } + + function maxDeposit(address) public view returns (uint256) { + return maxDepositLimit; + } + + function previewMint(uint256 shares) public view returns (uint256) { + return _penalizeUp(_convertToAssets(shares, Math.Rounding.Up), previewMintErrorWad); + } + + function previewRedeem(uint256 shares) public view returns (uint256) { + return _penalizeDown(_convertToAssets(shares, Math.Rounding.Down), previewRedeemErrorWad); + } + + // --- Test helpers --- + + function adminMint(address to, uint256 shares) external { + _mint(to, shares); + } + + function adminBurn(address from, uint256 shares) external { + _burn(from, shares); + } + + function setMaxWithdrawLimit(uint256 limit) external { + maxWithdrawLimit = limit; + } + + function setMaxDepositLimit(uint256 limit) external { + maxDepositLimit = limit; + } + + function setMaxRedeemLimit(uint256 limit) external { + maxRedeemLimit = limit; + } + + function setDepositErrorWad(uint256 wad) external { + depositErrorWad = wad; + } + + function setWithdrawErrorWad(uint256 wad) external { + withdrawErrorWad = wad; + } + + function setRedeemErrorWad(uint256 wad) external { + redeemErrorWad = wad; + } + + function setPreviewMintErrorWad(uint256 wad) external { + previewMintErrorWad = wad; + } + + function setPreviewRedeemErrorWad(uint256 wad) external { + previewRedeemErrorWad = wad; + } + + // --- Internal math --- + + function _penalizeDown(uint256 value, uint256 errWad) private pure returns (uint256) { + if (errWad == 0) return value; + return value.mulDiv(1e18 - errWad, 1e18, Math.Rounding.Down); + } + + function _penalizeUp(uint256 value, uint256 errWad) private pure returns (uint256) { + if (errWad == 0) return value; + return value.mulDiv(1e18 + errWad, 1e18, Math.Rounding.Up); + } + + function _convertToShares(uint256 assets, Math.Rounding rounding) private view returns (uint256) { + return assets.mulDiv(totalSupply(), totalAssets(), rounding); + } + + function _convertToAssets(uint256 shares, Math.Rounding rounding) private view returns (uint256) { + return shares.mulDiv(totalAssets(), totalSupply(), rounding); + } +} diff --git a/contracts/tokenbridge/test/MockSubVault.sol b/contracts/tokenbridge/test/MockSubVault.sol new file mode 100644 index 000000000..44087873d --- /dev/null +++ b/contracts/tokenbridge/test/MockSubVault.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockSubVault is ERC4626 { + constructor(IERC20 _asset, string memory _name, string memory _symbol) + ERC20(_name, _symbol) + ERC4626(_asset) + {} + + function totalAssets() public view override returns (uint256) { + return IERC20(asset()).balanceOf(address(this)); + } + + function adminMint(address to, uint256 amount) external { + _mint(to, amount); + } +} \ No newline at end of file diff --git a/contracts/tokenbridge/test/TestERC20.sol b/contracts/tokenbridge/test/TestERC20.sol index 71dd2c005..0cac4ff5a 100644 --- a/contracts/tokenbridge/test/TestERC20.sol +++ b/contracts/tokenbridge/test/TestERC20.sol @@ -25,8 +25,12 @@ contract TestERC20 is aeERC20 { aeERC20._initialize("IntArbTestToken", "IARB", uint8(18)); } + function mintAmount(uint256 amount) external { + _mint(msg.sender, amount); + } + function mint() external { - _mint(msg.sender, 50000000); + _mint(msg.sender, 50_000_000); } } @@ -39,11 +43,7 @@ contract Bytes32ERC20 { return transferFrom(msg.sender, dst, wad); } - function transferFrom( - address src, - address dst, - uint256 wad - ) public returns (bool) { + function transferFrom(address src, address dst, uint256 wad) public returns (bool) { if (src != msg.sender) { allowance[src][msg.sender] = allowance[src][msg.sender] - wad; } @@ -72,4 +72,4 @@ contract Bytes32ERC20WithMetadata is Bytes32ERC20 { // no totalSupply field // uint256 public totalSupply; -} +} \ No newline at end of file diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 000000000..1fff3eb83 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,11 @@ +{ + "lib/forge-std": { + "rev": "2b58ecbcf3dfde7a75959dc7b4eb3d0670278de6" + }, + "lib/nitro-contracts": { + "branch": { + "name": "develop", + "rev": "0770fe07d99c499bebfaf3b7df37047d19032050" + } + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index d09cdf554..b1c1f5847 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,14 +5,34 @@ libs = ["node_modules", "lib"] test = 'test-foundry' cache_path = 'forge-cache' optimizer = true -optimizer_runs = 100 +optimizer_runs = 200 via_ir = false fs_permissions = [{ access = "read", path = "node_modules/@offchainlabs/stablecoin-evm"}] [fmt] number_underscore = 'thousands' line_length = 100 -# See more config options https://github.com/foundry-rs/foundry/tree/master/config +tab_width = 4 +style = "space" +bracket_spacing = false +int_types = "long" +multiline_func_header = "attributes_first" +quote_style = "double" +hex_underscore = "remove" +single_line_statement_blocks = "preserve" +override_spacing = false +wrap_comments = false +docs_style = "preserve" +ignore = [] +contract_new_lines = false +sort_imports = false +pow_no_space = false +prefer_compact = "all" +single_line_imports = false + +[invariant] +runs = 256 +depth = 100 [lint] -lint_on_build = false \ No newline at end of file +lint_on_build = false diff --git a/scripts/atomicTokenBridgeDeployer.ts b/scripts/atomicTokenBridgeDeployer.ts index 47a375ac2..afcc6f3da 100644 --- a/scripts/atomicTokenBridgeDeployer.ts +++ b/scripts/atomicTokenBridgeDeployer.ts @@ -18,6 +18,12 @@ import { L1OrbitERC20Gateway__factory, L1OrbitCustomGateway__factory, L1OrbitGatewayRouter__factory, + L1YbbERC20Gateway__factory, + L1YbbCustomGateway__factory, + L1OrbitYbbERC20Gateway__factory, + L1OrbitYbbCustomGateway__factory, + MasterVault__factory, + MasterVaultFactory__factory, IInbox__factory, IERC20Bridge__factory, IERC20__factory, @@ -31,6 +37,10 @@ import { abi as UpgradeExecutorABI, bytecode as UpgradeExecutorBytecode, } from '@offchainlabs/upgrade-executor/build/contracts/src/UpgradeExecutor.sol/UpgradeExecutor.json' +import { + abi as L1GatewayDeployerABI, + bytecode as L1GatewayDeployerBytecode, +} from '../build/contracts/contracts/tokenbridge/ethereum/L1GatewayDeployer.sol/L1GatewayDeployer.json' import { JsonRpcProvider } from '@ethersproject/providers' import { ParentToChildMessageGasEstimator, @@ -238,8 +248,21 @@ export const deployL1TokenBridgeCreator = async ( ).deploy() await l1TokenBridgeCreatorProxyAdmin.deployed() + const l1GatewayDeployerLib = await new ethers.ContractFactory( + L1GatewayDeployerABI, + L1GatewayDeployerBytecode, + l1Deployer + ).deploy() + await l1GatewayDeployerLib.deployed() + const l1TokenBridgeCreatorLogic = - await new L1AtomicTokenBridgeCreator__factory(l1Deployer).deploy() + await new L1AtomicTokenBridgeCreator__factory( + { + 'contracts/tokenbridge/ethereum/L1GatewayDeployer.sol:L1GatewayDeployer': + l1GatewayDeployerLib.address, + }, + l1Deployer + ).deploy() await l1TokenBridgeCreatorLogic.deployed() const l1TokenBridgeCreatorProxy = @@ -382,6 +405,30 @@ export const deployL1TokenBridgeCreator = async ( const upgradeExecutor = await upgradeExecutorFactory.deploy() await upgradeExecutor.deployed() + const ybbStandardGatewayTemplate = + await new L1YbbERC20Gateway__factory(l1Deployer).deploy() + await ybbStandardGatewayTemplate.deployed() + + const ybbCustomGatewayTemplate = + await new L1YbbCustomGateway__factory(l1Deployer).deploy() + await ybbCustomGatewayTemplate.deployed() + + const feeTokenBasedYbbStandardGatewayTemplate = + await new L1OrbitYbbERC20Gateway__factory(l1Deployer).deploy() + await feeTokenBasedYbbStandardGatewayTemplate.deployed() + + const feeTokenBasedYbbCustomGatewayTemplate = + await new L1OrbitYbbCustomGateway__factory(l1Deployer).deploy() + await feeTokenBasedYbbCustomGatewayTemplate.deployed() + + const masterVaultFactoryTemplate = + await new MasterVaultFactory__factory(l1Deployer).deploy() + await masterVaultFactoryTemplate.deployed() + + const masterVaultTemplate = + await new MasterVault__factory(l1Deployer).deploy() + await masterVaultTemplate.deployed() + const l1Templates = { routerTemplate: routerTemplate.address, standardGatewayTemplate: standardGatewayTemplate.address, @@ -463,6 +510,19 @@ export const deployL1TokenBridgeCreator = async ( ) ).wait() + await ( + await l1TokenBridgeCreator.setYbbTemplates({ + ybbStandardGatewayTemplate: ybbStandardGatewayTemplate.address, + ybbCustomGatewayTemplate: ybbCustomGatewayTemplate.address, + feeTokenBasedYbbStandardGatewayTemplate: + feeTokenBasedYbbStandardGatewayTemplate.address, + feeTokenBasedYbbCustomGatewayTemplate: + feeTokenBasedYbbCustomGatewayTemplate.address, + masterVaultFactoryTemplate: masterVaultFactoryTemplate.address, + masterVaultTemplate: masterVaultTemplate.address, + }) + ).wait() + ///// verify contracts if (verifyContracts) { console.log('\n\n Start contract verification \n\n') diff --git a/test-foundry/AtomicTokenBridgeFactory.t.sol b/test-foundry/AtomicTokenBridgeFactory.t.sol index 61e834a72..239babcc4 100644 --- a/test-foundry/AtomicTokenBridgeFactory.t.sol +++ b/test-foundry/AtomicTokenBridgeFactory.t.sol @@ -7,14 +7,35 @@ import "forge-std/Test.sol"; import "../contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol"; import "../contracts/tokenbridge/arbitrum/L2AtomicTokenBridgeFactory.sol"; import "../contracts/tokenbridge/libraries/AddressAliasHelper.sol"; - -import {L1TokenBridgeRetryableSender} from - "../contracts/tokenbridge/ethereum/L1TokenBridgeRetryableSender.sol"; +import {L1ERC20Gateway} from "../contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol"; +import {L1CustomGateway} from "../contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol"; +import {L1WethGateway} from "../contracts/tokenbridge/ethereum/gateway/L1WethGateway.sol"; +import { + L1OrbitERC20Gateway +} from "../contracts/tokenbridge/ethereum/gateway/L1OrbitERC20Gateway.sol"; +import { + L1OrbitCustomGateway +} from "../contracts/tokenbridge/ethereum/gateway/L1OrbitCustomGateway.sol"; +import {L1YbbERC20Gateway} from "../contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol"; +import {L1YbbCustomGateway} from "../contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol"; +import { + L1OrbitYbbERC20Gateway +} from "../contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol"; +import { + L1OrbitYbbCustomGateway +} from "../contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol"; +import {MasterVaultFactory} from "../contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {MasterVault} from "../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +import { + L1TokenBridgeRetryableSender +} from "../contracts/tokenbridge/ethereum/L1TokenBridgeRetryableSender.sol"; import {TestWETH9} from "../contracts/tokenbridge/test/TestWETH9.sol"; import {Multicall2} from "../contracts/rpc-utils/MulticallV2.sol"; -import {TransparentUpgradeableProxy} from - "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { + TransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; // // Check that the rollupOwner account has EXECUTOR role // // on the upgrade executor which is the owner of the rollup @@ -132,14 +153,24 @@ contract AtomicTokenBridgeCreatorTest is Test { function setUp() public { l1Templates = L1AtomicTokenBridgeCreator.L1Templates( L1GatewayRouter(address(new L1GatewayRouter())), - L1ERC20Gateway(address(new L1ERC20Gateway())), - L1CustomGateway(address(new L1CustomGateway())), - L1WethGateway(payable(new L1WethGateway())), + address(new L1ERC20Gateway()), + address(new L1CustomGateway()), + address(new L1WethGateway()), L1OrbitGatewayRouter(address(new L1OrbitGatewayRouter())), - L1OrbitERC20Gateway(address(new L1OrbitERC20Gateway())), - L1OrbitCustomGateway(address(new L1OrbitCustomGateway())), + address(new L1OrbitERC20Gateway()), + address(new L1OrbitCustomGateway()), IUpgradeExecutor(address(new UpgradeExecutor())) ); + + L1AtomicTokenBridgeCreator.YbbL1Templates memory ybbTemplates = + L1AtomicTokenBridgeCreator.YbbL1Templates( + address(new L1YbbERC20Gateway()), + address(new L1YbbCustomGateway()), + address(new L1OrbitYbbERC20Gateway()), + address(new L1OrbitYbbCustomGateway()), + address(new MasterVaultFactory()), + address(new MasterVault()) + ); l2TokenBridgeFactoryTemplate = address(new L2AtomicTokenBridgeFactory()); l2RouterTemplate = address(new L2GatewayRouter()); l2StandardGatewayTemplate = address(new L2ERC20Gateway()); @@ -170,6 +201,7 @@ contract AtomicTokenBridgeCreatorTest is Test { l1MultiCall, MAX_DEPLOYMENT_GAS ); + factory.setYbbTemplates(ybbTemplates); } function testDeployment() public { @@ -188,15 +220,12 @@ contract AtomicTokenBridgeCreatorTest is Test { // Mode 2 simulate this case where the deployment fails and the call is executed first MockInbox inbox = new MockInbox(2); factory.createTokenBridge({ - inbox: address(inbox), - rollupOwner: address(this), - maxGasForContracts: 0, - gasPriceBid: 0 + inbox: address(inbox), rollupOwner: address(this), maxGasForContracts: 0, gasPriceBid: 0 }); // L2 Factory is not deployed in this case address l2factory = factory.canonicalL2FactoryAddress(); - assertEq(l2factory, 0x20011A455c9eBBeD73CA307539D3e9Baff600fBD); + assertEq(l2factory, 0xf6610B47ad3309979b17210d8D36C01C3E9f9905); assertEq(l2factory.code.length, 0); inbox.setMode(0); // set back to normal mode @@ -205,29 +234,26 @@ contract AtomicTokenBridgeCreatorTest is Test { function _testDeployment(address inbox) internal { factory.createTokenBridge({ - inbox: address(inbox), - rollupOwner: address(this), - maxGasForContracts: 0, - gasPriceBid: 0 + inbox: address(inbox), rollupOwner: address(this), maxGasForContracts: 0, gasPriceBid: 0 }); { address l2factory = factory.canonicalL2FactoryAddress(); - assertEq(l2factory, 0x20011A455c9eBBeD73CA307539D3e9Baff600fBD); - assertTrue(l2factory.code.length > 0); + assertTrue(l2factory != address(0), "l2factory should be non-zero"); + assertTrue(l2factory.code.length > 0, "l2factory should have code"); } { (address l1r, address l1sgw, address l1cgw, address l1wgw, address l1w) = factory.inboxToL1Deployment(address(inbox)); - assertEq(l1r, 0xcB37BCa7042A10FfA75Ff95Ad8B361A13bbAA63A, "l1r"); + assertTrue(l1r != address(0), "l1r should be non-zero"); assertTrue(l1r.code.length > 0, "l1r code"); - assertEq(l1sgw, 0x013b54d88f76fb9D05b8382747beb1B4Df313507, "l1sgw"); + assertTrue(l1sgw != address(0), "l1sgw should be non-zero"); assertTrue(l1sgw.code.length > 0, "l1sgw code"); - assertEq(l1cgw, 0xf8663294698E0623de82B9791906454A2036575F, "l1cgw"); + assertTrue(l1cgw != address(0), "l1cgw should be non-zero"); assertTrue(l1cgw.code.length > 0, "l1cgw code"); - assertEq(l1wgw, 0x79eF26bE05C5643D5AdC81B8c7e49b0898A74428, "l1wgw"); + assertTrue(l1wgw != address(0), "l1wgw should be non-zero"); assertTrue(l1wgw.code.length > 0, "l1wgw code"); - assertEq(l1w, 0x96d3F6c20EEd2697647F543fE6C08bC2Fbf39758, "l1w"); + assertTrue(l1w != address(0), "l1w should be non-zero"); assertTrue(l1w.code.length > 0, "l1w code"); } { @@ -243,23 +269,23 @@ contract AtomicTokenBridgeCreatorTest is Test { address l2mc ) = factory.inboxToL2Deployment(address(inbox)); - assertEq(l2r, 0xdB4050B663976d45E810B7C0E3B8B25564bD620d, "l2r"); + assertTrue(l2r != address(0), "l2r should be non-zero"); assertTrue(l2r.code.length > 0, "l2r code"); - assertEq(l2sgw, 0x25F753b06E1e092292e6773E119D00BEe5A1b8D4, "l2sgw"); + assertTrue(l2sgw != address(0), "l2sgw should be non-zero"); assertTrue(l2sgw.code.length > 0, "l2sgw code"); - assertEq(l2cgw, 0x4Ca25428D90D0813EC134b5160eb6301909B4A9B, "l2cgw"); + assertTrue(l2cgw != address(0), "l2cgw should be non-zero"); assertTrue(l2cgw.code.length > 0, "l2cgw code"); - assertEq(l2wgw, 0x29B1Fa62Af163E550Cb4173BE58787fa2d6456fF, "l2wgw"); + assertTrue(l2wgw != address(0), "l2wgw should be non-zero"); assertTrue(l2wgw.code.length > 0, "l2wgw code"); - assertEq(l2w, 0x7C9c18AE0EeA13600496D1222E8Ec22738b29C61, "l2w"); + assertTrue(l2w != address(0), "l2w should be non-zero"); assertTrue(l2w.code.length > 0, "l2w code"); - assertEq(l2pa, 0xf789F48Bc2c9ee6E98E564E6383B394ba6F9378c, "l2pa"); + assertTrue(l2pa != address(0), "l2pa should be non-zero"); assertTrue(l2pa.code.length > 0, "l2pa code"); - assertEq(l2bpf, 0x9446B15B1128aD326Ccf310a68F2FFB652D31934, "l2bpf"); + assertTrue(l2bpf != address(0), "l2bpf should be non-zero"); assertTrue(l2bpf.code.length > 0, "l2bpf code"); - assertEq(l2ue, 0xC85c71251E9354Cd6a8992BC02d968B04F4b55e6, "l2ue"); + assertTrue(l2ue != address(0), "l2ue should be non-zero"); assertTrue(l2ue.code.length > 0, "l2ue code"); - assertEq(l2mc, 0x6466F88A4E3B536892e706258c1079D0a880d7Cb, "l2mc"); + assertTrue(l2mc != address(0), "l2mc should be non-zero"); assertTrue(l2mc.code.length > 0, "l2mc code"); } } diff --git a/test-foundry/L1AtomicTokenBridgeCreator.t.sol b/test-foundry/L1AtomicTokenBridgeCreator.t.sol index fd4ee0487..365e8005f 100644 --- a/test-foundry/L1AtomicTokenBridgeCreator.t.sol +++ b/test-foundry/L1AtomicTokenBridgeCreator.t.sol @@ -9,20 +9,35 @@ import { L2DeploymentAddresses, TransparentUpgradeableProxy, ProxyAdmin, - ClonableBeaconProxy, BeaconProxyFactory } from "contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol"; -import {L1TokenBridgeRetryableSender} from - "contracts/tokenbridge/ethereum/L1TokenBridgeRetryableSender.sol"; +import {ClonableBeaconProxy} from "contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import { + L1TokenBridgeRetryableSender +} from "contracts/tokenbridge/ethereum/L1TokenBridgeRetryableSender.sol"; import {TestUtil} from "./util/TestUtil.sol"; import {AddressAliasHelper} from "contracts/tokenbridge/libraries/AddressAliasHelper.sol"; import {L1GatewayRouter} from "contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol"; import {L1ERC20Gateway} from "contracts/tokenbridge/ethereum/gateway/L1ERC20Gateway.sol"; import {L1CustomGateway} from "contracts/tokenbridge/ethereum/gateway/L1CustomGateway.sol"; import {L1WethGateway} from "contracts/tokenbridge/ethereum/gateway/L1WethGateway.sol"; -import {L1OrbitGatewayRouter} from "contracts/tokenbridge/ethereum/gateway/L1OrbitGatewayRouter.sol"; +import { + L1OrbitGatewayRouter +} from "contracts/tokenbridge/ethereum/gateway/L1OrbitGatewayRouter.sol"; import {L1OrbitERC20Gateway} from "contracts/tokenbridge/ethereum/gateway/L1OrbitERC20Gateway.sol"; -import {L1OrbitCustomGateway} from "contracts/tokenbridge/ethereum/gateway/L1OrbitCustomGateway.sol"; +import { + L1OrbitCustomGateway +} from "contracts/tokenbridge/ethereum/gateway/L1OrbitCustomGateway.sol"; +import {L1YbbERC20Gateway} from "contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol"; +import {L1YbbCustomGateway} from "contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol"; +import { + L1OrbitYbbERC20Gateway +} from "contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol"; +import { + L1OrbitYbbCustomGateway +} from "contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol"; +import {MasterVaultFactory} from "contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {MasterVault} from "contracts/tokenbridge/libraries/vault/MasterVault.sol"; import { IUpgradeExecutor, UpgradeExecutor @@ -43,8 +58,9 @@ import {RollupAdminLogic} from "lib/nitro-contracts/src/rollup/RollupAdminLogic. import {RollupUserLogic} from "lib/nitro-contracts/src/rollup/RollupUserLogic.sol"; import {Config, ContractDependencies} from "lib/nitro-contracts/src/rollup/Config.sol"; import {ISequencerInbox} from "lib/nitro-contracts/src/bridge/ISequencerInbox.sol"; -import {ERC20PresetMinterPauser} from - "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; +import { + ERC20PresetMinterPauser +} from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; @@ -167,7 +183,7 @@ contract L1AtomicTokenBridgeCreatorTest is Test { (address l1RouterAddress, address l1StandardGatewayAddress,,,) = l1Creator.inboxToL1Deployment(address(inbox)); - (, L1ERC20Gateway standardGatewayTemplate,,,,,,) = l1Creator.l1Templates(); + (, address standardGatewayTemplate,,,,,,) = l1Creator.l1Templates(); address expectedL1StandardGatewayAddress = Create2.computeAddress( keccak256(abi.encodePacked(bytes("L1SGW"), address(inbox))), @@ -230,7 +246,7 @@ contract L1AtomicTokenBridgeCreatorTest is Test { (address l1RouterAddress,, address l1CustomGatewayAddress,,) = l1Creator.inboxToL1Deployment(address(inbox)); - (,, L1CustomGateway customGatewayTemplate,,,,,) = l1Creator.l1Templates(); + (,, address customGatewayTemplate,,,,,) = l1Creator.l1Templates(); address expectedL1CustomGatewayAddress = Create2.computeAddress( keccak256(abi.encodePacked(bytes("L1CGW"), address(inbox))), @@ -272,7 +288,7 @@ contract L1AtomicTokenBridgeCreatorTest is Test { (address l1RouterAddress,,, address l1WethGatewayAddress,) = l1Creator.inboxToL1Deployment(address(inbox)); - (,,, L1WethGateway wethGatewayTemplate,,,,) = l1Creator.l1Templates(); + (,,, address wethGatewayTemplate,,,,) = l1Creator.l1Templates(); address expectedL1WethGatewayAddress = Create2.computeAddress( keccak256(abi.encodePacked(bytes("L1WGW"), address(inbox))), @@ -323,8 +339,12 @@ contract L1AtomicTokenBridgeCreatorTest is Test { ERC20Inbox _inbox; ERC20 _nativeToken; { - (RollupProxy rollup, ERC20Inbox inbox,, UpgradeExecutor upgExecutor, ERC20 nativeToken) - = _createERC20Rollup(18); + ( + RollupProxy rollup, + ERC20Inbox inbox,, + UpgradeExecutor upgExecutor, + ERC20 nativeToken + ) = _createERC20Rollup(18); // mock owner() => upgExecutor vm.mockCall( @@ -428,8 +448,12 @@ contract L1AtomicTokenBridgeCreatorTest is Test { ERC20Inbox _inbox; ERC20 _nativeToken; { - (RollupProxy rollup, ERC20Inbox inbox,, UpgradeExecutor upgExecutor, ERC20 nativeToken) - = _createERC20Rollup(decimals); + ( + RollupProxy rollup, + ERC20Inbox inbox,, + UpgradeExecutor upgExecutor, + ERC20 nativeToken + ) = _createERC20Rollup(decimals); // mock owner() => upgExecutor vm.mockCall( @@ -570,9 +594,8 @@ contract L1AtomicTokenBridgeCreatorTest is Test { // expect revert when creating bridge vm.expectRevert( abi.encodeWithSelector( - L1AtomicTokenBridgeCreator - .L1AtomicTokenBridgeCreator_RollupOwnershipMisconfig - .selector + L1AtomicTokenBridgeCreator.L1AtomicTokenBridgeCreator_RollupOwnershipMisconfig + .selector ) ); l1Creator.createTokenBridge(address(inbox), deployer, 100, 200); @@ -735,17 +758,17 @@ contract L1AtomicTokenBridgeCreatorTest is Test { } function test_setTemplates() public { - L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = L1AtomicTokenBridgeCreator - .L1Templates( - new L1GatewayRouter(), - new L1ERC20Gateway(), - new L1CustomGateway(), - new L1WethGateway(), - new L1OrbitGatewayRouter(), - new L1OrbitERC20Gateway(), - new L1OrbitCustomGateway(), - new UpgradeExecutor() - ); + L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = + L1AtomicTokenBridgeCreator.L1Templates( + new L1GatewayRouter(), + address(new L1ERC20Gateway()), + address(new L1CustomGateway()), + address(new L1WethGateway()), + new L1OrbitGatewayRouter(), + address(new L1OrbitERC20Gateway()), + address(new L1OrbitCustomGateway()), + new UpgradeExecutor() + ); vm.expectEmit(true, true, true, true); emit OrbitTokenBridgeTemplatesUpdated(); @@ -767,25 +790,21 @@ contract L1AtomicTokenBridgeCreatorTest is Test { ( L1GatewayRouter router, - L1ERC20Gateway gw, - L1CustomGateway customGw, - L1WethGateway wGw, + address gw, + address customGw, + address wGw, L1OrbitGatewayRouter oRouter, - L1OrbitERC20Gateway oGw, - L1OrbitCustomGateway oCustomGw, + address oGw, + address oCustomGw, IUpgradeExecutor executor ) = l1Creator.l1Templates(); assertEq(address(router), address(_l1Templates.routerTemplate), "Wrong templates"); - assertEq(address(gw), address(_l1Templates.standardGatewayTemplate), "Wrong templates"); - assertEq(address(customGw), address(_l1Templates.customGatewayTemplate), "Wrong templates"); - assertEq(address(wGw), address(_l1Templates.wethGatewayTemplate), "Wrong templates"); + assertEq(gw, _l1Templates.standardGatewayTemplate, "Wrong templates"); + assertEq(customGw, _l1Templates.customGatewayTemplate, "Wrong templates"); + assertEq(wGw, _l1Templates.wethGatewayTemplate, "Wrong templates"); assertEq(address(oRouter), address(_l1Templates.feeTokenBasedRouterTemplate), "Wrong temp"); - assertEq( - address(oGw), address(_l1Templates.feeTokenBasedStandardGatewayTemplate), "Wrong gw" - ); - assertEq( - address(oCustomGw), address(_l1Templates.feeTokenBasedCustomGatewayTemplate), "Wrong gw" - ); + assertEq(oGw, _l1Templates.feeTokenBasedStandardGatewayTemplate, "Wrong gw"); + assertEq(oCustomGw, _l1Templates.feeTokenBasedCustomGatewayTemplate, "Wrong gw"); assertEq(address(executor), address(_l1Templates.upgradeExecutor), "Wrong executor"); assertEq( @@ -811,17 +830,17 @@ contract L1AtomicTokenBridgeCreatorTest is Test { } function test_setTemplates_revert_OnlyOwner() public { - L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = L1AtomicTokenBridgeCreator - .L1Templates( - new L1GatewayRouter(), - new L1ERC20Gateway(), - new L1CustomGateway(), - new L1WethGateway(), - new L1OrbitGatewayRouter(), - new L1OrbitERC20Gateway(), - new L1OrbitCustomGateway(), - new UpgradeExecutor() - ); + L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = + L1AtomicTokenBridgeCreator.L1Templates( + new L1GatewayRouter(), + address(new L1ERC20Gateway()), + address(new L1CustomGateway()), + address(new L1WethGateway()), + new L1OrbitGatewayRouter(), + address(new L1OrbitERC20Gateway()), + address(new L1OrbitCustomGateway()), + new UpgradeExecutor() + ); vm.expectRevert("Ownable: caller is not the owner"); l1Creator.setTemplates( @@ -840,17 +859,17 @@ contract L1AtomicTokenBridgeCreatorTest is Test { } function test_setTemplates_revert_L2FactoryCannotBeChanged() public { - L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = L1AtomicTokenBridgeCreator - .L1Templates( - new L1GatewayRouter(), - new L1ERC20Gateway(), - new L1CustomGateway(), - new L1WethGateway(), - new L1OrbitGatewayRouter(), - new L1OrbitERC20Gateway(), - new L1OrbitCustomGateway(), - new UpgradeExecutor() - ); + L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = + L1AtomicTokenBridgeCreator.L1Templates( + new L1GatewayRouter(), + address(new L1ERC20Gateway()), + address(new L1CustomGateway()), + address(new L1WethGateway()), + new L1OrbitGatewayRouter(), + address(new L1OrbitERC20Gateway()), + address(new L1OrbitCustomGateway()), + new UpgradeExecutor() + ); address originalL2Factory = makeAddr("originalL2Factory"); @@ -872,9 +891,8 @@ contract L1AtomicTokenBridgeCreatorTest is Test { address newL2FactoryTemplate = makeAddr("newL2FactoryTemplate"); vm.expectRevert( abi.encodeWithSelector( - L1AtomicTokenBridgeCreator - .L1AtomicTokenBridgeCreator_L2FactoryCannotBeChanged - .selector + L1AtomicTokenBridgeCreator.L1AtomicTokenBridgeCreator_L2FactoryCannotBeChanged + .selector ) ); vm.prank(deployer); @@ -901,8 +919,9 @@ contract L1AtomicTokenBridgeCreatorTest is Test { rollup = new RollupProxy(); upgExecutor = new UpgradeExecutor(); - Bridge bridge = - Bridge(address(new TransparentUpgradeableProxy(address(new Bridge()), address(pa), ""))); + Bridge bridge = Bridge( + address(new TransparentUpgradeableProxy(address(new Bridge()), address(pa), "")) + ); inbox = Inbox( address(new TransparentUpgradeableProxy(address(new Inbox(104_857)), address(pa), "")) ); @@ -978,19 +997,29 @@ contract L1AtomicTokenBridgeCreatorTest is Test { } function _setTemplates() internal { - L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = L1AtomicTokenBridgeCreator - .L1Templates( - new L1GatewayRouter(), - new L1ERC20Gateway(), - new L1CustomGateway(), - new L1WethGateway(), - new L1OrbitGatewayRouter(), - new L1OrbitERC20Gateway(), - new L1OrbitCustomGateway(), - new UpgradeExecutor() - ); + L1AtomicTokenBridgeCreator.L1Templates memory _l1Templates = + L1AtomicTokenBridgeCreator.L1Templates( + new L1GatewayRouter(), + address(new L1ERC20Gateway()), + address(new L1CustomGateway()), + address(new L1WethGateway()), + new L1OrbitGatewayRouter(), + address(new L1OrbitERC20Gateway()), + address(new L1OrbitCustomGateway()), + new UpgradeExecutor() + ); - vm.prank(deployer); + L1AtomicTokenBridgeCreator.YbbL1Templates memory _ybbTemplates = + L1AtomicTokenBridgeCreator.YbbL1Templates( + address(new L1YbbERC20Gateway()), + address(new L1YbbCustomGateway()), + address(new L1OrbitYbbERC20Gateway()), + address(new L1OrbitYbbCustomGateway()), + address(new MasterVaultFactory()), + address(new MasterVault()) + ); + + vm.startPrank(deployer); l1Creator.setTemplates( _l1Templates, makeAddr("_l2TokenBridgeFactoryTemplate"), @@ -1004,6 +1033,8 @@ contract L1AtomicTokenBridgeCreatorTest is Test { makeAddr("_l1Multicall"), 1000 ); + l1Creator.setYbbTemplates(_ybbTemplates); + vm.stopPrank(); } //// diff --git a/test-foundry/L1OrbitYbbCustomGateway.t.sol b/test-foundry/L1OrbitYbbCustomGateway.t.sol new file mode 100644 index 000000000..03c8d0219 --- /dev/null +++ b/test-foundry/L1OrbitYbbCustomGateway.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import { + L1OrbitYbbCustomGateway +} from "contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol"; +import { + L1OrbitCustomGateway +} from "contracts/tokenbridge/ethereum/gateway/L1OrbitCustomGateway.sol"; +import {L1GatewayRouter} from "contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol"; +import {MasterVault} from "contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MasterVaultFactory} from "contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {IGatewayRouter} from "contracts/tokenbridge/libraries/gateway/IGatewayRouter.sol"; +import {TestERC20} from "contracts/tokenbridge/test/TestERC20.sol"; +import {ERC20InboxMock} from "contracts/tokenbridge/test/InboxMock.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { + ERC20PresetMinterPauser +} from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; + +contract L1OrbitYbbCustomGatewayTest is Test { + L1OrbitYbbCustomGateway public gateway; + L1GatewayRouter public router; + MasterVaultFactory public factory; + TestERC20 public token; + ERC20InboxMock public inbox; + ERC20 public nativeToken; + + address public l2Gateway = makeAddr("l2Gateway"); + address public l2Router = makeAddr("l2Router"); + address public owner = makeAddr("owner"); + address public user = makeAddr("user"); + address public l2Dest = makeAddr("l2Dest"); + address public creditBackAddress = makeAddr("creditBackAddress"); + + uint256 public constant DEPOSIT_AMOUNT = 1000e18; + uint256 public constant EXTRA_DECIMALS = 6; + uint256 public maxSubmissionCost = 0; + uint256 public maxGas = 1_000_000; + uint256 public gasPriceBid = 1; + uint256 public nativeTokenTotalFee; + + function setUp() public { + inbox = new ERC20InboxMock(); + nativeToken = ERC20(address(new ERC20PresetMinterPauser("X", "Y"))); + ERC20PresetMinterPauser(address(nativeToken)).mint(user, 1_000_000 ether); + ERC20PresetMinterPauser(address(nativeToken)).mint(owner, 1_000_000 ether); + inbox.setMockNativeToken(address(nativeToken)); + + router = new L1GatewayRouter(); + MasterVault masterVaultImpl = new MasterVault(); + factory = new MasterVaultFactory(); + + gateway = new L1OrbitYbbCustomGateway(); + gateway.initialize(l2Gateway, address(router), address(inbox), owner, address(factory)); + + router.initialize(address(this), address(gateway), address(0), l2Router, address(inbox)); + + factory.initialize(address(masterVaultImpl), address(this), IGatewayRouter(address(router))); + + nativeTokenTotalFee = maxGas * gasPriceBid; + + token = new TestERC20(); + vm.prank(user); + token.mintAmount(DEPOSIT_AMOUNT); + vm.deal(address(token), 100 ether); + vm.deal(owner, 100 ether); + } + + function test_outboundTransfer_depositsToVault() public { + _registerToken(); + + vm.prank(user); + token.approve(address(gateway), DEPOSIT_AMOUNT); + + vm.prank(user); + nativeToken.approve(address(gateway), nativeTokenTotalFee); + + vm.prank(address(router)); + gateway.outboundTransfer( + address(token), user, DEPOSIT_AMOUNT, maxGas, gasPriceBid, _buildRouterEncodedData("") + ); + + assertEq(token.balanceOf(user), 0, "User should have no tokens left"); + + address vaultAddr = factory.calculateVaultAddress(address(token)); + assertTrue(vaultAddr.code.length > 0, "Vault should be deployed"); + assertEq(token.balanceOf(vaultAddr), DEPOSIT_AMOUNT, "Vault should hold deposited tokens"); + + uint256 expectedShares = DEPOSIT_AMOUNT * (10 ** EXTRA_DECIMALS); + MasterVault vault = MasterVault(vaultAddr); + assertEq( + vault.balanceOf(address(gateway)), expectedShares, "Gateway should hold vault shares" + ); + } + + function test_registerTokenToL2() public { + address l2Token = makeAddr("l2Token"); + + ERC20PresetMinterPauser(address(nativeToken)).mint(address(token), nativeTokenTotalFee); + vm.prank(address(token)); + nativeToken.approve(address(gateway), nativeTokenTotalFee); + + vm.mockCall( + address(token), abi.encodeWithSignature("isArbitrumEnabled()"), abi.encode(uint8(0xb1)) + ); + vm.prank(address(token)); + L1OrbitCustomGateway(address(gateway)) + .registerTokenToL2(l2Token, maxGas, gasPriceBid, maxSubmissionCost, nativeTokenTotalFee); + + assertEq(gateway.l1ToL2Token(address(token)), l2Token, "Invalid L2 token"); + } + + function test_forceRegisterTokenToL2() public { + address[] memory l1Tokens = new address[](1); + l1Tokens[0] = makeAddr("l1Token1"); + address[] memory l2Tokens = new address[](1); + l2Tokens[0] = makeAddr("l2Token1"); + + vm.prank(owner); + nativeToken.approve(address(gateway), nativeTokenTotalFee); + + vm.prank(owner); + L1OrbitCustomGateway(address(gateway)) + .forceRegisterTokenToL2( + l1Tokens, l2Tokens, maxGas, gasPriceBid, maxSubmissionCost, nativeTokenTotalFee + ); + + assertEq(gateway.l1ToL2Token(l1Tokens[0]), l2Tokens[0], "Invalid L2 token"); + } + + function _registerToken() internal { + address l2Token = makeAddr("tokenL2Address"); + + ERC20PresetMinterPauser(address(nativeToken)).mint(address(token), nativeTokenTotalFee); + vm.prank(address(token)); + nativeToken.approve(address(gateway), nativeTokenTotalFee); + + vm.mockCall( + address(token), abi.encodeWithSignature("isArbitrumEnabled()"), abi.encode(uint8(0xb1)) + ); + vm.prank(address(token)); + L1OrbitCustomGateway(address(gateway)) + .registerTokenToL2( + l2Token, + maxGas, + gasPriceBid, + maxSubmissionCost, + creditBackAddress, + nativeTokenTotalFee + ); + } + + function _buildRouterEncodedData(bytes memory callHookData) + internal + view + returns (bytes memory) + { + bytes memory userEncodedData = + abi.encode(maxSubmissionCost, callHookData, nativeTokenTotalFee); + return abi.encode(user, userEncodedData); + } +} diff --git a/test-foundry/L1OrbitYbbERC20Gateway.t.sol b/test-foundry/L1OrbitYbbERC20Gateway.t.sol new file mode 100644 index 000000000..6eb2cf6a3 --- /dev/null +++ b/test-foundry/L1OrbitYbbERC20Gateway.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import { + L1OrbitYbbERC20Gateway +} from "contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol"; +import {L1GatewayRouter} from "contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol"; +import {MasterVault} from "contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MasterVaultFactory} from "contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {ClonableBeaconProxy} from "contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import {IGatewayRouter} from "contracts/tokenbridge/libraries/gateway/IGatewayRouter.sol"; +import {ITokenGateway} from "contracts/tokenbridge/libraries/gateway/ITokenGateway.sol"; +import {TestERC20} from "contracts/tokenbridge/test/TestERC20.sol"; +import {ERC20InboxMock} from "contracts/tokenbridge/test/InboxMock.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { + ERC20PresetMinterPauser +} from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; + +contract L1OrbitYbbERC20GatewayTest is Test { + L1OrbitYbbERC20Gateway public gateway; + L1GatewayRouter public router; + MasterVaultFactory public factory; + TestERC20 public token; + ERC20InboxMock public inbox; + ERC20 public nativeToken; + + address public l2Gateway = makeAddr("l2Gateway"); + address public l2Router = makeAddr("l2Router"); + address public l2BeaconProxyFactory = makeAddr("l2BeaconProxyFactory"); + address public user = makeAddr("user"); + address public l2Dest = makeAddr("l2Dest"); + + uint256 public constant DEPOSIT_AMOUNT = 1000e18; + uint256 public constant EXTRA_DECIMALS = 6; + uint256 public maxSubmissionCost = 0; + uint256 public maxGas = 1_000_000; + uint256 public gasPriceBid = 1; + uint256 public nativeTokenTotalFee; + + function setUp() public { + inbox = new ERC20InboxMock(); + nativeToken = ERC20(address(new ERC20PresetMinterPauser("X", "Y"))); + ERC20PresetMinterPauser(address(nativeToken)).mint(user, 1_000_000 ether); + inbox.setMockNativeToken(address(nativeToken)); + + router = new L1GatewayRouter(); + MasterVault masterVaultImpl = new MasterVault(); + factory = new MasterVaultFactory(); + + gateway = new L1OrbitYbbERC20Gateway(); + gateway.initialize( + l2Gateway, + address(router), + address(inbox), + keccak256(type(ClonableBeaconProxy).creationCode), + l2BeaconProxyFactory, + address(factory) + ); + + router.initialize(address(this), address(gateway), address(0), l2Router, address(inbox)); + + factory.initialize(address(masterVaultImpl), address(this), IGatewayRouter(address(router))); + + nativeTokenTotalFee = maxGas * gasPriceBid; + + token = new TestERC20(); + vm.prank(user); + token.mintAmount(DEPOSIT_AMOUNT); + } + + function test_outboundTransfer_depositsToVault() public { + vm.prank(user); + token.approve(address(gateway), DEPOSIT_AMOUNT); + + vm.prank(user); + nativeToken.approve(address(gateway), nativeTokenTotalFee); + + vm.prank(address(router)); + gateway.outboundTransfer( + address(token), user, DEPOSIT_AMOUNT, maxGas, gasPriceBid, _buildRouterEncodedData("") + ); + + assertEq(token.balanceOf(user), 0, "User should have no tokens left"); + + address vaultAddr = factory.calculateVaultAddress(address(token)); + assertTrue(vaultAddr.code.length > 0, "Vault should be deployed"); + assertEq(token.balanceOf(vaultAddr), DEPOSIT_AMOUNT, "Vault should hold deposited tokens"); + + uint256 expectedShares = DEPOSIT_AMOUNT * (10 ** EXTRA_DECIMALS); + MasterVault vault = MasterVault(vaultAddr); + assertEq( + vault.balanceOf(address(gateway)), expectedShares, "Gateway should hold vault shares" + ); + } + + function test_outboundTransferCustomRefund_revert_NoValue() public { + vm.deal(address(router), 100 ether); + vm.prank(address(router)); + vm.expectRevert("NO_VALUE"); + gateway.outboundTransferCustomRefund{value: 1 ether}( + address(token), user, user, 100, maxGas, gasPriceBid, "" + ); + } + + function test_outboundTransfer_revert_NotAllowedToBridgeFeeToken() public { + vm.prank(address(router)); + vm.expectRevert("NOT_ALLOWED_TO_BRIDGE_FEE_TOKEN"); + gateway.outboundTransfer(address(nativeToken), user, 100, maxGas, gasPriceBid, ""); + } + + function test_getOutboundCalldata_reportsVaultDecimals() public { + _depositToCreateVault(); + + uint8 vaultDecimals = token.decimals() + uint8(EXTRA_DECIMALS); + + bytes memory outboundCalldata = gateway.getOutboundCalldata( + address(token), user, l2Dest, DEPOSIT_AMOUNT, abi.encode("test") + ); + + bytes memory expectedCalldata = abi.encodeWithSelector( + ITokenGateway.finalizeInboundTransfer.selector, + address(token), + user, + l2Dest, + DEPOSIT_AMOUNT, + abi.encode( + abi.encode( + abi.encode("IntArbTestToken"), abi.encode("IARB"), abi.encode(vaultDecimals) + ), + abi.encode("test") + ) + ); + + assertEq(outboundCalldata, expectedCalldata, "Should encode vault decimals in calldata"); + } + + function _depositToCreateVault() internal { + vm.prank(user); + token.approve(address(gateway), DEPOSIT_AMOUNT); + vm.prank(user); + nativeToken.approve(address(gateway), nativeTokenTotalFee); + vm.prank(address(router)); + gateway.outboundTransfer( + address(token), user, DEPOSIT_AMOUNT, maxGas, gasPriceBid, _buildRouterEncodedData("") + ); + } + + function _buildRouterEncodedData(bytes memory callHookData) + internal + view + returns (bytes memory) + { + bytes memory userEncodedData = + abi.encode(maxSubmissionCost, callHookData, nativeTokenTotalFee); + return abi.encode(user, userEncodedData); + } +} diff --git a/test-foundry/L1YbbERC20Gateway.t.sol b/test-foundry/L1YbbERC20Gateway.t.sol new file mode 100644 index 000000000..f528c717f --- /dev/null +++ b/test-foundry/L1YbbERC20Gateway.t.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import {L1YbbERC20Gateway} from "contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol"; +import {L1GatewayRouter} from "contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol"; +import {MasterVault} from "contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MasterVaultFactory} from "contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {MasterVaultRoles} from "contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol"; +import { + BeaconProxyFactory, + ClonableBeaconProxy +} from "contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {IGatewayRouter} from "contracts/tokenbridge/libraries/gateway/IGatewayRouter.sol"; +import {ITokenGateway} from "contracts/tokenbridge/libraries/gateway/ITokenGateway.sol"; +import {TestERC20} from "contracts/tokenbridge/test/TestERC20.sol"; +import {InboxMock} from "contracts/tokenbridge/test/InboxMock.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { + GatewayMessageHandler +} from "contracts/tokenbridge/libraries/gateway/GatewayMessageHandler.sol"; + +contract L1YbbERC20GatewayTest is Test { + L1YbbERC20Gateway public gateway; + L1GatewayRouter public router; + MasterVaultFactory public factory; + TestERC20 public token; + InboxMock public inbox; + + address public l2Gateway = makeAddr("l2Gateway"); + address public l2Router = makeAddr("l2Router"); + address public l2BeaconProxyFactory = makeAddr("l2BeaconProxyFactory"); + address public user = makeAddr("user"); + address public l2Dest = makeAddr("l2Dest"); + + uint256 public constant DEPOSIT_AMOUNT = 1000e18; + uint256 public constant EXTRA_DECIMALS = 6; + uint256 public maxSubmissionCost = 0; + uint256 public maxGas = 1_000_000; + uint256 public gasPriceBid = 1; + + function setUp() public { + inbox = new InboxMock(); + router = new L1GatewayRouter(); + + MasterVault masterVaultImpl = new MasterVault(); + + factory = new MasterVaultFactory(); + + gateway = new L1YbbERC20Gateway(); + gateway.initialize( + l2Gateway, + address(router), + address(inbox), + keccak256(type(ClonableBeaconProxy).creationCode), + l2BeaconProxyFactory, + address(factory) + ); + + router.initialize( + address(this), // owner + address(gateway), // default gateway + address(0), // whitelist (unused) + l2Router, // counterpart + address(inbox) + ); + + factory.initialize(address(masterVaultImpl), address(this), IGatewayRouter(address(router))); + + token = new TestERC20(); + vm.prank(user); + token.mintAmount(DEPOSIT_AMOUNT); + } + + function test_outboundTransfer_depositsToVault() public { + uint256 userBalanceBefore = token.balanceOf(user); + assertEq(userBalanceBefore, DEPOSIT_AMOUNT, "User should have tokens"); + + vm.prank(user); + token.approve(address(gateway), DEPOSIT_AMOUNT); + + bytes memory userData = abi.encode(maxSubmissionCost, ""); + + // needed ETH to cover the retryable ticket: maxSubmissionCost + maxGas * gasPriceBid + uint256 retryableCost = maxSubmissionCost + maxGas * gasPriceBid; + vm.deal(user, retryableCost); + + vm.prank(user); + router.outboundTransferCustomRefund{value: retryableCost}( + address(token), user, l2Dest, DEPOSIT_AMOUNT, maxGas, gasPriceBid, userData + ); + + assertEq(token.balanceOf(user), 0, "User should have no tokens left"); + + // verify MasterVault was deployed and holds the tokens + address vaultAddr = factory.calculateVaultAddress(address(token)); + assertTrue(vaultAddr.code.length > 0, "Vault should be deployed"); + assertEq(token.balanceOf(vaultAddr), DEPOSIT_AMOUNT, "Vault should hold deposited tokens"); + + // verify gateway holds vault shares + // shares = DEPOSIT_AMOUNT * totalSupply / totalAssets + // = DEPOSIT_AMOUNT * 10^6 / (1 + 0) = DEPOSIT_AMOUNT * 10^6 + uint256 expectedShares = DEPOSIT_AMOUNT * (10 ** EXTRA_DECIMALS); + MasterVault vault = MasterVault(vaultAddr); + assertEq( + vault.balanceOf(address(gateway)), expectedShares, "Gateway should hold vault shares" + ); + + // verify vault total assets includes the deposit + assertEq(vault.totalAssets(), DEPOSIT_AMOUNT + 1, "Vault totalAssets should be deposit + 1"); + + // vault total supply = dead shares + minted shares + uint256 deadShares = 10 ** EXTRA_DECIMALS; + assertEq( + vault.totalSupply(), + deadShares + expectedShares, + "Vault totalSupply should be deadShares + userShares" + ); + } + + function test_vault_registryAdminCanGrantLocalRoles() public { + _depositToCreateVault(); + MasterVault vault = MasterVault(factory.calculateVaultAddress(address(token))); + + address manager = makeAddr("manager"); + vault.grantRole(vault.GENERAL_MANAGER_ROLE(), manager); + assertTrue(vault.hasRole(vault.GENERAL_MANAGER_ROLE(), manager)); + } + + function test_outboundTransfer_revertsOnZeroShares() public { + vm.prank(user); + token.approve(address(gateway), 0); + + bytes memory userData = abi.encode(maxSubmissionCost, ""); + uint256 retryableCost = maxSubmissionCost + maxGas * gasPriceBid; + vm.deal(user, retryableCost); + + vm.prank(user); + vm.expectRevert("ZERO_SHARES"); + router.outboundTransferCustomRefund{value: retryableCost}( + address(token), user, l2Dest, 0, maxGas, gasPriceBid, userData + ); + } + + function _depositToCreateVault() internal { + vm.prank(user); + token.approve(address(gateway), DEPOSIT_AMOUNT); + uint256 retryableCost = maxSubmissionCost + maxGas * gasPriceBid; + vm.deal(user, retryableCost); + vm.prank(user); + router.outboundTransferCustomRefund{value: retryableCost}( + address(token), + user, + l2Dest, + DEPOSIT_AMOUNT, + maxGas, + gasPriceBid, + abi.encode(maxSubmissionCost, "") + ); + } + + function test_getOutboundCalldata_reportsVaultDecimals() public { + vm.prank(user); + token.approve(address(gateway), DEPOSIT_AMOUNT); + + bytes memory userData = abi.encode(maxSubmissionCost, ""); + uint256 retryableCost = maxSubmissionCost + maxGas * gasPriceBid; + vm.deal(user, retryableCost); + + vm.prank(user); + router.outboundTransferCustomRefund{value: retryableCost}( + address(token), user, l2Dest, DEPOSIT_AMOUNT, maxGas, gasPriceBid, userData + ); + + uint8 vaultDecimals = token.decimals() + uint8(EXTRA_DECIMALS); + + bytes memory outboundCalldata = gateway.getOutboundCalldata( + address(token), user, l2Dest, DEPOSIT_AMOUNT, abi.encode("test") + ); + + bytes memory expectedCalldata = abi.encodeWithSelector( + ITokenGateway.finalizeInboundTransfer.selector, + address(token), + user, + l2Dest, + DEPOSIT_AMOUNT, + abi.encode( + abi.encode( + abi.encode("IntArbTestToken"), abi.encode("IARB"), abi.encode(vaultDecimals) + ), + abi.encode("test") + ) + ); + + assertEq(outboundCalldata, expectedCalldata, "Should encode vault decimals in calldata"); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultAttack.t.sol b/test-foundry/libraries/vault/MasterVaultAttack.t.sol new file mode 100644 index 000000000..d1f4c23ea --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultAttack.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/console2.sol"; +import {MasterVaultCoreTest} from "./MasterVaultCore.t.sol"; +import {MockSubVault} from "../../../contracts/tokenbridge/test/MockSubVault.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +contract AttackTestBase is MasterVaultCoreTest { + function setUp() public override { + super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), address(this)); + vault.setSubVaultWhitelist(address(_subvault), true); + vault.setSubVault(IERC4626(address(_subvault))); + } +} + +contract AttackTest is AttackTestBase { + function _calculateStolenAmount( + uint128 initialSubVaultTotalAssets, + uint128 initialSubVaultTotalSupply, + uint128 vaultInitialDepositAmount, + uint128 vaultAttackDepositAmount + ) public returns (uint256) { + console2.log("initialSubVaultTotalAssets:", initialSubVaultTotalAssets); + console2.log("initialSubVaultTotalSupply:", initialSubVaultTotalSupply); + console2.log("vaultInitialDepositAmount:", vaultInitialDepositAmount); + console2.log("vaultAttackDepositAmount:", vaultAttackDepositAmount); + + MockSubVault(address(vault.subVault())).adminMint(address(this), initialSubVaultTotalSupply); + token.mintAmount(initialSubVaultTotalAssets); + token.transfer(address(vault.subVault()), initialSubVaultTotalAssets); + + assertEq( + vault.subVault().totalAssets(), + initialSubVaultTotalAssets, + "subvault total assets should be correct" + ); + assertEq( + vault.subVault().totalSupply(), + initialSubVaultTotalSupply, + "subvault total supply should be correct" + ); + + vm.prank(user); + token.mintAmount(vaultInitialDepositAmount); + + vm.startPrank(user); + token.approve(address(vault), vaultInitialDepositAmount); + vault.deposit(vaultInitialDepositAmount); + vm.stopPrank(); + + address attacker = address(0xBEEF); + vm.prank(attacker); + token.mintAmount(vaultAttackDepositAmount); + + vm.prank(attacker); + token.transfer(user, vaultAttackDepositAmount); + + vm.startPrank(user); + token.approve(address(vault), vaultAttackDepositAmount); + uint256 sharesBack = vault.deposit(vaultAttackDepositAmount); + vault.transfer(attacker, sharesBack); + vm.stopPrank(); + + vm.prank(attacker); + vault.transfer(user, sharesBack); + + vm.startPrank(user); + uint256 assetsBack = vault.redeem(sharesBack, 0); + token.transfer(attacker, assetsBack); + vm.stopPrank(); + + uint256 stolenAmount = + assetsBack > vaultAttackDepositAmount ? assetsBack - vaultAttackDepositAmount : 0; + + console2.log("stolenAmount:", stolenAmount); + + return stolenAmount; + } + + function testFindCombo( + uint120 initialSubVaultTotalAssets, + int8 initialSubVaultTotalSupplyWiggle, + uint128 vaultInitialDepositAmount, + uint128 vaultAttackDepositAmount + ) public { + if (initialSubVaultTotalAssets < 1e18) { + initialSubVaultTotalAssets += 1e18; + } + if (vaultInitialDepositAmount < 1e18) { + vaultInitialDepositAmount += 1e18; + } + + uint128 initialSubVaultTotalSupply = uint128( + int128(int120(initialSubVaultTotalAssets)) + int128(initialSubVaultTotalSupplyWiggle) + ); + uint256 stolenAmt = _calculateStolenAmount( + initialSubVaultTotalAssets, + initialSubVaultTotalSupply, + vaultInitialDepositAmount, + vaultAttackDepositAmount + ); + require(stolenAmt == 0, "theft occurred with these parameters"); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultCore.t.sol b/test-foundry/libraries/vault/MasterVaultCore.t.sol new file mode 100644 index 000000000..e0a61f259 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultCore.t.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {MasterVault} from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { + MasterVaultFactory +} from "../../../contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {TestERC20} from "../../../contracts/tokenbridge/test/TestERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { + BeaconProxyFactory, + ClonableBeaconProxy +} from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import { + MasterVaultRoles +} from "../../../contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {console2} from "forge-std/console2.sol"; +import {IGatewayRouter} from "../../../contracts/tokenbridge/libraries/gateway/IGatewayRouter.sol"; + +contract MockGatewayRouter { + address public gateway; + + constructor(address _gateway) { + gateway = _gateway; + } + + function getGateway(address) external view returns (address) { + return gateway; + } +} + +contract MasterVaultCoreTest is Test { + MasterVaultFactory public factory; + MasterVault public vault; + TestERC20 public token; + + address public user = vm.addr(1); + string public name = "Master Test Token"; + string public symbol = "mTST"; + uint256 public constant DEAD_SHARES = 10 ** 6; + + address public keeper = address(0xBBBB); + address public beneficiaryAddr = address(0x9999); + address public generalManager = address(0xAAAA); + address public pauser = address(0xCCCC); + + struct State { + uint256 userShares; + uint256 masterVaultTotalAssets; + uint256 masterVaultTotalSupply; + uint256 masterVaultTokenBalance; + uint256 masterVaultSubVaultShareBalance; + uint256 subVaultTotalAssets; + uint256 subVaultTotalSupply; + uint256 subVaultTokenBalance; + } + + function getAssetsHoldingVault() internal view virtual returns (address) { + return address(vault.subVault()) == address(0) ? address(vault) : address(vault.subVault()); + } + + // todo: this setUp currently doesn't use proxies + function setUp() public virtual { + factory = new MasterVaultFactory(); + MockGatewayRouter mockGatewayRouter = new MockGatewayRouter(user); + MasterVault masterVaultImplementation = new MasterVault(); + + factory.initialize( + address(masterVaultImplementation), + address(this), + IGatewayRouter(address(mockGatewayRouter)) + ); + token = new TestERC20(); + vault = MasterVault(factory.deployVault(address(token))); + + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), address(this)); + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), generalManager); + vault.rolesRegistry().grantRole(vault.KEEPER_ROLE(), keeper); + vault.rolesRegistry().grantRole(vault.FEE_MANAGER_ROLE(), address(this)); + vault.rolesRegistry().grantRole(vault.PAUSER_ROLE(), pauser); + vault.setBeneficiary(beneficiaryAddr); + vault.setMinimumRebalanceAmount(1); + } + + function _checkState(State memory expectedState) internal { + assertEq(expectedState.userShares, vault.balanceOf(user), "userShares mismatch"); + assertEq( + expectedState.masterVaultTotalAssets, + vault.totalAssets(), + "masterVaultTotalAssets mismatch" + ); + assertEq( + expectedState.masterVaultTotalSupply, + vault.totalSupply(), + "masterVaultTotalSupply mismatch" + ); + assertEq( + expectedState.masterVaultTokenBalance, + token.balanceOf(address(vault)), + "masterVaultTokenBalance mismatch" + ); + assertEq( + expectedState.masterVaultSubVaultShareBalance, + vault.subVault().balanceOf(address(vault)), + "masterVaultSubVaultShareBalance mismatch" + ); + assertEq( + expectedState.subVaultTotalAssets, + vault.subVault().totalAssets(), + "subVaultTotalAssets mismatch" + ); + assertEq( + expectedState.subVaultTotalSupply, + vault.subVault().totalSupply(), + "subVaultTotalSupply mismatch" + ); + assertEq( + expectedState.subVaultTokenBalance, + token.balanceOf(address(vault.subVault())), + "subVaultTokenBalance mismatch" + ); + } + + function _getState() internal view returns (State memory) { + return State({ + userShares: vault.balanceOf(user), + masterVaultTotalAssets: vault.totalAssets(), + masterVaultTotalSupply: vault.totalSupply(), + masterVaultTokenBalance: token.balanceOf(address(vault)), + masterVaultSubVaultShareBalance: vault.subVault().balanceOf(address(vault)), + subVaultTotalAssets: vault.subVault().totalAssets(), + subVaultTotalSupply: vault.subVault().totalSupply(), + subVaultTokenBalance: token.balanceOf(address(vault.subVault())) + }); + } + + function _logState(string memory label, State memory state) internal view { + console2.log(label); + console2.log(" userShares:", state.userShares); + console2.log(" masterVaultTotalAssets:", state.masterVaultTotalAssets); + console2.log(" masterVaultTotalSupply:", state.masterVaultTotalSupply); + console2.log(" masterVaultTokenBalance:", state.masterVaultTokenBalance); + console2.log(" masterVaultSubVaultShareBalance:", state.masterVaultSubVaultShareBalance); + console2.log(" subVaultTotalAssets:", state.subVaultTotalAssets); + console2.log(" subVaultTotalSupply:", state.subVaultTotalSupply); + console2.log(" subVaultTokenBalance:", state.subVaultTokenBalance); + } + + function _depositAs(uint256 amount) internal returns (uint256) { + vm.prank(user); + token.mintAmount(amount); + vm.startPrank(user); + token.approve(address(vault), amount); + uint256 shares = vault.deposit(amount); + vm.stopPrank(); + return shares; + } + + function _setupWithAllocation(uint256 depositAmount, uint64 allocationWad) internal { + _depositAs(depositAmount); + vault.setTargetAllocationWad(allocationWad); + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vault.rebalance(-1e18); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultDepositRedeem.t.sol b/test-foundry/libraries/vault/MasterVaultDepositRedeem.t.sol new file mode 100644 index 000000000..49e760f26 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultDepositRedeem.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "./MasterVaultCore.t.sol"; +import {MockSubVault} from "../../../contracts/tokenbridge/test/MockSubVault.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract MasterVaultFirstDepositTest is MasterVaultCoreTest { + using Math for uint256; + + // first deposit + function test_deposit(uint96 _depositAmount) public { + uint256 depositAmount = _depositAmount; + vm.startPrank(user); + token.mintAmount(depositAmount); + token.approve(address(vault), depositAmount); + uint256 shares = vault.deposit(depositAmount); + vm.stopPrank(); + _checkState( + State({ + userShares: depositAmount * DEAD_SHARES, + masterVaultTotalAssets: depositAmount + 1, + masterVaultTotalSupply: (1 + depositAmount) * DEAD_SHARES, + masterVaultTokenBalance: depositAmount, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(shares, depositAmount * DEAD_SHARES, "shares mismatch deposit return value"); + } + + function test_redeem(uint96 _firstDeposit, uint96 _redeemAmount) public { + uint256 firstDeposit = _firstDeposit; + uint256 redeemAmount = _redeemAmount; + vm.assume(redeemAmount <= firstDeposit * DEAD_SHARES); + test_deposit(_firstDeposit); + State memory beforeState = _getState(); + vm.startPrank(user); + uint256 assets = vault.redeem(redeemAmount, 0); + uint256 expectedAssets = (beforeState.masterVaultTotalAssets * redeemAmount) + / (beforeState.masterVaultTotalSupply); + vm.stopPrank(); + _checkState( + State({ + userShares: beforeState.userShares - redeemAmount, + masterVaultTotalAssets: beforeState.masterVaultTotalAssets - expectedAssets, + masterVaultTotalSupply: beforeState.masterVaultTotalSupply - redeemAmount, + masterVaultTokenBalance: beforeState.masterVaultTokenBalance - expectedAssets, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(assets, expectedAssets, "assets mismatch redeem return value"); + } +} + +contract MasterVaultTestWithSubvaultFresh is MasterVaultFirstDepositTest { + function setUp() public override { + super.setUp(); + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), address(this)); + vault.setSubVaultWhitelist(address(_subvault), true); + vault.setSubVault(IERC4626(address(_subvault))); + } +} + +contract MasterVaultTestWithSubvaultHoldingAssets is MasterVaultFirstDepositTest { + function _setupSubvaultWithAssets(uint256 _initAmount) internal { + MockSubVault _subvault = new MockSubVault(IERC20(address(token)), "TestSubvault", "TSV"); + token.mintAmount(_initAmount); + token.approve(address(_subvault), _initAmount); + _subvault.deposit(_initAmount, address(this)); + assertEq( + _initAmount, + _subvault.totalAssets(), + "subvault should be initiated with assets = _initAmount" + ); + assertEq( + _initAmount, + _subvault.totalSupply(), + "subvault should be initiated with shares = _initAmount" + ); + + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), address(this)); + vault.setSubVaultWhitelist(address(_subvault), true); + vault.setSubVault(IERC4626(address(_subvault))); + } + + function test_deposit(uint96 _depositAmount, uint96 _initAmount) public { + uint256 depositAmount = _depositAmount; + uint256 initAmount = _initAmount; + _setupSubvaultWithAssets(initAmount); + + vm.startPrank(user); + token.mintAmount(depositAmount); + token.approve(address(vault), depositAmount); + uint256 shares = vault.deposit(depositAmount); + vm.stopPrank(); + _checkState( + State({ + userShares: depositAmount * DEAD_SHARES, + masterVaultTotalAssets: depositAmount + 1, + masterVaultTotalSupply: (1 + depositAmount) * DEAD_SHARES, + masterVaultTokenBalance: depositAmount, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: initAmount, + subVaultTotalSupply: initAmount, + subVaultTokenBalance: initAmount + }) + ); + assertEq(shares, depositAmount * DEAD_SHARES, "shares mismatch deposit return value"); + } + + function test_redeem(uint96 _firstDeposit, uint96 _redeemAmount, uint96 _initAmount) public { + uint256 firstDeposit = _firstDeposit; + uint256 redeemAmount = _redeemAmount; + vm.assume(redeemAmount <= firstDeposit * DEAD_SHARES); + test_deposit(_firstDeposit, _initAmount); + State memory beforeState = _getState(); + vm.startPrank(user); + uint256 assets = vault.redeem(redeemAmount, 0); + uint256 expectedAssets = (beforeState.masterVaultTotalAssets * redeemAmount) + / (beforeState.masterVaultTotalSupply); + vm.stopPrank(); + _checkState( + State({ + userShares: beforeState.userShares - redeemAmount, + masterVaultTotalAssets: beforeState.masterVaultTotalAssets - expectedAssets, + masterVaultTotalSupply: beforeState.masterVaultTotalSupply - redeemAmount, + masterVaultTokenBalance: beforeState.masterVaultTokenBalance - expectedAssets, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: beforeState.subVaultTotalAssets, + subVaultTotalSupply: beforeState.subVaultTotalSupply, + subVaultTokenBalance: beforeState.subVaultTokenBalance + }) + ); + assertEq(assets, expectedAssets, "assets mismatch redeem return value"); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultFactory.t.sol b/test-foundry/libraries/vault/MasterVaultFactory.t.sol new file mode 100644 index 000000000..91fee3164 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultFactory.t.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import { + MasterVaultFactory +} from "../../../contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {BeaconProxyFactory} from "../../../contracts/tokenbridge/libraries/ClonableBeaconProxy.sol"; +import { + MasterVaultRoles +} from "../../../contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol"; +import {MasterVault} from "../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {TestERC20} from "../../../contracts/tokenbridge/test/TestERC20.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {IGatewayRouter} from "../../../contracts/tokenbridge/libraries/gateway/IGatewayRouter.sol"; + +contract MasterVaultFactoryTest is Test { + MasterVaultFactory public factory; + TestERC20 public token; + + address public owner = address(0x1); + address public user = address(0x2); + + event VaultDeployed(address indexed token, address indexed vault); + + function factoryOwner() internal view returns (address) { + bytes32 adminRole = factory.rolesRegistry().ADMIN_ROLE(); + return factory.rolesRegistry().getRoleMember(adminRole, 0); + } + + function vaultOwner(MasterVault vault) internal view returns (address) { + bytes32 adminRole = vault.ADMIN_ROLE(); + return vault.rolesRegistry().getRoleMember(adminRole, 0); + } + + function setUp() public { + token = new TestERC20(); + factory = new MasterVaultFactory(); + MasterVault masterVaultImplementation = new MasterVault(); + + factory.initialize(address(masterVaultImplementation), owner, IGatewayRouter(address(0))); + } + + function test_initialize() public { + assertEq(factoryOwner(), owner, "Invalid owner"); + } + + function test_deployVault() public { + address expectedVault = factory.calculateVaultAddress(address(token)); + + vm.expectEmit(true, true, false, false); + emit VaultDeployed(address(token), expectedVault); + + address deployedVault = factory.deployVault(address(token)); + + assertEq(deployedVault, expectedVault, "Vault address mismatch"); + assertTrue(deployedVault.code.length > 0, "Vault not deployed"); + + MasterVault vault = MasterVault(deployedVault); + assertEq(address(vault.asset()), address(token), "Invalid vault asset"); + assertEq(vaultOwner(vault), owner, "Factory owner should be vault owner"); + } + + function test_deployVault_RevertZeroAddress() public { + vm.expectRevert(); + factory.deployVault(address(0)); + } + + function test_getVault_DeploysIfNotExists() public { + address expectedVault = factory.calculateVaultAddress(address(token)); + address vault = factory.getVault(address(token)); + + assertEq(vault, expectedVault, "Vault address mismatch"); + assertTrue(vault.code.length > 0, "Vault not deployed"); + } + + function test_getVault_ReturnsExistingVault() public { + address vault1 = factory.getVault(address(token)); + address vault2 = factory.getVault(address(token)); + + assertEq(vault1, vault2, "Should return same vault"); + } + + function test_calculateVaultAddress() public { + address calculatedAddress = factory.calculateVaultAddress(address(token)); + address deployedVault = factory.deployVault(address(token)); + + assertEq(calculatedAddress, deployedVault, "Address calculation incorrect"); + } + + function test_beaconOwnership() public { + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).owner(), + factoryOwner(), + "Beacon owner should be the factory owner" + ); + } + + function test_ownerCanUpgradeBeacon() public { + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); + vm.prank(owner); + beacon.upgradeTo(address(newImplementation)); + + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), + address(newImplementation), + "Beacon implementation should be updated" + ); + } + + function test_nonOwnerCannotUpgradeBeacon() public { + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); + vm.prank(user); + vm.expectRevert("Ownable: caller is not the owner"); + beacon.upgradeTo(address(newImplementation)); + } + + function test_beaconUpgradeAffectsAllVaults() public { + address vault1 = factory.deployVault(address(token)); + + TestERC20 token2 = new TestERC20(); + address vault2 = factory.deployVault(address(token2)); + + MasterVault newImplementation = new MasterVault(); + + UpgradeableBeacon beacon = UpgradeableBeacon(factory.beaconProxyFactory().beacon()); + vm.prank(owner); + beacon.upgradeTo(address(newImplementation)); + + assertEq( + UpgradeableBeacon(factory.beaconProxyFactory().beacon()).implementation(), + address(newImplementation), + "Beacon should point to new implementation" + ); + + assertEq(vaultOwner(MasterVault(vault1)), owner, "Vault1 should have owner as admin"); + assertEq(vaultOwner(MasterVault(vault2)), owner, "Vault2 should have owner as admin"); + } +} diff --git a/test-foundry/libraries/vault/MasterVaultFirstDeposit.t.sol b/test-foundry/libraries/vault/MasterVaultFirstDeposit.t.sol new file mode 100644 index 000000000..649f44617 --- /dev/null +++ b/test-foundry/libraries/vault/MasterVaultFirstDeposit.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "./MasterVaultCore.t.sol"; + +contract MasterVaultFirstDepositTest is MasterVaultCoreTest { + function testCannotRugFirstDepositWithDonationAttack(uint128 attackerDepositAmount, uint128 attackerDonationAmount, uint128 userDepositAmount) public { + uint256 snapshot = vm.snapshot(); + uint256 sharesRecvBefore = _depositAs(userDepositAmount); + vm.revertTo(snapshot); + + // attacker deposit + _depositAs(attackerDepositAmount); + + // attacker donate + vm.prank(address(vault)); + token.mintAmount(attackerDonationAmount); + + // user deposit + uint256 sharesRecvAfter = _depositAs(userDepositAmount); + + // make sure user does not lose + assertGe(sharesRecvAfter, sharesRecvBefore, "user received fewer shares after attacker donation"); + } +} diff --git a/test-foundry/libraries/vault/invariant/MasterVaultHandler.sol b/test-foundry/libraries/vault/invariant/MasterVaultHandler.sol new file mode 100644 index 000000000..73247cd6a --- /dev/null +++ b/test-foundry/libraries/vault/invariant/MasterVaultHandler.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {FuzzSubVault} from "../../../../contracts/tokenbridge/test/FuzzSubVault.sol"; +import {TestERC20} from "../../../../contracts/tokenbridge/test/TestERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +/// @notice Handler for MasterVault invariant testing. +/// @dev The fuzzer calls handler functions in random order with random inputs. +/// Each function wraps a MasterVault operation, bounds inputs, pranks the correct caller, +/// and updates ghost state. All actions use try/catch so the handler never reverts to +/// the fuzzer -- this ensures maximum exploration of the state space. +contract MasterVaultHandler is Test { + MasterVault public vault; + FuzzSubVault public subVault; + TestERC20 public token; + address public user; + address public keeper; + + // --- Ghost variables --- + + uint256 public random; + + /// @notice Cumulative assets deposited into the MasterVault + uint256 public ghost_deposited; + + /// @notice Cumulative assets received by users via redeem + uint256 public ghost_redeemed; + + /// @notice Cumulative profit injected into the system (tokens sent to vault/subvault) + uint256 public ghost_profit; + + /// @notice Cumulative loss removed from the system (tokens taken from vault/subvault) + uint256 public ghost_loss; + + /// @notice Cumulative fees sent to beneficiary via distributePerformanceFee + uint256 public ghost_feesClaimed; + + /// @notice Count of successful calls per action (for debugging fuzzer coverage) + mapping(bytes4 => uint256) public ghost_callCount; + + modifier updateRandom() { + random = uint256(keccak256(msg.data)); + _; + } + + constructor( + MasterVault _vault, + FuzzSubVault _subVault, + TestERC20 _token, + address _user, + address _keeper + ) { + vault = _vault; + subVault = _subVault; + token = _token; + user = _user; + keeper = _keeper; + } + + // --- User actions --- + + /// @notice Deposit assets into the MasterVault via the gateway user + /// @dev Bounds amount to [1, 1e30] to stay within reasonable range + function deposit(uint256 amount) external updateRandom { + amount = bound(amount, 1, 1e30); + + vm.prank(user); + token.mintAmount(amount); + + vm.startPrank(user); + token.approve(address(vault), amount); + vault.deposit(amount); + ghost_deposited += amount; + ghost_callCount[this.deposit.selector]++; + vm.stopPrank(); + } + + /// @notice Redeem shares from the MasterVault as user + /// @dev Bounds shares to [1, user balance] + function redeem(uint256 shares) external updateRandom { + uint256 bal = vault.balanceOf(user); + if (bal == 0) return; + shares = bound(shares, 1, bal); + + uint256 assetsBefore = token.balanceOf(user); + vm.prank(user); + vault.redeem(shares, 0); + uint256 assetsAfter = token.balanceOf(user); + ghost_redeemed += assetsAfter - assetsBefore; + ghost_callCount[this.redeem.selector]++; + } + + // --- Keeper actions --- + + /// @notice Warp time and call rebalance with permissive slippage + /// @dev Uses extreme slippage bounds to avoid exchange rate reverts masking real bugs + function rebalance() external updateRandom { + vm.warp(block.timestamp + 2); + + int256 minExchRate; + uint256 idle = token.balanceOf(address(vault)); + uint256 totalUp = vault.totalAssets() + 1; // rough upper bound + uint64 alloc = vault.targetAllocationWad(); + uint256 idleTarget = (totalUp * (1e18 - alloc)) / 1e18; + + // Negative means deposit (masterVault -> subVault), positive means withdraw + if (idle > idleTarget) { + minExchRate = -1e18; + } else { + minExchRate = 0; + } + + vm.prank(keeper); + vault.rebalance(minExchRate); + ghost_callCount[this.rebalance.selector]++; + } + + /// @notice Distribute performance fees to beneficiary + function distributePerformanceFee() external updateRandom { + address beneficiary = vault.beneficiary(); + uint256 before = token.balanceOf(beneficiary); + + vm.prank(keeper); + vault.distributePerformanceFee(); + uint256 after_ = token.balanceOf(beneficiary); + ghost_feesClaimed += after_ - before; + ghost_callCount[this.distributePerformanceFee.selector]++; + } + + // --- Manager actions --- + + /// @notice Set target allocation (bounded 0 to 1e18) + function setTargetAllocation(uint256 seed) external updateRandom { + uint64 alloc = uint64(bound(seed, 0, 1e18)); + vault.setTargetAllocationWad(alloc); + ghost_callCount[this.setTargetAllocation.selector]++; + } + + /// @notice Set maxWithdraw limit on the subvault + function capSubVaultMaxWithdraw(uint256 lim) external updateRandom { + lim = bound(lim, 1, type(uint128).max); + subVault.setMaxWithdrawLimit(lim); + ghost_callCount[this.capSubVaultMaxWithdraw.selector]++; + } + + /// @notice Set maxDeposit limit on the subvault + function capSubVaultMaxDeposit(uint256 lim) external updateRandom { + lim = bound(lim, 1, type(uint128).max); + subVault.setMaxDepositLimit(lim); + ghost_callCount[this.capSubVaultMaxDeposit.selector]++; + } + + /// @notice Set maxRedeem limit on the subvault + function capSubVaultMaxRedeem(uint256 lim) external updateRandom { + lim = bound(lim, 1, type(uint128).max); + subVault.setMaxRedeemLimit(lim); + ghost_callCount[this.capSubVaultMaxRedeem.selector]++; + } +} + +contract MasterVaultWithManipulationHandler is MasterVaultHandler { + constructor( + MasterVault _vault, + FuzzSubVault _subVault, + TestERC20 _token, + address _user, + address _keeper + ) MasterVaultHandler(_vault, _subVault, _token, _user, _keeper) {} + + // --- Environment manipulation --- + + /// @notice Send tokens directly to the subvault (simulates yield / A increases) + function simulateSubVaultProfit(uint256 amt) external updateRandom { + amt = bound(amt, 1, 1e24); + token.mintAmount(amt); + token.transfer(address(subVault), amt); + ghost_profit += amt; + ghost_callCount[this.simulateSubVaultProfit.selector]++; + } + + /// @notice Remove tokens from the subvault (simulates loss / A decreases) + function simulateSubVaultLoss(uint256 amt) external updateRandom { + uint256 subBal = token.balanceOf(address(subVault)); + if (subBal == 0) return; + amt = bound(amt, 1, subBal); + + vm.prank(address(subVault)); + token.transfer(address(0xdead), amt); + ghost_loss += amt; + ghost_callCount[this.simulateSubVaultLoss.selector]++; + } + + /// @notice Mint shares without backing assets on FuzzSubVault (T increases, A < T) + function inflateSubVaultShares(uint256 amt) external updateRandom { + amt = bound(amt, 1, 1e24); + subVault.adminMint(address(vault), amt); + ghost_callCount[this.inflateSubVaultShares.selector]++; + } + + /// @notice Burn shares without withdrawing assets on FuzzSubVault (T decreases, A > T) + function deflateSubVaultShares(uint256 amt) external updateRandom { + uint256 vaultShares = subVault.balanceOf(address(vault)); + if (vaultShares == 0) return; + amt = bound(amt, 1, vaultShares); + subVault.adminBurn(address(vault), amt); + ghost_callCount[this.deflateSubVaultShares.selector]++; + } + + // --- Rounding error manipulation --- + + /// @notice Set deposit rounding error on the subvault (0–10% in wad) + function setDepositError(uint256 seed) external updateRandom { + uint256 wad = bound(seed, 0, 1e17); + subVault.setDepositErrorWad(wad); + ghost_callCount[this.setDepositError.selector]++; + } + + /// @notice Set withdraw rounding error on the subvault (0–10% in wad) + function setWithdrawError(uint256 seed) external updateRandom { + uint256 wad = bound(seed, 0, 1e17); + subVault.setWithdrawErrorWad(wad); + ghost_callCount[this.setWithdrawError.selector]++; + } + + /// @notice Set redeem rounding error on the subvault (0–10% in wad) + function setRedeemError(uint256 seed) external updateRandom { + uint256 wad = bound(seed, 0, 1e17); + subVault.setRedeemErrorWad(wad); + ghost_callCount[this.setRedeemError.selector]++; + } + + /// @notice Set previewMint rounding error on the subvault (0–10% in wad) + function setPreviewMintError(uint256 seed) external updateRandom { + uint256 wad = bound(seed, 0, 1e17); + subVault.setPreviewMintErrorWad(wad); + ghost_callCount[this.setPreviewMintError.selector]++; + } + + /// @notice Set previewRedeem rounding error on the subvault (0–10% in wad) + function setPreviewRedeemError(uint256 seed) external updateRandom { + uint256 wad = bound(seed, 0, 1e17); + subVault.setPreviewRedeemErrorWad(wad); + ghost_callCount[this.setPreviewRedeemError.selector]++; + } +} diff --git a/test-foundry/libraries/vault/invariant/MasterVaultInvariant.t.sol b/test-foundry/libraries/vault/invariant/MasterVaultInvariant.t.sol new file mode 100644 index 000000000..f268f4955 --- /dev/null +++ b/test-foundry/libraries/vault/invariant/MasterVaultInvariant.t.sol @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import { + MasterVaultFactory +} from "../../../../contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; +import {FuzzSubVault} from "../../../../contracts/tokenbridge/test/FuzzSubVault.sol"; +import {TestERC20} from "../../../../contracts/tokenbridge/test/TestERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IGatewayRouter} from "../../../../contracts/tokenbridge/libraries/gateway/IGatewayRouter.sol"; +import {TransparentUpgradeableProxy} from + "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {MasterVaultWithManipulationHandler, MasterVaultHandler} from "./MasterVaultHandler.sol"; +import {console2} from "forge-std/console2.sol"; + +contract MockGatewayRouterInvariant { + address public gateway; + + constructor(address _gateway) { + gateway = _gateway; + } + + function getGateway(address) external view returns (address) { + return gateway; + } +} + +abstract contract BaseMasterVaultInvariant is Test { + MasterVaultFactory public factory; + MasterVault public vault; + FuzzSubVault public subVault; + TestERC20 public token; + address public handler; + + address public user = vm.addr(1); + address public keeper = address(0xBBBB); + address public beneficiaryAddr = address(0x9999); + address public proxyAdmin = address(0xAA); + + uint256 public random; + + uint256 public constant DEAD_SHARES = 10 ** 6; + + function setUp() public virtual { + // Deploy factory behind a TransparentUpgradeableProxy + MasterVault impl = new MasterVault(); + MasterVaultFactory factoryImpl = new MasterVaultFactory(); + factory = MasterVaultFactory( + address(new TransparentUpgradeableProxy(address(factoryImpl), proxyAdmin, bytes(""))) + ); + MockGatewayRouterInvariant mockRouter = new MockGatewayRouterInvariant(user); + factory.initialize(address(impl), address(this), IGatewayRouter(address(mockRouter))); + token = new TestERC20(); + vault = MasterVault(factory.deployVault(address(token))); + + // Deploy FuzzSubVault and configure it as the active subvault + subVault = new FuzzSubVault(IERC20(address(token)), "FuzzSub", "fSUB"); + vault.rolesRegistry().grantRole(vault.ADMIN_ROLE(), address(this)); + vault.setSubVaultWhitelist(address(subVault), true); + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), address(this)); + vault.rolesRegistry().grantRole(vault.KEEPER_ROLE(), keeper); + vault.rolesRegistry().grantRole(vault.FEE_MANAGER_ROLE(), address(this)); + vault.setSubVault(IERC4626(address(subVault))); + vault.setBeneficiary(beneficiaryAddr); + vault.setMinimumRebalanceAmount(1); + + handler = _createHandler(); + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), handler); + targetContract(handler); + } + + function _createHandler() internal virtual returns (address); + + function _clearAllLimits() internal { + subVault.setMaxWithdrawLimit(type(uint256).max); + subVault.setMaxDepositLimit(type(uint256).max); + subVault.setMaxRedeemLimit(type(uint256).max); + } + + function _clearAllRoundingError() internal { + subVault.setDepositErrorWad(0); + subVault.setWithdrawErrorWad(0); + subVault.setRedeemErrorWad(0); + subVault.setPreviewMintErrorWad(0); + subVault.setPreviewRedeemErrorWad(0); + } + + function _revert(bytes memory reason) internal pure { + assembly { revert(add(reason, 32), mload(reason)) } + } + + /// @dev Returns false if the subvault exchange rate would cause the deposit path to overflow + /// or yield 0 shares. Withdraw and drain paths are naturally bounded and never overflow. + function _rebalanceWillOverflow() internal view returns (bool) { + uint64 alloc = vault.targetAllocationWad(); + if (alloc == 0) return true; // drain path — always safe + + uint256 idle = token.balanceOf(address(vault)); + uint256 total = vault.totalAssets(); + uint256 idleTarget = (total * (1e18 - alloc)) / 1e18; + + if (idle <= idleTarget) return true; // withdraw path - always safe + + // Deposit path: shares = depositAmount * subSupply / subAssets + uint256 depositAmount = idle - idleTarget; + uint256 subSupply = subVault.totalSupply(); + + // Overflow: result exceeds uint256 when supply/assets ratio is too large + if (type(uint256).max / subSupply < depositAmount) return false; + return true; + } + + function _errorSelector(bytes memory reason) internal pure returns (bytes4 sel) { + if (reason.length >= 4) { + assembly { sel := mload(add(reason, 32)) } + } + } + + function _rebalanceSlippage() internal view returns (int256) { + uint64 alloc = vault.targetAllocationWad(); + if (alloc == 0) return int256(0); + uint256 idle = token.balanceOf(address(vault)); + uint256 idleTarget = ((vault.totalAssets() + 1) * (1e18 - alloc)) / 1e18; + return idle > idleTarget ? type(int248).min : int256(0); + } + + function _rebalanceToZero() internal returns (bool skip) { + if (vault.targetAllocationWad() != 0) { + vault.setTargetAllocationWad(0); + } + + uint256 shareBalance = vault.subVault().balanceOf(address(vault)); + if (shareBalance == 0) return false; + + uint256 maxRedeem = vault.subVault().maxRedeem(address(vault)); + if (maxRedeem == 0) revert("maxRedeem should not be zero"); + + uint256 iterationsRequired = (shareBalance) / maxRedeem + 1; + + // set some reasonable upper bound on iterations to prevent infinite loop + if (iterationsRequired > 50) { + _clearAllLimits(); + iterationsRequired = (shareBalance) / vault.subVault().maxRedeem(address(vault)) + 1; + require(iterationsRequired == 2, "too many iterations required to rebalance to zero"); + } + + for (uint256 i = 0; i < iterationsRequired && vault.subVault().balanceOf(address(vault)) != 0; i++) { + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vault.rebalance(0); + } + + uint256 shareBalanceAfter = vault.subVault().balanceOf(address(vault)); + assertEq(shareBalanceAfter, 0, "should have redeemed all shares after iterations"); + } + + function _mintAndDeposit(uint256 amount) internal returns (uint256) { + vm.startPrank(user); + token.mintAmount(amount); + token.approve(address(vault), amount); + uint256 shares = vault.deposit(amount); + vm.stopPrank(); + return shares; + } + + function _rebalanceAcceptTargetMetAndImpossibleExchRate() internal { + vm.warp(block.timestamp + 2); + vm.startPrank(keeper); + try vault.rebalance(_rebalanceSlippage()) {} + catch (bytes memory reason) { + bytes4 sel = _errorSelector(reason); + if (sel == MasterVault.RebalanceExchRateTooLow.selector) { + (, int256 deltaAssets, uint256 subVaultShares) = abi.decode(_sliceMemoryBytes(reason, 4), (int256, int256, uint256)); + if (deltaAssets != 0 && subVaultShares != 0) { + _revert(reason); + } + } + else if (sel != MasterVault.TargetAllocationMet.selector) { + _revert(reason); + } + } + vm.stopPrank(); + } + + function _sliceMemoryBytes(bytes memory x, uint256 start) internal view returns (bytes memory result) { + return this.sliceCalldataBytes(x, start); + } + + function sliceCalldataBytes(bytes calldata x, uint256 start) external pure returns (bytes memory) { + return x[start:]; + } + + function _rand() internal returns (uint256) { + random = uint256(keccak256(abi.encode(random))); + return random; + } +} + +/// @notice Stateful invariant tests for MasterVault. +/// @dev Setup deploys the vault via factory, a FuzzSubVault as the active subvault, +/// grants all roles, and targets the handler contract for fuzzer calls. +contract MasterVaultInvariant is BaseMasterVaultInvariant { + function _createHandler() internal virtual override returns (address) { + return address(new MasterVaultWithManipulationHandler(vault, subVault, token, user, keeper)); + } + + // --- Invariants --- + + function invariant_canAlwaysRebalanceToZero() public { + _rebalanceToZero(); + } + + function invariant_canAlwaysSwitchSubVaults() public { + if (_rebalanceToZero()) return; + + FuzzSubVault newSubVault = new FuzzSubVault(IERC20(address(token)), "FuzzSub2", "fSUB2"); + vault.setSubVaultWhitelist(address(newSubVault), true); + vault.setSubVault(IERC4626(address(newSubVault))); + + // restore original subvault so future invariant calls work + vault.setSubVaultWhitelist(address(subVault), true); + vault.setSubVault(IERC4626(address(subVault))); + } + + /// @notice A deposit-redeem round-trip must never extract value. + /// @dev At any reachable state (arbitrary exchange rates from handler actions), + /// depositing X and immediately redeeming should return <= X. + /// Catches: share pricing rounding that favors depositor over vault. + function invariant_depositRedeemNoValueExtraction() public { + uint256 depositAmount = bound(MasterVaultHandler(handler).random(), 1, 1e18); + vm.prank(user); + token.mintAmount(depositAmount); + vm.startPrank(user); + token.approve(address(vault), depositAmount); + uint256 shares = vault.deposit(depositAmount); + vm.stopPrank(); + + uint256 balBefore = token.balanceOf(user); + vm.prank(user); + vault.redeem(shares, 0); + uint256 assetsReceived = token.balanceOf(user) - balBefore; + + assertLe(assetsReceived, depositAmount, "deposit-redeem round-trip extracted value"); + } + + /// @notice Redeeming shares must never return more than 1:1 (i.e. assets <= shares / 1e6). + /// @dev The ideal rate is the ceiling. Profit goes to beneficiary, not share holders. + /// Catches: share pricing bugs that let users extract more than they deposited. + function invariant_redeemRateNeverAbovePar() public { + _clearAllLimits(); + subVault.setWithdrawErrorWad(0); + + uint256 userShares = vault.balanceOf(user); + if (userShares == 0) return; + + uint256 sharesToRedeem = bound(MasterVaultHandler(handler).random(), 1, userShares); + uint256 balBefore = token.balanceOf(user); + vm.prank(user); + vault.redeem(sharesToRedeem, 0); + uint256 assetsReceived = token.balanceOf(user) - balBefore; + + assertLe(assetsReceived * DEAD_SHARES, sharesToRedeem, "redeem rate exceeded 1:1"); + } + + /// @notice A rebalance must not change totalAssets (within tolerable error) + /// @dev Rebalancing can lose up to 1 subvault share's worth of value + function invariant_rebalancePreservesTotalAssets() public { + _clearAllLimits(); + _clearAllRoundingError(); + + if (!_rebalanceWillOverflow()) return; + + uint256 totalBefore = vault.totalAssets(); + uint256 subVaultTotalAssetsBefore = vault.subVault().totalAssets(); + uint256 subVaultTotalSupplyBefore = vault.subVault().totalSupply(); + uint256 subVaultPPS = subVaultTotalAssetsBefore / subVaultTotalSupplyBefore; + + _rebalanceAcceptTargetMetAndImpossibleExchRate(); + + uint256 totalAfter = vault.totalAssets(); + assertLe(totalAfter, totalBefore, "rebalance created value"); + assertGe(totalAfter + subVaultPPS, totalBefore, "rebalance lost more than subVaultPPS"); + } + + /// @notice Once target allocation is reached, further rebalances revert with TargetAllocationMet. + /// @dev Catches: oscillating rebalances, tolerance band not working. + function invariant_rebalanceIdempotent() public { + _clearAllLimits(); + _clearAllRoundingError(); + + if (!_rebalanceWillOverflow()) return; + + // First rebalance to reach target + _rebalanceAcceptTargetMetAndImpossibleExchRate(); + + // recheck since first rebalance may have shifted the band into deposit territory + // this is not an issue because it involves the mastervault totalAssets correctly changing + if (!_rebalanceWillOverflow()) return; + + // Second rebalance must not succeed + vm.warp(block.timestamp + 2); + vm.startPrank(keeper); + try vault.rebalance(_rebalanceSlippage()) { + revert("second rebalance succeeded when target allocation met"); + } + catch (bytes memory reason) { + bytes4 sel = _errorSelector(reason); + if (sel != MasterVault.TargetAllocationMet.selector && sel != MasterVault.RebalanceExchRateTooLow.selector) { + _revert(reason); + } + } + vm.stopPrank(); + } + + /// @dev We can lose up to 1 subVault share worth of value + function invariant_feeDistributionCantCauseInsolvency() public { + _clearAllLimits(); + _clearAllRoundingError(); + if (vault.totalAssets() * DEAD_SHARES < vault.totalSupply()) { + return; + } + uint256 subVaultPPS = vault.subVault().totalAssets() / vault.subVault().totalSupply(); + vm.prank(keeper); + try vault.distributePerformanceFee() {} catch {} + assertGe((vault.totalAssets() + subVaultPPS) * DEAD_SHARES, vault.totalSupply(), "vault became insolvent after fee distribution"); + } + + function invariant_noDonationAttackWhenSolvent() public { + if (vault.totalAssets() * DEAD_SHARES < vault.totalSupply()) { + // make the vault solvent by donating some tokens + uint256 amountToMint = ((vault.totalSupply() / DEAD_SHARES) - vault.totalAssets()) + 1; + vm.prank(address(vault)); + token.mintAmount(amountToMint); + } + + random = MasterVaultHandler(handler).random(); + uint256 userDepositAmount = bound(_rand(), 1, 1e18); + uint256 snapshot = vm.snapshot(); + uint256 sharesRecvBefore = _mintAndDeposit(userDepositAmount); + vm.revertTo(snapshot); + + // attacker deposit + _mintAndDeposit(bound(_rand(), 1, 1e18)); + + // attacker donate + vm.prank(address(vault)); + token.mintAmount(bound(_rand(), 1, 1e18)); + + // user deposit + uint256 sharesRecvAfter = _mintAndDeposit(userDepositAmount); + + // make sure user does not lose + assertGe(sharesRecvAfter, sharesRecvBefore, "user received fewer shares after attacker donation"); + } + + function invariant_donationAttackNotProfitable() public { + random = MasterVaultHandler(handler).random(); + + uint256 attackerDepositAmount = bound(_rand(), 1, 1e18); + uint256 attackerDonationAmount = bound(_rand(), 1, 1e18); + uint256 userDepositAmount = bound(_rand(), 1, 1e18); + + // attacker deposit + uint256 attackerShares = _mintAndDeposit(attackerDepositAmount); + // attacker donate + vm.prank(address(vault)); + token.mintAmount(attackerDonationAmount); + // user deposit + _mintAndDeposit(userDepositAmount); + + // attacker redeem + // make sure attacker does not profit + vm.prank(user); + assertLe(vault.redeem(attackerShares, 0), attackerDepositAmount + attackerDonationAmount, "attacker made profit from donation attack"); + } +} + +contract MasterVaultNoManipulationInvariant is BaseMasterVaultInvariant { + function _createHandler() internal virtual override returns (address) { + return address(new MasterVaultHandler(vault, subVault, token, user, keeper)); + } + + /// @notice When no rounding errors injected, assets cover principal. + /// @dev Under normal operation, the vault should never become insolvent. + function invariant_solvency() public { + assertGe(vault.totalAssets() * DEAD_SHARES, vault.totalSupply(), "insolvent without manipulation"); + } + + /// @notice Performance fees must never exceed reported profit. + function invariant_feeDistributionBounded() public { + subVault.setMaxWithdrawLimit(type(uint256).max); + uint256 roundingTolerance = MasterVaultHandler(handler).ghost_callCount(MasterVaultHandler.deposit.selector) + MasterVaultHandler(handler).ghost_callCount(MasterVaultHandler.redeem.selector); + vm.prank(keeper); + vault.distributePerformanceFee(); + assertLe( + token.balanceOf(vault.beneficiary()), + MasterVaultHandler(handler).ghost_profit() + roundingTolerance, + "fees extracted exceed profit" + ); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultAccessControl.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultAccessControl.t.sol new file mode 100644 index 000000000..528cd77c0 --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultAccessControl.t.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultAccessControlTest is MasterVaultCoreTest { + function test_onlyGateway_rejectsNonGateway() public { + address notGateway = address(0x1234); + vm.prank(notGateway); + token.mintAmount(1e18); + vm.startPrank(notGateway); + token.approve(address(vault), 1e18); + vm.expectRevert(abi.encodeWithSelector(MasterVault.NotGateway.selector, notGateway)); + vault.deposit(1e18); + vm.stopPrank(); + } + + function test_onlyKeeper_rejectsNonKeeper() public { + _depositAs(1e18); + vault.setTargetAllocationWad(5e17); + vm.warp(block.timestamp + 2); + address notKeeper = address(0x7777); + vm.prank(notKeeper); + vm.expectRevert(MasterVault.NotKeeper.selector); + vault.rebalance(-1e18); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultDefaultSubVault.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultDefaultSubVault.t.sol new file mode 100644 index 000000000..ee8ee7455 --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultDefaultSubVault.t.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import { + DefaultSubVault +} from "../../../../contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol"; + +contract MasterVaultDefaultSubVaultTest is MasterVaultCoreTest { + function test_defaultSubVault_withdraw_onlyMasterVault() public { + DefaultSubVault dsv = DefaultSubVault(address(vault.subVault())); + address attacker = address(0xdead); + vm.prank(attacker); + vm.expectRevert("ONLY_MASTER_VAULT"); + dsv.withdraw(1, attacker, attacker); + } + + function test_defaultSubVault_deposit_onlyMasterVault() public { + DefaultSubVault dsv = DefaultSubVault(address(vault.subVault())); + address attacker = address(0xdead); + vm.prank(attacker); + vm.expectRevert("ONLY_MASTER_VAULT"); + dsv.deposit(1, attacker); + } + + function test_defaultSubVault_withdraw_requireTrue_onlyMasterVault() public { + DefaultSubVault dsv = DefaultSubVault(address(vault.subVault())); + vm.prank(address(vault)); + // should not revert when called by masterVault (with 0 amount) + dsv.withdraw(0, address(vault), address(vault)); + } + + function test_defaultSubVault_mint_reverts() public { + DefaultSubVault dsv = DefaultSubVault(address(vault.subVault())); + vm.expectRevert("UNSUPPORTED"); + dsv.mint(1, address(this)); + } + + function test_defaultSubVault_redeem_onlyMasterVault() public { + DefaultSubVault dsv = DefaultSubVault(address(vault.subVault())); + address attacker = address(0xdead); + vm.prank(attacker); + vm.expectRevert("ONLY_MASTER_VAULT"); + dsv.redeem(1, attacker, attacker); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultFees.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultFees.t.sol new file mode 100644 index 000000000..c6d97ec9b --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultFees.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {TestERC20} from "../../../../contracts/tokenbridge/test/TestERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Vm} from "forge-std/Test.sol"; + +contract MasterVaultFeesTest is MasterVaultCoreTest { + function test_totalProfit_correctMath() public { + // this test was AI generated to target a mutant where subtract is replaced with modulo in profit calculation + + // Use a scenario where profit > principal, so % gives different result than - + // Deposit 10e18, simulate 20e18 profit → totalAssets ≈ 30e18, principal ≈ 10e18 + // With -: profit = 30e18 - 10e18 = 20e18 + // With %: profit = 30e18 % 10e18 = 0 (since 30 is divisible by 10) + _depositAs(10e18); + token.mintAmount(20e18); + token.transfer(address(vault), 20e18); + uint256 profit = vault.totalProfit(); + assertEq(profit, 20e18, "profit should be 20e18"); + } + + function test_distributePerformanceFee_noBeneficiary_reverts() public { + MasterVault freshVault = MasterVault(factory.deployVault(address(new TestERC20()))); + freshVault.rolesRegistry().grantRole(freshVault.KEEPER_ROLE(), address(this)); + vm.expectRevert(MasterVault.BeneficiaryNotSet.selector); + freshVault.distributePerformanceFee(); + } + + function test_distributePerformanceFee_zeroProfit_noEvent() public { + // No profit -> early return, should NOT emit PerformanceFeesWithdrawn + // If mutant removes early return, the event would still be emitted with (beneficiary, 0, 0) + vm.prank(keeper); + vm.recordLogs(); + vault.distributePerformanceFee(); + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 i = 0; i < logs.length; i++) { + assertTrue( + logs[i].topics[0] != keccak256("PerformanceFeesWithdrawn(address,uint256,uint256)"), + "should not emit PerformanceFeesWithdrawn when profit is zero" + ); + } + } + + function test_distributePerformanceFee_transfersIdleProfit() public { + _depositAs(100e18); + token.mintAmount(10e18); + token.transfer(address(vault), 10e18); + uint256 vaultBalBefore = token.balanceOf(address(vault)); + vm.prank(keeper); + vault.distributePerformanceFee(); + uint256 vaultBalAfter = token.balanceOf(address(vault)); + assertEq(token.balanceOf(beneficiaryAddr), 10e18, "beneficiary should receive profit"); + assertEq(vaultBalBefore - vaultBalAfter, 10e18, "vault should lose exactly the profit amount"); + } + + function test_distributePerformanceFee_allProfitInSubVault() public { + _setupWithAllocation(100e18, 99e16); // 99% to subvault + // Simulate profit in subvault + token.mintAmount(5e18); + token.transfer(address(vault.subVault()), 5e18); + uint256 idleBefore = token.balanceOf(address(vault)); + uint256 profit = vault.totalProfit(); + assertTrue(profit > 0); + assertEq(profit, 5e18, "profit should be 5e18"); + vm.prank(keeper); + vault.distributePerformanceFee(); + // If mutant makes `if(true)`, it calls safeTransfer(beneficiary, 0) when idle profit is 0. + // This would still succeed, so this is more about ensuring overall correctness. + assertEq(token.balanceOf(beneficiaryAddr), profit, "beneficiary receives full profit"); + assertEq(token.balanceOf(address(vault)), 0, "should take all idle from vault"); + assertEq(token.balanceOf(address(vault.subVault())), 100e18, "should withdraw only profit from subvault"); + } + + function test_distributePerformanceFee_allProfitIdle_noSubVaultWithdraw() public { + _depositAs(100e18); + // Profit is all idle (no allocation to subvault) + token.mintAmount(5e18); + token.transfer(address(vault), 5e18); + uint256 subVaultSharesBefore = vault.subVault().balanceOf(address(vault)); + vm.prank(keeper); + vault.distributePerformanceFee(); + uint256 subVaultSharesAfter = vault.subVault().balanceOf(address(vault)); + assertEq(subVaultSharesBefore, subVaultSharesAfter, "no subvault shares should change"); + assertEq(token.balanceOf(beneficiaryAddr), 5e18, "beneficiary gets idle profit"); + } + + function test_distributePerformanceFee_noSubVaultWithdrawCall() public { + _depositAs(100e18); + // All profit is idle — amountToWithdraw will be 0 + token.mintAmount(5e18); + token.transfer(address(vault), 5e18); + + vm.prank(keeper); + vm.recordLogs(); + vault.distributePerformanceFee(); + + // The mutant calls subVault.withdraw(0, ...) which emits a Withdraw event. + // Correct code skips the call entirely, so no Withdraw event from the subvault. + bytes32 withdrawSig = keccak256("Withdraw(address,address,address,uint256,uint256)"); + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter == address(vault.subVault())) { + assertTrue(logs[i].topics[0] != withdrawSig, "subVault should not emit Withdraw"); + } + } + } + + function test_distributePerformanceFee_noTransferCallWhenIdleZero() public { + _setupWithAllocation(100e18, 1e18); // 100% to subvault, idle should be 0 + assertEq(token.balanceOf(address(vault)), 0, "idle should be 0 after 100% rebalance"); + + // All profit in subvault — amountToTransfer will be 0 + token.mintAmount(5e18); + token.transfer(address(vault.subVault()), 5e18); + + vm.prank(keeper); + vm.recordLogs(); + vault.distributePerformanceFee(); + + // The mutant calls safeTransfer(beneficiary, 0) which emits Transfer(vault, beneficiary, 0). + // Correct code skips the call, so no such Transfer event. + bytes32 transferSig = keccak256("Transfer(address,address,uint256)"); + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == transferSig && logs[i].emitter == address(token)) { + (uint256 amount) = abi.decode(logs[i].data, (uint256)); + if (amount == 0) { + address from = address(uint160(uint256(logs[i].topics[1]))); + address to = address(uint160(uint256(logs[i].topics[2]))); + assertTrue( + !(from == address(vault) && to == beneficiaryAddr), + "should not transfer 0 from vault to beneficiary" + ); + } + } + } + } + + function test_distributePerformanceFee_withdrawsFromSubVault() public { + // Put nearly everything into subvault so idle is very small + _setupWithAllocation(100e18, 99e16); // 99% to subvault + uint256 idleBeforeProfit = token.balanceOf(address(vault)); + // Simulate profit LARGER than idle, forcing withdrawal from subvault + uint256 profitAmount = idleBeforeProfit + 5e18; + token.mintAmount(profitAmount); + token.transfer(address(vault.subVault()), profitAmount); + uint256 profit = vault.totalProfit(); + assertTrue(profit > idleBeforeProfit, "profit exceeds idle"); + uint256 subVaultBalBefore = token.balanceOf(address(vault.subVault())); + vm.prank(keeper); + vault.distributePerformanceFee(); + uint256 subVaultBalAfter = token.balanceOf(address(vault.subVault())); + assertEq(token.balanceOf(beneficiaryAddr), profit, "beneficiary should receive all profit"); + assertTrue(subVaultBalBefore > subVaultBalAfter, "subvault balance should decrease"); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultInit.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultInit.t.sol new file mode 100644 index 000000000..4ac6b42ef --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultInit.t.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {TestERC20} from "../../../../contracts/tokenbridge/test/TestERC20.sol"; +import {stdError} from "forge-std/StdError.sol"; +import {IAccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +abstract contract BadTokenBase is IERC20Metadata { + function name() external pure returns (string memory) { return "Bad"; } + function symbol() external pure returns (string memory) { return "BAD"; } + function balanceOf(address) external pure returns (uint256) { return 0; } + function transfer(address, uint256) external pure returns (bool) { return true; } + function approve(address, uint256) external pure returns (bool) { return true; } + function transferFrom(address, address, uint256) external pure returns (bool) { return true; } + function allowance(address, address) external pure returns (uint256) { return 0; } + function totalSupply() external pure returns (uint256) { return 0; } +} + +contract RevertingDecimalsToken is BadTokenBase { + function decimals() external pure returns (uint8) { revert("no decimals"); } +} + +contract OverflowDecimalsToken is BadTokenBase { + function decimals() external pure returns (uint8) { return type(uint8).max - 5; } +} + +contract MasterVaultInitTest is MasterVaultCoreTest { + function test_initialize_setsERC20Name() public { + string memory n = vault.name(); + assertTrue(bytes(n).length > 0, "name should be set"); + } + + function test_initialize_callsDecimals() public { + assertEq(vault.decimals(), 18 + 6, "decimals should be underlying + EXTRA_DECIMALS"); + } + + function test_initialize_revertsOnRevertingDecimals() public { + address badToken = address(new RevertingDecimalsToken()); + vm.expectRevert("no decimals"); + factory.deployVault(badToken); + } + + function test_initialize_revertsOnOverflowDecimals() public { + address badToken = address(new OverflowDecimalsToken()); + vm.expectRevert(stdError.arithmeticError); + factory.deployVault(badToken); + } + + function test_initialize_pausableInit() public { + assertFalse(vault.paused(), "vault should not be paused initially"); + vm.prank(pauser); + vault.pause(); + assertTrue(vault.paused(), "vault should be paused after pause()"); + } + + function test_initialize_setsSubVaultWhitelist() public { + assertTrue( + vault.isSubVaultWhitelisted(address(vault.subVault())), + "initial subVault should be whitelisted" + ); + } + + function test_initialize_minimumRebalanceAmount() public { + assertEq( + vault.minimumRebalanceAmount(), + 1, // we set it to 1 in setUp, but let's check on a fresh vault + "minimumRebalanceAmount after setUp" + ); + // Deploy a fresh vault to check default + MasterVault freshVault = MasterVault(factory.deployVault(address(new TestERC20()))); + assertEq( + freshVault.minimumRebalanceAmount(), + freshVault.DEFAULT_MIN_REBALANCE_AMOUNT(), + "default minimumRebalanceAmount" + ); + } + + function test_initialize_rebalanceCooldown() public { + MasterVault freshVault = MasterVault(factory.deployVault(address(new TestERC20()))); + assertEq( + freshVault.rebalanceCooldown(), + freshVault.MIN_REBALANCE_COOLDOWN(), + "default rebalanceCooldown" + ); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultPause.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultPause.t.sol new file mode 100644 index 000000000..e48be8256 --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultPause.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultPauseTest is MasterVaultCoreTest { + function test_pause_works() public { + vm.prank(pauser); + vault.pause(); + assertTrue(vault.paused(), "should be paused"); + } + + function test_unpause_works() public { + vm.prank(pauser); + vault.pause(); + assertTrue(vault.paused()); + vm.prank(pauser); + vault.unpause(); + assertFalse(vault.paused(), "should be unpaused"); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultRebalance.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultRebalance.t.sol new file mode 100644 index 000000000..8e76dce13 --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultRebalance.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +contract MasterVaultRebalanceTest is MasterVaultCoreTest { + function test_rebalance_targetAllocationMet_reverts() public { + _setupWithAllocation(1e18, 5e17); + // Rebalance again with same allocation - should be met now + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vm.expectRevert(MasterVault.TargetAllocationMet.selector); + vault.rebalance(0); + } + + function test_rebalance_withdraw_negativeExchRate_reverts() public { + _setupWithAllocation(10e18, 8e17); + vault.setTargetAllocationWad(2e17); + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vm.expectRevert( + abi.encodeWithSelector(MasterVault.RebalanceExchRateWrongSign.selector, int256(-1)) + ); + vault.rebalance(-1); + } + + function test_rebalance_deposit_positiveExchRate_reverts() public { + _depositAs(1e18); + vault.setTargetAllocationWad(5e17); + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vm.expectRevert( + abi.encodeWithSelector(MasterVault.RebalanceExchRateWrongSign.selector, int256(1)) + ); + vault.rebalance(1); + } + + function test_rebalance_withdraw_zeroExchRate_succeeds() public { + _setupWithAllocation(10e18, 8e17); + vault.setTargetAllocationWad(2e17); + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vault.rebalance(0); + } + + function test_rebalance_withdraw_correctAmount() public { + // this test was AI generated to catch a mutated desiredWithdraw calculation + _setupWithAllocation(10e18, 8e17); // 80% to subvault + + uint256 idleBefore = token.balanceOf(address(vault)); + uint256 totalAssetsBefore = vault.totalAssets(); + + vault.setTargetAllocationWad(2e17); // 20% to subvault, want more idle + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vault.rebalance(0); + + uint256 idleAfter = token.balanceOf(address(vault)); + // idle should be 80% of total assets now + // With mutant #104 (+), desiredWithdraw would be idleTarget + idle (huge), capped by maxWithdrawable + // With mutant #105 (*), desiredWithdraw would be idleTarget * idle (huge), capped by maxWithdrawable + // Both would withdraw too much, leaving much more idle than expected + uint256 idleTarget80pct = totalAssetsBefore * 80 / 100; + assertEq(idleAfter, idleTarget80pct, "idle should be exactly 80% of total assets"); + } + + function test_rebalance_withdraw_tooSmall_reverts() public { + _setupWithAllocation(10e18, 5e17); + + vault.setMinimumRebalanceAmount(100e18); // huge minimum + vault.setTargetAllocationWad(49e16); // tiny change to trigger small withdraw + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector( + MasterVault.RebalanceAmountTooSmall.selector, false, 1e17 - 1, 1e17 - 1, 100e18 + )); + vault.rebalance(0); + } + + function test_rebalance_withdraw_exchRateTooLow_reverts() public { + _setupWithAllocation(10e18, 8e17); + vault.setTargetAllocationWad(2e17); + vm.warp(block.timestamp + 2); + // Pass a very high minExchRateWad so the exchange rate check fails + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector( + MasterVault.RebalanceExchRateTooLow.selector, int256(100e18), int256(6e18 - 1), 6e18 - 1 + )); + vault.rebalance(int256(100e18)); + } + + function test_rebalance_deposit_tooSmall_reverts() public { + _depositAs(10e18); + vault.setMinimumRebalanceAmount(100e18); + vault.setTargetAllocationWad(5e17); + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector( + MasterVault.RebalanceAmountTooSmall.selector, true, 5e18 - 1, 5e18 - 1, 100e18 + )); + vault.rebalance(-1e18); + } + + function test_rebalance_deposit_exchRatePasses() public { + // this test was AI generated to catch a mutated minExchRateWad comparison + _depositAs(10e18); + vault.setTargetAllocationWad(5e17); + vm.warp(block.timestamp + 2); + // -1e18 means 1:1 exchange rate tolerance. For a 1:1 subvault this should pass. + // With #97 (++minExchRateWad): -(-1e18+1) = 999999999999999999, actualRate=1e18 > 999999999999999999 → reverts + // With #100 (~minExchRateWad): ~(-1e18) = 1e18-1, actualRate=1e18 > 1e18-1 → reverts + vm.prank(keeper); + vault.rebalance(-1e18); + } + + function test_rebalance_deposit_exchRate_tooStrict_reverts() public { + _depositAs(10e18); + vault.setTargetAllocationWad(5e17); + vm.warp(block.timestamp + 2); + // Pass -1 (tolerance of 1 wei per share) - actual rate is 1e18, so 1e18 > 1 reverts + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector( + MasterVault.RebalanceExchRateTooLow.selector, int256(-1), -int256(5e18 - 1), 5e18 - 1 + )); + vault.rebalance(-1); + } + + function test_rebalance_deposit_exchRate_revertExactArgs() public { + _depositAs(10e18); + vault.setTargetAllocationWad(5e17); + vm.warp(block.timestamp + 2); + // Use -2 so uint256(-(-2)) = 2. actualExchRate=1e18 > 2, should revert. + // Replicate the contract's exact math to predict depositAmount: + uint256 idleBalance = token.balanceOf(address(vault)); + // totalAssetsUp uses Rounding.Up for previewMint (subvault has 0 shares so it's just 1+idle) + uint256 totalAssetsUp = 1 + idleBalance; // no subvault shares yet + uint64 alloc = vault.targetAllocationWad(); + uint256 idleTargetUp = (totalAssetsUp * (1e18 - alloc) + 1e18 - 1) / 1e18; // round up + uint256 desiredDeposit = idleBalance - idleTargetUp; + uint256 maxDepositable = vault.subVault().maxDeposit(address(vault)); + uint256 depositAmount = desiredDeposit < maxDepositable ? desiredDeposit : maxDepositable; + // Normal: RebalanceExchRateTooLow(-2, -int256(depositAmount), depositAmount) + // Mutant: ~int256(depositAmount) = -(depositAmount+1) instead of -depositAmount + vm.prank(keeper); + vm.expectRevert( + abi.encodeWithSelector( + MasterVault.RebalanceExchRateTooLow.selector, + int256(-2), + -int256(depositAmount), + depositAmount + ) + ); + vault.rebalance(-2); + } + + function test_rebalance_updatesLastRebalanceTime() public { + _depositAs(1e18); + vault.setTargetAllocationWad(5e17); + uint256 rebalanceTime = block.timestamp + 100; + vm.warp(rebalanceTime); + vm.prank(keeper); + vault.rebalance(-1e18); + assertEq(vault.lastRebalanceTime(), rebalanceTime, "lastRebalanceTime should be updated"); + } + + function test_rebalance_drain_whenTargetIsZero() public { + // Deposit and allocate 50% to subvault + _setupWithAllocation(10e18, 5e17); + uint256 subVaultSharesBefore = vault.subVault().balanceOf(address(vault)); + assertTrue(subVaultSharesBefore > 0, "should have subvault shares"); + + // Set target to 0% — should trigger drain path (redeem), not rebalanceToTarget (withdraw) + vault.setTargetAllocationWad(0); + vm.warp(block.timestamp + 2); + + // Drain uses redeem(allShares) — verify it's called + vm.expectCall( + address(vault.subVault()), + abi.encodeCall(IERC4626.redeem, (subVaultSharesBefore, address(vault), address(vault))) + ); + vm.prank(keeper); + vault.rebalance(0); + + // Drain should redeem ALL subvault shares + uint256 subVaultSharesAfter = vault.subVault().balanceOf(address(vault)); + assertEq(subVaultSharesAfter, 0, "drain should redeem all subvault shares"); + } + + function test_rebalance_drain_noShares_reverts() public { + _depositAs(10e18); + // Target is 0 and no subvault shares — drain should revert + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vm.expectRevert(MasterVault.TargetAllocationMet.selector); + vault.rebalance(0); + } + + function test_rebalance_drain_negativeExchRate_reverts() public { + _setupWithAllocation(10e18, 5e17); + vault.setTargetAllocationWad(0); + vm.warp(block.timestamp + 2); + vm.prank(keeper); + vm.expectRevert( + abi.encodeWithSelector(MasterVault.RebalanceExchRateWrongSign.selector, int256(-1)) + ); + vault.rebalance(-1); + } + + function test_rebalance_drain_exchRateTooLow_reverts() public { + _setupWithAllocation(10e18, 5e17); + vault.setTargetAllocationWad(0); + vm.warp(block.timestamp + 2); + + uint256 subVaultShares = vault.subVault().maxRedeem(address(vault)); + uint256 assetsReceived = vault.subVault().previewRedeem(subVaultShares); + + vm.prank(keeper); + vm.expectRevert( + abi.encodeWithSelector( + MasterVault.RebalanceExchRateTooLow.selector, + int256(100e18), + int256(assetsReceived), + subVaultShares + ) + ); + vault.rebalance(int256(100e18)); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultRebalanceCooldown.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultRebalanceCooldown.t.sol new file mode 100644 index 000000000..253774460 --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultRebalanceCooldown.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultRebalanceCooldownTest is MasterVaultCoreTest { + function test_rebalance_cooldownMath() public { + _depositAs(10e18); + vault.setTargetAllocationWad(3e17); // 30% allocation + vault.setRebalanceCooldown(10); + // Rebalance at t=100 (deposits ~30% of assets to subvault) + vm.warp(100); + vm.prank(keeper); + vault.rebalance(-1e18); + // Increase allocation so there's more to deposit + vault.setTargetAllocationWad(8e17); + // Warp only 5s (less than 10s cooldown) + vm.warp(105); + // With mutant (+): timeSinceLastRebalance = 105+100=205 >= 10, no cooldown revert + // With correct code (-): timeSinceLastRebalance = 105-100=5 < 10, cooldown revert + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector(MasterVault.RebalanceCooldownNotMet.selector, uint256(5), uint256(10))); + vault.rebalance(-1e18); + } + + function test_rebalance_cooldownEnforced() public { + _depositAs(10e18); + vault.setTargetAllocationWad(3e17); // 30% + vault.setRebalanceCooldown(100); + vm.warp(block.timestamp + 101); + vm.prank(keeper); + vault.rebalance(-1e18); + // Increase allocation so second rebalance is also a deposit + vault.setTargetAllocationWad(8e17); + // Warp only 50s (less than 100 cooldown) + vm.warp(block.timestamp + 50); + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector(MasterVault.RebalanceCooldownNotMet.selector, uint256(50), uint256(100))); + vault.rebalance(-1e18); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultRedeem.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultRedeem.t.sol new file mode 100644 index 000000000..b8b341b44 --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultRedeem.t.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultRedeemTest is MasterVaultCoreTest { + function test_redeem_minAssets_reverts() public { + uint256 shares = _depositAs(1e18); + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(MasterVault.InsufficientAssets.selector, 1e18, 2e18) + ); + vault.redeem(shares, 2e18); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultRoles.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultRoles.t.sol new file mode 100644 index 000000000..6932a0cb5 --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultRoles.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultRolesTest is MasterVaultCoreTest { + // --- standalone rolesRegistry --- + + function test_rolesRegistry_adminRoleAdmin_isAdminRole() public { + assertEq( + vault.rolesRegistry().getRoleAdmin(vault.ADMIN_ROLE()), + vault.ADMIN_ROLE(), + "ADMIN_ROLE admin should be ADMIN_ROLE" + ); + } + + function test_rolesRegistry_generalManagerRoleAdmin_isAdminRole() public { + assertEq( + vault.rolesRegistry().getRoleAdmin(vault.GENERAL_MANAGER_ROLE()), + vault.ADMIN_ROLE(), + "GENERAL_MANAGER_ROLE admin should be ADMIN_ROLE" + ); + } + + function test_rolesRegistry_feeManagerRoleAdmin_isGeneralManagerRole() public { + assertEq( + vault.rolesRegistry().getRoleAdmin(vault.FEE_MANAGER_ROLE()), + vault.GENERAL_MANAGER_ROLE(), + "FEE_MANAGER_ROLE admin should be GENERAL_MANAGER_ROLE" + ); + } + + function test_rolesRegistry_pauserRoleAdmin_isGeneralManagerRole() public { + assertEq( + vault.rolesRegistry().getRoleAdmin(vault.PAUSER_ROLE()), + vault.GENERAL_MANAGER_ROLE(), + "PAUSER_ROLE admin should be GENERAL_MANAGER_ROLE" + ); + } + + function test_rolesRegistry_keeperRoleAdmin_isGeneralManagerRole() public { + assertEq( + vault.rolesRegistry().getRoleAdmin(vault.KEEPER_ROLE()), + vault.GENERAL_MANAGER_ROLE(), + "KEEPER_ROLE admin should be GENERAL_MANAGER_ROLE" + ); + } + + // --- MasterVault (inherits MasterVaultRoles) --- + + function test_vault_adminRoleAdmin_isAdminRole() public { + assertEq( + vault.getRoleAdmin(vault.ADMIN_ROLE()), + vault.ADMIN_ROLE(), + "vault ADMIN_ROLE admin should be ADMIN_ROLE" + ); + } + + function test_vault_generalManagerRoleAdmin_isAdminRole() public { + assertEq( + vault.getRoleAdmin(vault.GENERAL_MANAGER_ROLE()), + vault.ADMIN_ROLE(), + "vault GENERAL_MANAGER_ROLE admin should be ADMIN_ROLE" + ); + } + + function test_vault_feeManagerRoleAdmin_isGeneralManagerRole() public { + assertEq( + vault.getRoleAdmin(vault.FEE_MANAGER_ROLE()), + vault.GENERAL_MANAGER_ROLE(), + "vault FEE_MANAGER_ROLE admin should be GENERAL_MANAGER_ROLE" + ); + } + + function test_vault_pauserRoleAdmin_isGeneralManagerRole() public { + assertEq( + vault.getRoleAdmin(vault.PAUSER_ROLE()), + vault.GENERAL_MANAGER_ROLE(), + "vault PAUSER_ROLE admin should be GENERAL_MANAGER_ROLE" + ); + } + + function test_vault_keeperRoleAdmin_isGeneralManagerRole() public { + assertEq( + vault.getRoleAdmin(vault.KEEPER_ROLE()), + vault.GENERAL_MANAGER_ROLE(), + "vault KEEPER_ROLE admin should be GENERAL_MANAGER_ROLE" + ); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultSetSubVault.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultSetSubVault.t.sol new file mode 100644 index 000000000..747730b4d --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultSetSubVault.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MockSubVault} from "../../../../contracts/tokenbridge/test/MockSubVault.sol"; +import {TestERC20} from "../../../../contracts/tokenbridge/test/TestERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +contract MasterVaultSetSubVaultTest is MasterVaultCoreTest { + function test_setSubVault_nonWhitelisted_reverts() public { + MockSubVault newSv = new MockSubVault(IERC20(address(token)), "New", "NEW"); + vm.prank(generalManager); + vm.expectRevert( + abi.encodeWithSelector(MasterVault.SubVaultNotWhitelisted.selector, address(newSv)) + ); + vault.setSubVault(IERC4626(address(newSv))); + } + + function test_setSubVault_wrongAsset_reverts() public { + TestERC20 otherToken = new TestERC20(); + MockSubVault wrongAssetSv = new MockSubVault(IERC20(address(otherToken)), "Wrong", "WRG"); + vault.setSubVaultWhitelist(address(wrongAssetSv), true); + vm.prank(generalManager); + vm.expectRevert(MasterVault.SubVaultAssetMismatch.selector); + vault.setSubVault(IERC4626(address(wrongAssetSv))); + } + + function test_setSubVault_nonZeroAllocation_reverts() public { + MockSubVault newSv = new MockSubVault(IERC20(address(token)), "New", "NEW"); + vault.setSubVaultWhitelist(address(newSv), true); + vault.setTargetAllocationWad(5e17); + vm.prank(generalManager); + vm.expectRevert(abi.encodeWithSelector(MasterVault.NonZeroTargetAllocation.selector, 5e17)); + vault.setSubVault(IERC4626(address(newSv))); + } + + function test_setSubVault_nonZeroShares_reverts() public { + _setupWithAllocation(1e18, 5e17); + // allocation is 50%, so there are subvault shares. Set allocation to 0 first. + vault.setTargetAllocationWad(0); + // subvault still has shares even though allocation is 0 + assertTrue(vault.subVault().balanceOf(address(vault)) > 0, "should have subvault shares"); + MockSubVault newSv = new MockSubVault(IERC20(address(token)), "New", "NEW"); + vault.setSubVaultWhitelist(address(newSv), true); + uint256 shares = vault.subVault().balanceOf(address(vault)); + vm.prank(generalManager); + vm.expectRevert(abi.encodeWithSelector(MasterVault.NonZeroSubVaultShares.selector, shares)); + vault.setSubVault(IERC4626(address(newSv))); + } +} diff --git a/test-foundry/libraries/vault/mutation/MasterVaultSetters.t.sol b/test-foundry/libraries/vault/mutation/MasterVaultSetters.t.sol new file mode 100644 index 000000000..1d9a4dece --- /dev/null +++ b/test-foundry/libraries/vault/mutation/MasterVaultSetters.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultSettersTest is MasterVaultCoreTest { + function test_setTargetAllocationWad_over100_reverts() public { + vm.prank(generalManager); + vm.expectRevert("Target allocation must be <= 100%"); + vault.setTargetAllocationWad(1e18 + 1); + } + + function test_setTargetAllocationWad_unchanged_reverts() public { + vault.setTargetAllocationWad(5e17); + vm.prank(generalManager); + vm.expectRevert("Allocation unchanged"); + vault.setTargetAllocationWad(5e17); + } + + function test_setMinimumRebalanceAmount_setsValue() public { + vault.setMinimumRebalanceAmount(42); + assertEq(vault.minimumRebalanceAmount(), 42, "minimumRebalanceAmount should be 42"); + } + + function test_setRebalanceCooldown_atMinimum_succeeds() public { + vault.setRebalanceCooldown(vault.MIN_REBALANCE_COOLDOWN()); + assertEq(vault.rebalanceCooldown(), vault.MIN_REBALANCE_COOLDOWN()); + } + + function test_setRebalanceCooldown_belowMinimum_reverts() public { + uint256 minimum = vault.MIN_REBALANCE_COOLDOWN(); + vm.prank(generalManager); + vm.expectRevert(abi.encodeWithSelector(MasterVault.RebalanceCooldownTooLow.selector, uint32(0), uint32(1))); + vault.setRebalanceCooldown(uint32(minimum - 1)); + } + + function test_setRebalanceCooldown_setsValue() public { + vault.setRebalanceCooldown(500); + assertEq(vault.rebalanceCooldown(), 500, "rebalanceCooldown should be 500"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol new file mode 100644 index 000000000..4649ef3e3 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario01.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario01Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: 2 users deposit and redeem with no profit/loss + /// User A deposits 100 USDC, User B deposits 300 USDC + /// User A redeems 100 shares, User B redeems 300 shares + /// Expected: All state variables return to 0, no user gains/losses + function test_scenario01_noGainNoLoss() public { + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + // Verify intermediate state + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + + // Step 3: User A redeems 100 shares + _redeem(userA, sharesA); + + // Step 4: User B redeems 300 shares + _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 0, "User B should have 0 shares"); + + // Verify user balances (no change) + _checkHoldings(userAInitialBalance, userBInitialBalance, 0); + } + + /// @dev Scenario: 2 users deposit and redeem with no profit/loss, 100% subvault allocation + /// User A deposits 100 USDC, User B deposits 300 USDC + /// User A redeems 100 shares, User B redeems 300 shares + /// Expected: All state variables return to 0 (except dead shares), assets moved through subvault + function test_scenario01_noGainNoLoss_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, // 100% allocated + masterVaultSubVaultShareBalance: 400, // 1:1 in DefaultSubVault + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + + // Step 3: User A redeems 100 shares + _redeem(userA, sharesA); + + // Step 4: User B redeems 300 shares + _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 0, "User B should have 0 shares"); + + // Verify user balances (no change) + _checkHoldings(userAInitialBalance, userBInitialBalance, 0); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol new file mode 100644 index 000000000..12370fec8 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario02.t.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario02Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: 2 users deposit, vault loses 100 USDC, users socialize losses + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault loses 100 USDC (25% loss) + /// User A redeems 100 shares, User B redeems 300 shares + /// Expected: Users socialize the loss proportionally (25% each) + function test_scenario02_socializeLosses() public { + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + // Verify intermediate state + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + + // Step 3: Vault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 4: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 5: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 0, "User B should have 0 shares"); + + // Verify user holdings change + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 0); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC (100 - 25)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); + } + + /// @dev Scenario: 2 users deposit, subvault loses 100 USDC, users socialize losses, 100% allocation + function test_scenario02_socializeLosses_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 400, + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + + // Step 3: Subvault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 4: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 5: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Verify user holdings change + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 0); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC (100 - 25)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol new file mode 100644 index 000000000..d95b0bdfa --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario03.t.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; + +contract MasterVaultScenario03Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: 2 users deposit, vault wins 100 USDC, beneficiary claims all profit + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault wins 100 USDC (25% profit) + /// Beneficiary claims 100 USDC + /// User A redeems 100 shares, User B redeems 300 shares + /// Expected: Users get back their initial deposit, beneficiary keeps profit + function test_scenario03_profitToBeneficiary() public { + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + // Verify intermediate state + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + + // Step 3: Vault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + // Step 5: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 6: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 0, "User B should have 0 shares"); + + // Verify user holdings change + _checkHoldings(userAInitialBalance, userBInitialBalance, 100); + + // Verify assets received + assertEq(assetsReceivedA, 100, "User A should receive 100 USDC"); + assertEq(assetsReceivedB, 300, "User B should receive 300 USDC"); + } + + /// @dev Scenario: 2 users deposit, subvault wins 100 USDC, beneficiary claims all profit, 100% allocation + function test_scenario03_profitToBeneficiary_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 400, + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + + // Step 3: Subvault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + // Step 5: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 6: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Verify user holdings change + _checkHoldings(userAInitialBalance, userBInitialBalance, 100); + + // Verify assets received + assertEq(assetsReceivedA, 100, "User A should receive 100 USDC"); + assertEq(assetsReceivedB, 300, "User B should receive 300 USDC"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol new file mode 100644 index 000000000..643c0f045 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario04.t.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; + +contract MasterVaultScenario04Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: Profit, fee claim, then User A and B deposit more before redemptions + /// Expected: Beneficiary keeps profits, users get back their total deposits + function test_scenario04_depositAfterProfit() public { + // Setup: Mint tokens for users (200 for A: 100+100, 600 for B: 300+300) + _mintTokens(userA, 200); + _mintTokens(userB, 600); + + // Step 1: User A deposits 100 USDC + uint256 sharesA1 = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB1 = _deposit(userB, 300); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + user = vm.addr(1); + + // Step 3: Vault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: User A deposits another 100 USDC + uint256 sharesA2 = _deposit(userA, 100); + + // Step 6: User B deposits another 300 USDC + uint256 sharesB2 = _deposit(userB, 300); + + // Step 7: User A redeems all 200 shares + uint256 assetsReceivedA = _redeem(userA, 200 * DEAD_SHARES); + + // Step 8: User B redeems all 600 shares + uint256 assetsReceivedB = _redeem(userB, 600 * DEAD_SHARES); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Verify user holdings change + _checkHoldings(userAInitialBalance, userBInitialBalance, 100); + + // Verify assets received + assertEq(assetsReceivedA, 200, "User A should receive 200 USDC"); + assertEq(assetsReceivedB, 600, "User B should receive 600 USDC"); + } + + /// @dev Scenario: Profit, fee claim, then User A and B deposit more before redemptions, 100% allocation + function test_scenario04_depositAfterProfit_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 200); + _mintTokens(userB, 600); + + // Step 1: User A deposits 100 USDC + uint256 sharesA1 = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB1 = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Step 3: Subvault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: User A deposits another 100 USDC + uint256 sharesA2 = _deposit(userA, 100); + + // Step 6: User B deposits another 300 USDC + uint256 sharesB2 = _deposit(userB, 300); + + // Step 7: User A redeems all 200 shares + uint256 assetsReceivedA = _redeem(userA, 200 * DEAD_SHARES); + + // Step 8: User B redeems all 600 shares + uint256 assetsReceivedB = _redeem(userB, 600 * DEAD_SHARES); + + // Verify final state + _checkHoldings(userAInitialBalance, userBInitialBalance, 100); + assertEq(assetsReceivedA, 200, "User A should receive 200 USDC"); + assertEq(assetsReceivedB, 600, "User B should receive 600 USDC"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol new file mode 100644 index 000000000..0e910995e --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario05.t.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; + +contract MasterVaultScenario05Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: Profit, fee claim, more deposits, redemptions, then loss and final redemptions + /// Expected: Beneficiary keeps profits, users share final loss + function test_scenario05_profitThenLoss() public { + // Setup: Mint tokens for users (200 for A: 100+100, 600 for B: 300+300) + _mintTokens(userA, 200); + _mintTokens(userB, 600); + + // Step 1: User A deposits 100 USDC + uint256 sharesA1 = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB1 = _deposit(userB, 300); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + user = vm.addr(1); + + // Step 3: Vault wins 100 USDC + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Claim fees + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: User A deposits another 100 USDC + _deposit(userA, 100); + + // Step 6: User B deposits another 300 USDC + _deposit(userB, 300); + + // Verify intermediate state 2 + user = userA; + _checkState( + State({ + userShares: 200 * DEAD_SHARES, + masterVaultTotalAssets: 801, + masterVaultTotalSupply: 801 * DEAD_SHARES, + masterVaultTokenBalance: 800, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + user = vm.addr(1); + + // Step 7: User A redeems 200 shares + _redeem(userA, 200 * DEAD_SHARES); + + // Step 8: User B redeems 600 shares + _redeem(userB, 600 * DEAD_SHARES); + + // Verify intermediate state 3 (empty vault) + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Step 9: User A deposits 100 USDC + _deposit(userA, 100); + + // Step 10: User B deposits 300 USDC + _deposit(userB, 300); + + // Verify intermediate state 4 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + user = vm.addr(1); + + // Step 11: Vault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 12: User A redeems 100 shares + uint256 assetsReceivedA = _redeem(userA, 100 * DEAD_SHARES); + + // Step 13: User B redeems 300 shares + uint256 assetsReceivedB = _redeem(userB, 300 * DEAD_SHARES); + + // Verify final state + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC"); + } + + /// @dev Scenario: Profit, fee claim, more deposits, redemptions, then loss and final redemptions, 100% allocation + function test_scenario05_profitThenLoss_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 200); + _mintTokens(userB, 600); + + // Step 1: User A deposits 100 USDC + _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Step 3: Subvault wins 100 USDC + _simulateProfit(100); + + // Step 4: Claim fees + _distributePerformanceFee(); + + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: User A deposits another 100 USDC + _deposit(userA, 100); + + // Step 6: User B deposits another 300 USDC + _deposit(userB, 300); + + vm.warp(block.timestamp + 2); + vault.rebalance(type(int256).min + 1); + + // Step 7: User A redeems 200 shares + _redeem(userA, 200 * DEAD_SHARES); + + // Step 8: User B redeems 600 shares + _redeem(userB, 600 * DEAD_SHARES); + + // Step 9: User A deposits 100 USDC + _deposit(userA, 100); + + // Step 10: User B deposits 300 USDC + _deposit(userB, 300); + + vm.warp(block.timestamp + 2); + vault.rebalance(type(int256).min + 1); + + // Step 11: Subvault loses 100 USDC (25% loss) + _simulateLoss(100); + + // Step 12: User A redeems 100 shares + uint256 assetsReceivedA = _redeem(userA, 100 * DEAD_SHARES); + + // Step 13: User B redeems 300 shares + uint256 assetsReceivedB = _redeem(userB, 300 * DEAD_SHARES); + + // Verify final state + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol new file mode 100644 index 000000000..7d7e10407 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario06.t.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; + +contract MasterVaultScenario06Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: Profit claim, then loss, then full redemptions + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault wins 100 USDC (25% profit) + /// Beneficiary claims 100 USDC + /// Vault loses 100 USDC (25% loss) + /// User A redeems all shares, User B redeems all shares + /// Expected: Beneficiary keeps profit, users socialize the loss + function test_scenario06_profitClaimThenLoss() public { + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + user = vm.addr(1); + + // Step 3: Vault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: Vault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 6: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 7: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 0, "User B should have 0 shares"); + + // Verify user holdings change + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC (100 - 25)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); + } + + /// @dev Scenario: Profit claim, then loss, then full redemptions, 100% allocation + function test_scenario06_profitClaimThenLoss_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 400, + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + user = vm.addr(1); + + // Step 3: Subvault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: Subvault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 6: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 7: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 0, "User B should have 0 shares"); + + // Verify user holdings change + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + + // Verify assets received + assertEq(assetsReceivedA, 75, "User A should receive 75 USDC (100 - 25)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol new file mode 100644 index 000000000..f804823ac --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario07.t.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; + +contract MasterVaultScenario07Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: Profit claim, loss, full redemptions, then new deposit and redemption + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault wins 100 USDC (25% profit) + /// Beneficiary claims 100 USDC + /// Vault loses 100 USDC (25% loss) + /// User A redeems all shares, User B redeems all shares + /// User A deposits 100 USDC again + /// User A redeems all shares again + /// Expected: Vault works correctly after full cycle + function test_scenario07_profitLossThenNewDeposit() public { + // Setup: Mint tokens for users (200 for A: 100+100) + _mintTokens(userA, 200); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA1 = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + user = vm.addr(1); + + // Step 3: Vault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: Vault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 6: User A redeems all shares + uint256 assetsReceivedA1 = _redeem(userA, sharesA1); + + // Step 7: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify intermediate state 2 (empty vault) + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Verify first redemption amounts + assertEq(assetsReceivedA1, 75, "User A should receive 75 USDC (100 - 25)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); + + // Step 8: User A deposits 100 USDC again + uint256 sharesA2 = _deposit(userA, 100); + + // Verify intermediate state 3 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 101, + masterVaultTotalSupply: 101 * DEAD_SHARES, + masterVaultTokenBalance: 100, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + user = vm.addr(1); + + // Step 9: User A redeems all shares again + uint256 assetsReceivedA2 = _redeem(userA, sharesA2); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Verify final user holdings + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + + // Verify second redemption amount + assertEq(assetsReceivedA2, 100, "User A should receive 100 USDC on second redemption"); + } + + /// @dev Scenario: Profit claim, loss, full redemptions, then new deposit and redemption, 100% allocation + function test_scenario07_profitLossThenNewDeposit_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users (200 for A: 100+100) + _mintTokens(userA, 200); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA1 = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 400, + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B shares mismatch"); + user = vm.addr(1); + + // Step 3: Subvault wins 100 USDC (25% profit) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: Subvault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 6: User A redeems all shares + uint256 assetsReceivedA1 = _redeem(userA, sharesA1); + + // Step 7: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify intermediate state 2 (empty vault) + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Verify first redemption amounts + assertEq(assetsReceivedA1, 75, "User A should receive 75 USDC (100 - 25)"); + assertEq(assetsReceivedB, 225, "User B should receive 225 USDC (300 - 75)"); + + // Step 8: User A deposits 100 USDC again + uint256 sharesA2 = _deposit(userA, 100); + + vm.warp(block.timestamp + 2); + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state 3 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 101, + masterVaultTotalSupply: 101 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 100, + subVaultTotalAssets: 100, + subVaultTotalSupply: 100, + subVaultTokenBalance: 100 + }) + ); + user = vm.addr(1); + + // Step 9: User A redeems all shares again + uint256 assetsReceivedA2 = _redeem(userA, sharesA2); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Verify final user holdings + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + + // Verify second redemption amount + assertEq(assetsReceivedA2, 100, "User A should receive 100 USDC on second redemption"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol new file mode 100644 index 000000000..1fcaf629d --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario08.t.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; + +contract MasterVaultScenario08Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: Profit claim, loss, then additional deposit before redemptions + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault wins 100 USDC + /// Beneficiary claims 100 USDC + /// Vault loses 100 USDC (25% loss) + /// User B deposits 300 USDC more + /// User A redeems all shares + /// User B redeems all shares + /// Expected: User B gets better price on second deposit due to loss, both users share final state + function test_scenario08_depositAfterLoss() public { + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 600); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB1 = _deposit(userB, 300); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B initial shares mismatch"); + user = vm.addr(1); + + // Step 3: Vault wins 100 USDC + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: Vault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 6: User B deposits 300 USDC more (at discounted share price due to loss) + uint256 sharesB2 = _deposit(userB, 300); + + // Calculate expected shares for second deposit + // After loss, totalAssets = 301, totalSupply = 401 * DEAD_SHARES + // shares = 300 * 401 * DEAD_SHARES / 301 ≈ 399.67 * DEAD_SHARES + uint256 expectedSharesB2 = (300 * 401 * DEAD_SHARES) / 301; + assertEq(sharesB2, expectedSharesB2, "User B second deposit shares mismatch"); + + uint256 totalSharesB = sharesB1 + sharesB2; + assertEq(vault.balanceOf(userB), totalSharesB, "User B total shares mismatch"); + + // Verify intermediate state 2 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 601, + masterVaultTotalSupply: (401 * DEAD_SHARES) + expectedSharesB2, + masterVaultTokenBalance: 600, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + user = vm.addr(1); + + // Step 7: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 8: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, totalSharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Calculate expected redemption amounts based on share proportions + uint256 totalSupply = (401 * DEAD_SHARES) + expectedSharesB2; + uint256 expectedAssetsA = (601 * sharesA) / totalSupply; + uint256 expectedAssetsB = (601 * totalSharesB) / totalSupply; + + // Verify user holdings + assertEq(assetsReceivedA, expectedAssetsA, "User A redemption mismatch"); + assertEq(assetsReceivedB, expectedAssetsB, "User B redemption mismatch"); + + // Verify exact final holdings + assertEq(assetsReceivedA, 75, "User A should receive exactly 75 USDC"); + assertEq(assetsReceivedB, 525, "User B should receive exactly 525 USDC"); + + // Verify losses/gains + assertEq(100 - assetsReceivedA, 25, "User A should lose exactly 25 USDC"); + assertEq(600 - assetsReceivedB, 75, "User B should lose exactly 75 USDC"); + assertEq( + token.balanceOf(beneficiaryAddress), 100, "Beneficiary should gain exactly 100 USDC" + ); + + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + } + + /// @dev Scenario: Profit claim, loss, then additional deposit before redemptions, 100% allocation + function test_scenario08_depositAfterLoss_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 600); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB1 = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 400, + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + assertEq(vault.balanceOf(userB), 300 * DEAD_SHARES, "User B initial shares mismatch"); + user = vm.addr(1); + + // Step 3: Subvault wins 100 USDC + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 5: Subvault loses 100 USDC (25% loss) + _simulateLoss(100); + + assertEq(vault.totalAssets(), 301, "Vault should have 301 USDC after loss"); + + // Step 6: User B deposits 300 USDC more + uint256 sharesB2 = _deposit(userB, 300); + + vm.warp(block.timestamp + 2); + vault.rebalance(type(int256).min + 1); + + // Calculate expected shares for second deposit + // After loss, totalAssets = 301, totalSupply = 401 * DEAD_SHARES + // shares = 300 * 401 * DEAD_SHARES / 301 ≈ 399.67 * DEAD_SHARES + uint256 expectedSharesB2 = (300 * 401 * DEAD_SHARES) / 301; + assertEq(sharesB2, expectedSharesB2, "User B second deposit shares mismatch"); + + uint256 totalSharesB = sharesB1 + sharesB2; + assertEq(vault.balanceOf(userB), totalSharesB, "User B total shares mismatch"); + + // Step 7: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 8: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, totalSharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Calculate expected redemption amounts based on share proportions + uint256 totalSupply = (401 * DEAD_SHARES) + expectedSharesB2; + uint256 expectedAssetsA = (601 * sharesA) / totalSupply; + uint256 expectedAssetsB = (601 * totalSharesB) / totalSupply; + + // Verify redemption amounts + assertEq(assetsReceivedA, expectedAssetsA, "User A redemption mismatch"); + assertEq(assetsReceivedB, expectedAssetsB, "User B redemption mismatch"); + + // Verify exact final holdings + assertEq(assetsReceivedA, 75, "User A should receive exactly 75 USDC"); + assertEq(assetsReceivedB, 525, "User B should receive exactly 525 USDC"); + + // Verify losses/gains + assertEq(100 - assetsReceivedA, 25, "User A should lose exactly 25 USDC"); + assertEq(600 - assetsReceivedB, 75, "User B should lose exactly 75 USDC"); + assertEq( + token.balanceOf(beneficiaryAddress), 100, "Beneficiary should gain exactly 100 USDC" + ); + + _checkHoldings(userAInitialBalance - 25, userBInitialBalance - 75, 100); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol new file mode 100644 index 000000000..f5f6d7835 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario09.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario09Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: Multiple profit claims + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault wins 50 USDC → beneficiary claims 50 + /// Vault wins 50 USDC more → beneficiary claims another 50 + /// Users redeem all + /// Expected: Multiple fee distributions work correctly, users get back principal + function test_scenario09_multipleProfitClaims() public { + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + user = vm.addr(1); + + // Step 3: Vault wins 50 USDC + _simulateProfit(50); + + assertEq(vault.totalAssets(), 451, "Vault should have 451 USDC after first profit"); + assertEq(vault.totalProfit(), 50, "Total profit should be 50 USDC"); + + // Step 4: Beneficiary claims first 50 USDC + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after first fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 50, "Beneficiary should have 50 USDC"); + assertEq(vault.totalProfit(), 0, "Profit should be 0 after claim"); + + vm.stopPrank(); + + // Step 5: Vault wins 50 USDC more + _simulateProfit(50); + + assertEq(vault.totalAssets(), 451, "Vault should have 451 USDC after second profit"); + assertEq(vault.totalProfit(), 50, "Total profit should be 50 USDC again"); + + // Step 6: Beneficiary claims second 50 USDC + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after second fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC total"); + assertEq(vault.totalProfit(), 0, "Profit should be 0 after second claim"); + + vm.stopPrank(); + + // Step 7: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 8: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Users should get back their principal + assertEq(assetsReceivedA, 100, "User A should receive 100 USDC"); + assertEq(assetsReceivedB, 300, "User B should receive 300 USDC"); + + // Verify final holdings (no loss, beneficiary got all profit) + _checkHoldings(userAInitialBalance, userBInitialBalance, 100); + } + + /// @dev Scenario: Multiple profit claims, 100% allocation + function test_scenario09_multipleProfitClaims_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 400, + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + user = vm.addr(1); + + // Step 3: Subvault wins 50 USDC + _simulateProfit(50); + + assertEq(vault.totalAssets(), 451, "Vault should have 451 USDC after first profit"); + assertEq(vault.totalProfit(), 50, "Total profit should be 50 USDC"); + + // Step 4: Beneficiary claims first 50 USDC + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after first fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 50, "Beneficiary should have 50 USDC"); + + vm.stopPrank(); + + // Step 5: Subvault wins 50 USDC more + _simulateProfit(50); + + assertEq(vault.totalAssets(), 451, "Vault should have 451 USDC after second profit"); + assertEq(vault.totalProfit(), 50, "Total profit should be 50 USDC again"); + + // Step 6: Beneficiary claims second 50 USDC + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 401, "Vault should have 401 USDC after second fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC total"); + + vm.stopPrank(); + + // Step 7: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 8: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // Users should get back their principal + assertEq(assetsReceivedA, 100, "User A should receive 100 USDC"); + assertEq(assetsReceivedB, 300, "User B should receive 300 USDC"); + + // Verify final holdings (no loss, beneficiary got all profit) + _checkHoldings(userAInitialBalance, userBInitialBalance, 100); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol new file mode 100644 index 000000000..a1a536a82 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario10.t.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; +import {MasterVault} from "../../../../contracts/tokenbridge/libraries/vault/MasterVault.sol"; + +contract MasterVaultScenario10Test is MasterVaultScenarioCoreTest { + address public userC = address(0xC); + uint256 public userCInitialBalance; + + /// @dev Scenario: Deposit during unrealized profit (before claim) + /// User A deposits 100 USDC, User B deposits 300 USDC + /// Vault wins 100 USDC (profit exists but not claimed yet) + /// User C deposits 100 USDC + /// Beneficiary claims profit + /// All users redeem + /// Expected: New depositor pays fair price for unrealized profits, all get back what they deposited + function test_scenario10_depositDuringUnrealizedProfit() public { + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + vm.prank(userC); + token.mintAmount(100); + userCInitialBalance = token.balanceOf(userC); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 400, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + user = vm.addr(1); + + // Step 3: Vault wins 100 USDC (profit not claimed yet) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: User C deposits 100 USDC during unrealized profit + // With perf fees on, C should get shares based on principal (401), not total assets (501) + // This protects C from paying for unrealized profits + uint256 sharesC = _deposit(userC, 100); + + // C should get 100 * DEAD_SHARES because they deposit at principal value + assertEq(sharesC, 100 * DEAD_SHARES, "User C should get 100 shares at principal price"); + + // After C's deposit + assertEq(vault.totalAssets(), 601, "Vault should have 601 USDC total"); + assertEq(vault.totalSupply(), 501 * DEAD_SHARES, "Total supply should be 501 shares"); + assertEq(vault.totalProfit(), 100, "Profit should still be 100 USDC"); + + // Step 5: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + assertEq(vault.totalProfit(), 0, "Profit should be 0 after claim"); + + vm.stopPrank(); + + // Step 6: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 7: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Step 8: User C redeems all shares + vm.prank(userC); + vault.transfer(user, sharesC); + + vm.startPrank(user); + uint256 assetsReceivedC = vault.redeem(sharesC, 0); + token.transfer(userC, assetsReceivedC); + vm.stopPrank(); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // All users should get back what they deposited (no loss for anyone) + assertEq(assetsReceivedA, 100, "User A should receive 100 USDC"); + assertEq(assetsReceivedB, 300, "User B should receive 300 USDC"); + assertEq(assetsReceivedC, 100, "User C should receive 100 USDC"); + + // Verify final holdings + assertEq(token.balanceOf(userA), userAInitialBalance, "User A balance should be unchanged"); + assertEq(token.balanceOf(userB), userBInitialBalance, "User B balance should be unchanged"); + assertEq(token.balanceOf(userC), userCInitialBalance, "User C balance should be unchanged"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + } + + /// @dev Scenario: Deposit during unrealized profit (before claim), 100% allocation + function test_scenario10_depositDuringUnrealizedProfit_100PercentAllocation() public { + // Set target allocation to 100% + vault.setTargetAllocationWad(1e18); + + // Setup: Mint tokens for users + _mintTokens(userA, 100); + _mintTokens(userB, 300); + vm.prank(userC); + token.mintAmount(100); + userCInitialBalance = token.balanceOf(userC); + + // Step 1: User A deposits 100 USDC + uint256 sharesA = _deposit(userA, 100); + + // Step 2: User B deposits 300 USDC + uint256 sharesB = _deposit(userB, 300); + + vault.rebalance(type(int256).min + 1); + + // Verify intermediate state 1 + user = userA; + _checkState( + State({ + userShares: 100 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 400, + subVaultTotalAssets: 400, + subVaultTotalSupply: 400, + subVaultTokenBalance: 400 + }) + ); + user = vm.addr(1); + + // Step 3: Subvault wins 100 USDC (profit not claimed yet) + _simulateProfit(100); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after profit"); + assertEq(vault.totalProfit(), 100, "Total profit should be 100 USDC"); + + // Step 4: User C deposits 100 USDC during unrealized profit + uint256 sharesC = _deposit(userC, 100); + + vm.warp(block.timestamp + 2); + vault.rebalance(type(int256).min + 1); + + // C should get 100 * DEAD_SHARES because they deposit at principal value + assertEq(sharesC, 100 * DEAD_SHARES, "User C should get 100 shares at principal price"); + + // After C's deposit and rebalance + assertEq(vault.totalAssets(), 601, "Vault should have 601 USDC total"); + assertEq(vault.totalSupply(), 501 * DEAD_SHARES, "Total supply should be 501 shares"); + + // Step 5: Beneficiary claims profit + _distributePerformanceFee(); + + assertEq(vault.totalAssets(), 501, "Vault should have 501 USDC after fee withdrawal"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + + vm.stopPrank(); + + // Step 6: User A redeems all shares + uint256 assetsReceivedA = _redeem(userA, sharesA); + + // Step 7: User B redeems all shares + uint256 assetsReceivedB = _redeem(userB, sharesB); + + // Step 8: User C redeems all shares + vm.prank(userC); + vault.transfer(user, sharesC); + + vm.startPrank(user); + uint256 assetsReceivedC = vault.redeem(sharesC, 0); + token.transfer(userC, assetsReceivedC); + vm.stopPrank(); + + // Verify final state + _checkState( + State({ + userShares: 0, + masterVaultTotalAssets: 1, + masterVaultTotalSupply: DEAD_SHARES, + masterVaultTokenBalance: 0, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + // All users should get back what they deposited + assertEq(assetsReceivedA, 100, "User A should receive 100 USDC"); + assertEq(assetsReceivedB, 300, "User B should receive 300 USDC"); + assertEq(assetsReceivedC, 100, "User C should receive 100 USDC"); + + // Verify final holdings + assertEq(token.balanceOf(userA), userAInitialBalance, "User A balance should be unchanged"); + assertEq(token.balanceOf(userB), userBInitialBalance, "User B balance should be unchanged"); + assertEq(token.balanceOf(userC), userCInitialBalance, "User C balance should be unchanged"); + assertEq(token.balanceOf(beneficiaryAddress), 100, "Beneficiary should have 100 USDC"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenario11.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenario11.t.sol new file mode 100644 index 000000000..09f94af49 --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenario11.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultScenarioCoreTest} from "./MasterVaultScenarioCore.t.sol"; + +/// This test proofs that rebalance withdraws all assets including profit from the subvault. +contract MasterVaultScenario11Test is MasterVaultScenarioCoreTest { + /// @dev Scenario: 50% allocation, sub vault doubles in value, then allocation set to 0% + /// User A deposits 400, 50% allocated to sub vault, sub vault doubles (100% profit) + /// Allocation changed to 0%, rebalance withdraws all assets including profit + function test_scenario11_profitWithdrawnFromSubVault() public { + vault.setTargetAllocationWad(0.5e18); + + _mintTokens(userA, 400); + _deposit(userA, 400); + + // rebalance to 50% alloc + vault.rebalance(type(int256).min + 1); + + user = userA; + _checkState( + State({ + userShares: 400 * DEAD_SHARES, + masterVaultTotalAssets: 401, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 201, + masterVaultSubVaultShareBalance: 199, + subVaultTotalAssets: 199, + subVaultTotalSupply: 199, + subVaultTokenBalance: 199 + }) + ); + + // subvault doubles in value + _simulateProfit(199); + + assertEq(vault.subVault().totalAssets(), 398, "Sub vault should have doubled"); + assertEq(vault.totalAssets(), 600, "Total assets should be 600"); + assertEq(vault.totalProfit(), 199, "Profit should be 199"); + + // change allocation to 0% and rebalance + vault.setTargetAllocationWad(0); + vm.warp(block.timestamp + 2); + vault.rebalance(0); + + // all assets withdrawn from subvault including profit + assertEq( + vault.subVault().balanceOf(address(vault)), 0, "Vault should hold no sub vault shares" + ); + + _checkState( + State({ + userShares: 400 * DEAD_SHARES, + masterVaultTotalAssets: 600, + masterVaultTotalSupply: 401 * DEAD_SHARES, + masterVaultTokenBalance: 599, + masterVaultSubVaultShareBalance: 0, + subVaultTotalAssets: 0, + subVaultTotalSupply: 0, + subVaultTokenBalance: 0 + }) + ); + + assertEq(token.balanceOf(address(vault.subVault())), 0, "Sub vault should have no tokens"); + } +} diff --git a/test-foundry/libraries/vault/scenarios/MasterVaultScenarioCore.t.sol b/test-foundry/libraries/vault/scenarios/MasterVaultScenarioCore.t.sol new file mode 100644 index 000000000..14de7e15d --- /dev/null +++ b/test-foundry/libraries/vault/scenarios/MasterVaultScenarioCore.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {MasterVaultCoreTest} from "../MasterVaultCore.t.sol"; + +contract MasterVaultScenarioCoreTest is MasterVaultCoreTest { + address public userA = address(0xA); + address public userB = address(0xB); + address public beneficiaryAddress = address(0x9999); + uint256 public userAInitialBalance; + uint256 public userBInitialBalance; + + function setUp() public virtual override { + super.setUp(); + vault.rolesRegistry().grantRole(vault.GENERAL_MANAGER_ROLE(), address(this)); + vault.rolesRegistry().grantRole(vault.FEE_MANAGER_ROLE(), address(this)); + vault.rolesRegistry().grantRole(vault.KEEPER_ROLE(), address(this)); + vault.setMinimumRebalanceAmount(1); + vault.setBeneficiary(beneficiaryAddress); + } + + function _mintTokens(address _user, uint256 _amount) internal { + vm.prank(_user); + token.mintAmount(_amount); + if (_user == userA) userAInitialBalance = token.balanceOf(userA); + if (_user == userB) userBInitialBalance = token.balanceOf(userB); + } + + function _deposit(address _user, uint256 _amount) internal returns (uint256) { + vm.prank(_user); + token.transfer(user, _amount); + + vm.startPrank(user); + token.approve(address(vault), _amount); + uint256 shares = vault.deposit(_amount); + vault.transfer(_user, shares); + vm.stopPrank(); + + return shares; + } + + function _redeem(address _user, uint256 _shares) internal returns (uint256) { + vm.prank(_user); + vault.transfer(user, _shares); + + vm.startPrank(user); + uint256 assets = vault.redeem(_shares, 0); + token.transfer(_user, assets); + vm.stopPrank(); + + return assets; + } + + function _simulateProfit(uint256 _amount) internal { + token.mintAmount(_amount); + if (vault.targetAllocationWad() > 0) { + token.transfer(address(vault.subVault()), _amount); + } else { + token.transfer(address(vault), _amount); + } + } + + function _simulateLoss(uint256 _amount) internal { + if (vault.targetAllocationWad() > 0) { + vm.prank(address(vault.subVault())); + } else { + vm.prank(address(vault)); + } + token.transfer(address(0xdead), _amount); + } + + function _distributePerformanceFee() internal { + vault.distributePerformanceFee(); + } + + function _checkHoldings(uint256 _expectedA, uint256 _expectedB, uint256 _expectedBeneficiary) + internal + { + assertEq(token.balanceOf(userA), _expectedA, "User A balance mismatch"); + assertEq(token.balanceOf(userB), _expectedB, "User B balance mismatch"); + assertEq( + token.balanceOf(beneficiaryAddress), + _expectedBeneficiary, + "Beneficiary balance mismatch" + ); + } +} diff --git a/test/signatures/DefaultSubVault b/test/signatures/DefaultSubVault new file mode 100644 index 000000000..a4d6b4727 --- /dev/null +++ b/test/signatures/DefaultSubVault @@ -0,0 +1,61 @@ + +╭---------------------------------------+------------╮ +| Method | Identifier | ++====================================================+ +| allowance(address,address) | dd62ed3e | +|---------------------------------------+------------| +| approve(address,uint256) | 095ea7b3 | +|---------------------------------------+------------| +| asset() | 38d52e0f | +|---------------------------------------+------------| +| balanceOf(address) | 70a08231 | +|---------------------------------------+------------| +| convertToAssets(uint256) | 07a2d13a | +|---------------------------------------+------------| +| convertToShares(uint256) | c6e6f592 | +|---------------------------------------+------------| +| decimals() | 313ce567 | +|---------------------------------------+------------| +| decreaseAllowance(address,uint256) | a457c2d7 | +|---------------------------------------+------------| +| deposit(uint256,address) | 6e553f65 | +|---------------------------------------+------------| +| increaseAllowance(address,uint256) | 39509351 | +|---------------------------------------+------------| +| masterVault() | 3b7e3775 | +|---------------------------------------+------------| +| maxDeposit(address) | 402d267d | +|---------------------------------------+------------| +| maxMint(address) | c63d75b6 | +|---------------------------------------+------------| +| maxRedeem(address) | d905777e | +|---------------------------------------+------------| +| maxWithdraw(address) | ce96cb77 | +|---------------------------------------+------------| +| mint(uint256,address) | 94bf804d | +|---------------------------------------+------------| +| name() | 06fdde03 | +|---------------------------------------+------------| +| previewDeposit(uint256) | ef8b30f7 | +|---------------------------------------+------------| +| previewMint(uint256) | b3d7f6b9 | +|---------------------------------------+------------| +| previewRedeem(uint256) | 4cdad506 | +|---------------------------------------+------------| +| previewWithdraw(uint256) | 0a28a477 | +|---------------------------------------+------------| +| redeem(uint256,address,address) | ba087652 | +|---------------------------------------+------------| +| symbol() | 95d89b41 | +|---------------------------------------+------------| +| totalAssets() | 01e1d114 | +|---------------------------------------+------------| +| totalSupply() | 18160ddd | +|---------------------------------------+------------| +| transfer(address,uint256) | a9059cbb | +|---------------------------------------+------------| +| transferFrom(address,address,uint256) | 23b872dd | +|---------------------------------------+------------| +| withdraw(uint256,address,address) | b460af94 | +╰---------------------------------------+------------╯ + diff --git a/test/signatures/L1AtomicTokenBridgeCreator b/test/signatures/L1AtomicTokenBridgeCreator index 46c782076..a333ef394 100644 --- a/test/signatures/L1AtomicTokenBridgeCreator +++ b/test/signatures/L1AtomicTokenBridgeCreator @@ -6,6 +6,8 @@ |-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| | createTokenBridge(address,address,uint256,uint256) | 8277742b | |-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| +| createYbbTokenBridge(address,address,uint256,uint256) | 40db2e1c | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| | gasLimitForL2FactoryDeployment() | 888139d4 | |-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| | getRouter(address) | 8369166d | @@ -46,6 +48,10 @@ |-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| | setTemplates((address,address,address,address,address,address,address,address),address,address,address,address,address,address,address,address,address,uint256) | 81fb9184 | |-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| +| setYbbTemplates((address,address,address,address,address,address)) | ea011123 | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| | transferOwnership(address) | f2fde38b | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------| +| ybbL1Templates() | f6d679e9 | ╰-----------------------------------------------------------------------------------------------------------------------------------------------------------------+------------╯ diff --git a/test/signatures/L1OrbitYbbCustomGateway b/test/signatures/L1OrbitYbbCustomGateway new file mode 100644 index 000000000..6ae246e7b --- /dev/null +++ b/test/signatures/L1OrbitYbbCustomGateway @@ -0,0 +1,59 @@ + +╭-------------------------------------------------------------------------------------+------------╮ +| Method | Identifier | ++==================================================================================================+ +| calculateL2TokenAddress(address) | a7e28d48 | +|-------------------------------------------------------------------------------------+------------| +| counterpartGateway() | 2db09c1c | +|-------------------------------------------------------------------------------------+------------| +| encodeWithdrawal(uint256,address) | 020a6058 | +|-------------------------------------------------------------------------------------+------------| +| finalizeInboundTransfer(address,address,address,uint256,bytes) | 2e567b36 | +|-------------------------------------------------------------------------------------+------------| +| forceRegisterTokenToL2(address[],address[],uint256,uint256,uint256) | 1d3a689f | +|-------------------------------------------------------------------------------------+------------| +| forceRegisterTokenToL2(address[],address[],uint256,uint256,uint256,uint256) | 85f25597 | +|-------------------------------------------------------------------------------------+------------| +| getExternalCall(uint256,address,bytes) | f68a9082 | +|-------------------------------------------------------------------------------------+------------| +| getOutboundCalldata(address,address,address,uint256,bytes) | a0c76a96 | +|-------------------------------------------------------------------------------------+------------| +| inbox() | fb0e722b | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,address) | f8c8765e | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,address,address) | 1459457a | +|-------------------------------------------------------------------------------------+------------| +| l1ToL2Token(address) | 8a2dc014 | +|-------------------------------------------------------------------------------------+------------| +| masterVaultFactory() | 48aa651c | +|-------------------------------------------------------------------------------------+------------| +| outboundTransfer(address,address,uint256,uint256,uint256,bytes) | d2ce7d65 | +|-------------------------------------------------------------------------------------+------------| +| outboundTransferCustomRefund(address,address,address,uint256,uint256,uint256,bytes) | 4fb1a07b | +|-------------------------------------------------------------------------------------+------------| +| owner() | 8da5cb5b | +|-------------------------------------------------------------------------------------+------------| +| postUpgradeInit() | 95fcea78 | +|-------------------------------------------------------------------------------------+------------| +| redirectedExits(bytes32) | bcf2e6eb | +|-------------------------------------------------------------------------------------+------------| +| registerTokenToL2(address,uint256,uint256,uint256) | f26bdead | +|-------------------------------------------------------------------------------------+------------| +| registerTokenToL2(address,uint256,uint256,uint256,address) | ca346d4a | +|-------------------------------------------------------------------------------------+------------| +| registerTokenToL2(address,uint256,uint256,uint256,address,uint256) | 37daacad | +|-------------------------------------------------------------------------------------+------------| +| registerTokenToL2(address,uint256,uint256,uint256,uint256) | 3e8ee3df | +|-------------------------------------------------------------------------------------+------------| +| router() | f887ea40 | +|-------------------------------------------------------------------------------------+------------| +| setOwner(address) | 13af4035 | +|-------------------------------------------------------------------------------------+------------| +| supportsInterface(bytes4) | 01ffc9a7 | +|-------------------------------------------------------------------------------------+------------| +| transferExitAndCall(uint256,address,address,bytes,bytes) | bd5f3e7d | +|-------------------------------------------------------------------------------------+------------| +| whitelist() | 93e59dc1 | +╰-------------------------------------------------------------------------------------+------------╯ + diff --git a/test/signatures/L1OrbitYbbERC20Gateway b/test/signatures/L1OrbitYbbERC20Gateway new file mode 100644 index 000000000..4eee3548b --- /dev/null +++ b/test/signatures/L1OrbitYbbERC20Gateway @@ -0,0 +1,45 @@ + +╭-------------------------------------------------------------------------------------+------------╮ +| Method | Identifier | ++==================================================================================================+ +| calculateL2TokenAddress(address) | a7e28d48 | +|-------------------------------------------------------------------------------------+------------| +| cloneableProxyHash() | 97881f8d | +|-------------------------------------------------------------------------------------+------------| +| counterpartGateway() | 2db09c1c | +|-------------------------------------------------------------------------------------+------------| +| encodeWithdrawal(uint256,address) | 020a6058 | +|-------------------------------------------------------------------------------------+------------| +| finalizeInboundTransfer(address,address,address,uint256,bytes) | 2e567b36 | +|-------------------------------------------------------------------------------------+------------| +| getExternalCall(uint256,address,bytes) | f68a9082 | +|-------------------------------------------------------------------------------------+------------| +| getOutboundCalldata(address,address,address,uint256,bytes) | a0c76a96 | +|-------------------------------------------------------------------------------------+------------| +| inbox() | fb0e722b | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,bytes32,address) | a01893bf | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,bytes32,address,address) | b54eea9c | +|-------------------------------------------------------------------------------------+------------| +| l2BeaconProxyFactory() | 70fc045f | +|-------------------------------------------------------------------------------------+------------| +| masterVaultFactory() | 48aa651c | +|-------------------------------------------------------------------------------------+------------| +| outboundTransfer(address,address,uint256,uint256,uint256,bytes) | d2ce7d65 | +|-------------------------------------------------------------------------------------+------------| +| outboundTransferCustomRefund(address,address,address,uint256,uint256,uint256,bytes) | 4fb1a07b | +|-------------------------------------------------------------------------------------+------------| +| postUpgradeInit() | 95fcea78 | +|-------------------------------------------------------------------------------------+------------| +| redirectedExits(bytes32) | bcf2e6eb | +|-------------------------------------------------------------------------------------+------------| +| router() | f887ea40 | +|-------------------------------------------------------------------------------------+------------| +| supportsInterface(bytes4) | 01ffc9a7 | +|-------------------------------------------------------------------------------------+------------| +| transferExitAndCall(uint256,address,address,bytes,bytes) | bd5f3e7d | +|-------------------------------------------------------------------------------------+------------| +| whitelist() | 93e59dc1 | +╰-------------------------------------------------------------------------------------+------------╯ + diff --git a/test/signatures/L1YbbCustomGateway b/test/signatures/L1YbbCustomGateway new file mode 100644 index 000000000..055f5b210 --- /dev/null +++ b/test/signatures/L1YbbCustomGateway @@ -0,0 +1,53 @@ + +╭-------------------------------------------------------------------------------------+------------╮ +| Method | Identifier | ++==================================================================================================+ +| calculateL2TokenAddress(address) | a7e28d48 | +|-------------------------------------------------------------------------------------+------------| +| counterpartGateway() | 2db09c1c | +|-------------------------------------------------------------------------------------+------------| +| encodeWithdrawal(uint256,address) | 020a6058 | +|-------------------------------------------------------------------------------------+------------| +| finalizeInboundTransfer(address,address,address,uint256,bytes) | 2e567b36 | +|-------------------------------------------------------------------------------------+------------| +| forceRegisterTokenToL2(address[],address[],uint256,uint256,uint256) | 1d3a689f | +|-------------------------------------------------------------------------------------+------------| +| getExternalCall(uint256,address,bytes) | f68a9082 | +|-------------------------------------------------------------------------------------+------------| +| getOutboundCalldata(address,address,address,uint256,bytes) | a0c76a96 | +|-------------------------------------------------------------------------------------+------------| +| inbox() | fb0e722b | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,address) | f8c8765e | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,address,address) | 1459457a | +|-------------------------------------------------------------------------------------+------------| +| l1ToL2Token(address) | 8a2dc014 | +|-------------------------------------------------------------------------------------+------------| +| masterVaultFactory() | 48aa651c | +|-------------------------------------------------------------------------------------+------------| +| outboundTransfer(address,address,uint256,uint256,uint256,bytes) | d2ce7d65 | +|-------------------------------------------------------------------------------------+------------| +| outboundTransferCustomRefund(address,address,address,uint256,uint256,uint256,bytes) | 4fb1a07b | +|-------------------------------------------------------------------------------------+------------| +| owner() | 8da5cb5b | +|-------------------------------------------------------------------------------------+------------| +| postUpgradeInit() | 95fcea78 | +|-------------------------------------------------------------------------------------+------------| +| redirectedExits(bytes32) | bcf2e6eb | +|-------------------------------------------------------------------------------------+------------| +| registerTokenToL2(address,uint256,uint256,uint256) | f26bdead | +|-------------------------------------------------------------------------------------+------------| +| registerTokenToL2(address,uint256,uint256,uint256,address) | ca346d4a | +|-------------------------------------------------------------------------------------+------------| +| router() | f887ea40 | +|-------------------------------------------------------------------------------------+------------| +| setOwner(address) | 13af4035 | +|-------------------------------------------------------------------------------------+------------| +| supportsInterface(bytes4) | 01ffc9a7 | +|-------------------------------------------------------------------------------------+------------| +| transferExitAndCall(uint256,address,address,bytes,bytes) | bd5f3e7d | +|-------------------------------------------------------------------------------------+------------| +| whitelist() | 93e59dc1 | +╰-------------------------------------------------------------------------------------+------------╯ + diff --git a/test/signatures/L1YbbERC20Gateway b/test/signatures/L1YbbERC20Gateway new file mode 100644 index 000000000..4eee3548b --- /dev/null +++ b/test/signatures/L1YbbERC20Gateway @@ -0,0 +1,45 @@ + +╭-------------------------------------------------------------------------------------+------------╮ +| Method | Identifier | ++==================================================================================================+ +| calculateL2TokenAddress(address) | a7e28d48 | +|-------------------------------------------------------------------------------------+------------| +| cloneableProxyHash() | 97881f8d | +|-------------------------------------------------------------------------------------+------------| +| counterpartGateway() | 2db09c1c | +|-------------------------------------------------------------------------------------+------------| +| encodeWithdrawal(uint256,address) | 020a6058 | +|-------------------------------------------------------------------------------------+------------| +| finalizeInboundTransfer(address,address,address,uint256,bytes) | 2e567b36 | +|-------------------------------------------------------------------------------------+------------| +| getExternalCall(uint256,address,bytes) | f68a9082 | +|-------------------------------------------------------------------------------------+------------| +| getOutboundCalldata(address,address,address,uint256,bytes) | a0c76a96 | +|-------------------------------------------------------------------------------------+------------| +| inbox() | fb0e722b | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,bytes32,address) | a01893bf | +|-------------------------------------------------------------------------------------+------------| +| initialize(address,address,address,bytes32,address,address) | b54eea9c | +|-------------------------------------------------------------------------------------+------------| +| l2BeaconProxyFactory() | 70fc045f | +|-------------------------------------------------------------------------------------+------------| +| masterVaultFactory() | 48aa651c | +|-------------------------------------------------------------------------------------+------------| +| outboundTransfer(address,address,uint256,uint256,uint256,bytes) | d2ce7d65 | +|-------------------------------------------------------------------------------------+------------| +| outboundTransferCustomRefund(address,address,address,uint256,uint256,uint256,bytes) | 4fb1a07b | +|-------------------------------------------------------------------------------------+------------| +| postUpgradeInit() | 95fcea78 | +|-------------------------------------------------------------------------------------+------------| +| redirectedExits(bytes32) | bcf2e6eb | +|-------------------------------------------------------------------------------------+------------| +| router() | f887ea40 | +|-------------------------------------------------------------------------------------+------------| +| supportsInterface(bytes4) | 01ffc9a7 | +|-------------------------------------------------------------------------------------+------------| +| transferExitAndCall(uint256,address,address,bytes,bytes) | bd5f3e7d | +|-------------------------------------------------------------------------------------+------------| +| whitelist() | 93e59dc1 | +╰-------------------------------------------------------------------------------------+------------╯ + diff --git a/test/signatures/MasterVault b/test/signatures/MasterVault new file mode 100644 index 000000000..b04322d1e --- /dev/null +++ b/test/signatures/MasterVault @@ -0,0 +1,117 @@ + +╭---------------------------------------------------+------------╮ +| Method | Identifier | ++================================================================+ +| ADMIN_ROLE() | 75b238fc | +|---------------------------------------------------+------------| +| DEFAULT_ADMIN_ROLE() | a217fddf | +|---------------------------------------------------+------------| +| DEFAULT_MIN_REBALANCE_AMOUNT() | 07771237 | +|---------------------------------------------------+------------| +| EXTRA_DECIMALS() | f47b3a17 | +|---------------------------------------------------+------------| +| FEE_MANAGER_ROLE() | 05db2f41 | +|---------------------------------------------------+------------| +| GENERAL_MANAGER_ROLE() | 5072705d | +|---------------------------------------------------+------------| +| KEEPER_ROLE() | 364bc15a | +|---------------------------------------------------+------------| +| MIN_REBALANCE_COOLDOWN() | 4faaedec | +|---------------------------------------------------+------------| +| PAUSER_ROLE() | e63ab1e9 | +|---------------------------------------------------+------------| +| allowance(address,address) | dd62ed3e | +|---------------------------------------------------+------------| +| approve(address,uint256) | 095ea7b3 | +|---------------------------------------------------+------------| +| asset() | 38d52e0f | +|---------------------------------------------------+------------| +| balanceOf(address) | 70a08231 | +|---------------------------------------------------+------------| +| beneficiary() | 38af3eed | +|---------------------------------------------------+------------| +| decimals() | 313ce567 | +|---------------------------------------------------+------------| +| decreaseAllowance(address,uint256) | a457c2d7 | +|---------------------------------------------------+------------| +| deposit(uint256) | b6b55f25 | +|---------------------------------------------------+------------| +| distributePerformanceFee() | 79083436 | +|---------------------------------------------------+------------| +| gatewayRouter() | 03c85ea3 | +|---------------------------------------------------+------------| +| getRoleAdmin(bytes32) | 248a9ca3 | +|---------------------------------------------------+------------| +| getRoleMember(bytes32,uint256) | 9010d07c | +|---------------------------------------------------+------------| +| getRoleMemberCount(bytes32) | ca15c873 | +|---------------------------------------------------+------------| +| grantRole(bytes32,address) | 2f2ff15d | +|---------------------------------------------------+------------| +| hasRole(bytes32,address) | 91d14854 | +|---------------------------------------------------+------------| +| increaseAllowance(address,uint256) | 39509351 | +|---------------------------------------------------+------------| +| initialize(address) | c4d66de8 | +|---------------------------------------------------+------------| +| initialize(address,string,string,address,address) | 6cf1dbed | +|---------------------------------------------------+------------| +| isSubVaultWhitelisted(address) | ad476dd0 | +|---------------------------------------------------+------------| +| lastRebalanceTime() | d9ca21d5 | +|---------------------------------------------------+------------| +| minimumRebalanceAmount() | bed0bb2c | +|---------------------------------------------------+------------| +| name() | 06fdde03 | +|---------------------------------------------------+------------| +| pause() | 8456cb59 | +|---------------------------------------------------+------------| +| paused() | 5c975abb | +|---------------------------------------------------+------------| +| rebalance(int256) | 29249fca | +|---------------------------------------------------+------------| +| rebalanceCooldown() | b3de272d | +|---------------------------------------------------+------------| +| redeem(uint256,uint256) | 7cbc2373 | +|---------------------------------------------------+------------| +| renounceRole(bytes32,address) | 36568abe | +|---------------------------------------------------+------------| +| revokeRole(bytes32,address) | d547741f | +|---------------------------------------------------+------------| +| rolesRegistry() | b8173414 | +|---------------------------------------------------+------------| +| setBeneficiary(address) | 1c31f710 | +|---------------------------------------------------+------------| +| setMinimumRebalanceAmount(uint120) | a18cca65 | +|---------------------------------------------------+------------| +| setRebalanceCooldown(uint32) | a292bb05 | +|---------------------------------------------------+------------| +| setSubVault(address) | 9a5a68f9 | +|---------------------------------------------------+------------| +| setSubVaultWhitelist(address,bool) | 3f07a11d | +|---------------------------------------------------+------------| +| setTargetAllocationWad(uint64) | 9add8443 | +|---------------------------------------------------+------------| +| subVault() | 19b063de | +|---------------------------------------------------+------------| +| supportsInterface(bytes4) | 01ffc9a7 | +|---------------------------------------------------+------------| +| symbol() | 95d89b41 | +|---------------------------------------------------+------------| +| targetAllocationWad() | b8c0bad7 | +|---------------------------------------------------+------------| +| totalAssets() | 01e1d114 | +|---------------------------------------------------+------------| +| totalProfit() | 86001519 | +|---------------------------------------------------+------------| +| totalSupply() | 18160ddd | +|---------------------------------------------------+------------| +| transfer(address,uint256) | a9059cbb | +|---------------------------------------------------+------------| +| transferFrom(address,address,uint256) | 23b872dd | +|---------------------------------------------------+------------| +| unpause() | 3f4ba83a | +|---------------------------------------------------+------------| +| whitelistedSubVaults() | 8d206c8e | +╰---------------------------------------------------+------------╯ + diff --git a/test/signatures/MasterVaultFactory b/test/signatures/MasterVaultFactory new file mode 100644 index 000000000..4e67d3dd3 --- /dev/null +++ b/test/signatures/MasterVaultFactory @@ -0,0 +1,19 @@ + +╭-------------------------------------+------------╮ +| Method | Identifier | ++==================================================+ +| beaconProxyFactory() | c05e6a95 | +|-------------------------------------+------------| +| calculateVaultAddress(address) | a2fc2afe | +|-------------------------------------+------------| +| deployVault(address) | 5eb512e7 | +|-------------------------------------+------------| +| gatewayRouter() | 03c85ea3 | +|-------------------------------------+------------| +| getVault(address) | 0eb9af38 | +|-------------------------------------+------------| +| initialize(address,address,address) | c0c53b8b | +|-------------------------------------+------------| +| rolesRegistry() | b8173414 | +╰-------------------------------------+------------╯ + diff --git a/test/signatures/MasterVaultRoles b/test/signatures/MasterVaultRoles new file mode 100644 index 000000000..4e907e196 --- /dev/null +++ b/test/signatures/MasterVaultRoles @@ -0,0 +1,35 @@ + +╭--------------------------------+------------╮ +| Method | Identifier | ++=============================================+ +| ADMIN_ROLE() | 75b238fc | +|--------------------------------+------------| +| DEFAULT_ADMIN_ROLE() | a217fddf | +|--------------------------------+------------| +| FEE_MANAGER_ROLE() | 05db2f41 | +|--------------------------------+------------| +| GENERAL_MANAGER_ROLE() | 5072705d | +|--------------------------------+------------| +| KEEPER_ROLE() | 364bc15a | +|--------------------------------+------------| +| PAUSER_ROLE() | e63ab1e9 | +|--------------------------------+------------| +| getRoleAdmin(bytes32) | 248a9ca3 | +|--------------------------------+------------| +| getRoleMember(bytes32,uint256) | 9010d07c | +|--------------------------------+------------| +| getRoleMemberCount(bytes32) | ca15c873 | +|--------------------------------+------------| +| grantRole(bytes32,address) | 2f2ff15d | +|--------------------------------+------------| +| hasRole(bytes32,address) | 91d14854 | +|--------------------------------+------------| +| initialize(address) | c4d66de8 | +|--------------------------------+------------| +| renounceRole(bytes32,address) | 36568abe | +|--------------------------------+------------| +| revokeRole(bytes32,address) | d547741f | +|--------------------------------+------------| +| supportsInterface(bytes4) | 01ffc9a7 | +╰--------------------------------+------------╯ + diff --git a/test/storage/DefaultSubVault b/test/storage/DefaultSubVault new file mode 100644 index 000000000..49e4101d9 --- /dev/null +++ b/test/storage/DefaultSubVault @@ -0,0 +1,15 @@ + +╭--------------+-------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++=======================================================================================================================================================================+ +| _balances | mapping(address => uint256) | 0 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:DefaultSubVault | +|--------------+-------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------| +| _allowances | mapping(address => mapping(address => uint256)) | 1 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:DefaultSubVault | +|--------------+-------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------| +| _totalSupply | uint256 | 2 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:DefaultSubVault | +|--------------+-------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------| +| _name | string | 3 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:DefaultSubVault | +|--------------+-------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------| +| _symbol | string | 4 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:DefaultSubVault | +╰--------------+-------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------╯ + diff --git a/test/storage/L1AtomicTokenBridgeCreator b/test/storage/L1AtomicTokenBridgeCreator index 92d5ffacf..7fff7383c 100644 --- a/test/storage/L1AtomicTokenBridgeCreator +++ b/test/storage/L1AtomicTokenBridgeCreator @@ -41,5 +41,7 @@ | l1Multicall | address | 121 | 0 | 20 | contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol:L1AtomicTokenBridgeCreator | |--------------------------------+--------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| | canonicalL2FactoryAddress | address | 122 | 0 | 20 | contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol:L1AtomicTokenBridgeCreator | +|--------------------------------+--------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| ybbL1Templates | struct L1AtomicTokenBridgeCreator.YbbL1Templates | 123 | 0 | 192 | contracts/tokenbridge/ethereum/L1AtomicTokenBridgeCreator.sol:L1AtomicTokenBridgeCreator | ╰--------------------------------+--------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------╯ diff --git a/test/storage/L1OrbitYbbCustomGateway b/test/storage/L1OrbitYbbCustomGateway new file mode 100644 index 000000000..0988cffab --- /dev/null +++ b/test/storage/L1OrbitYbbCustomGateway @@ -0,0 +1,23 @@ + +╭--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++=========================================================================================================================================================================================================+ +| counterpartGateway | address | 0 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| router | address | 1 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| inbox | address | 2 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| redirectedExits | mapping(bytes32 => struct L1ArbitrumExtendedGateway.ExitData) | 3 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| l1ToL2Token | mapping(address => address) | 4 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| owner | address | 5 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| whitelist | address | 6 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| _status | uint256 | 7 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------| +| masterVaultFactory | address | 8 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbCustomGateway.sol:L1OrbitYbbCustomGateway | +╰--------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------------------╯ + diff --git a/test/storage/L1OrbitYbbERC20Gateway b/test/storage/L1OrbitYbbERC20Gateway new file mode 100644 index 000000000..93303f7ec --- /dev/null +++ b/test/storage/L1OrbitYbbERC20Gateway @@ -0,0 +1,23 @@ + +╭----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++=========================================================================================================================================================================================================+ +| counterpartGateway | address | 0 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| router | address | 1 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| inbox | address | 2 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| redirectedExits | mapping(bytes32 => struct L1ArbitrumExtendedGateway.ExitData) | 3 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| cloneableProxyHash | bytes32 | 4 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| l2BeaconProxyFactory | address | 5 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| whitelist | address | 6 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| _status | uint256 | 7 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------| +| masterVaultFactory | address | 8 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1OrbitYbbERC20Gateway.sol:L1OrbitYbbERC20Gateway | +╰----------------------+---------------------------------------------------------------+------+--------+-------+------------------------------------------------------------------------------------------╯ + diff --git a/test/storage/L1YbbCustomGateway b/test/storage/L1YbbCustomGateway new file mode 100644 index 000000000..74b759656 --- /dev/null +++ b/test/storage/L1YbbCustomGateway @@ -0,0 +1,23 @@ + +╭--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++===============================================================================================================================================================================================+ +| counterpartGateway | address | 0 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| router | address | 1 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| inbox | address | 2 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| redirectedExits | mapping(bytes32 => struct L1ArbitrumExtendedGateway.ExitData) | 3 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| l1ToL2Token | mapping(address => address) | 4 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| owner | address | 5 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| whitelist | address | 6 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| _status | uint256 | 7 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +|--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------| +| masterVaultFactory | address | 8 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbCustomGateway.sol:L1YbbCustomGateway | +╰--------------------+---------------------------------------------------------------+------+--------+-------+----------------------------------------------------------------------------------╯ + diff --git a/test/storage/L1YbbERC20Gateway b/test/storage/L1YbbERC20Gateway new file mode 100644 index 000000000..ac836b4f7 --- /dev/null +++ b/test/storage/L1YbbERC20Gateway @@ -0,0 +1,23 @@ + +╭----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++===============================================================================================================================================================================================+ +| counterpartGateway | address | 0 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| router | address | 1 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| inbox | address | 2 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| redirectedExits | mapping(bytes32 => struct L1ArbitrumExtendedGateway.ExitData) | 3 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| cloneableProxyHash | bytes32 | 4 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| l2BeaconProxyFactory | address | 5 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| whitelist | address | 6 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| _status | uint256 | 7 | 0 | 32 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +|----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------| +| masterVaultFactory | address | 8 | 0 | 20 | contracts/tokenbridge/ethereum/gateway/L1YbbERC20Gateway.sol:L1YbbERC20Gateway | +╰----------------------+---------------------------------------------------------------+------+--------+-------+--------------------------------------------------------------------------------╯ + diff --git a/test/storage/MasterVault b/test/storage/MasterVault new file mode 100644 index 000000000..582a0d876 --- /dev/null +++ b/test/storage/MasterVault @@ -0,0 +1,63 @@ + +╭------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++=====================================================================================================================================================================================+ +| _initialized | uint8 | 0 | 0 | 1 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _initializing | bool | 0 | 1 | 1 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| __gap | uint256[50] | 1 | 0 | 1600 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| __gap | uint256[50] | 51 | 0 | 1600 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _roles | mapping(bytes32 => struct AccessControlUpgradeable.RoleData) | 101 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| __gap | uint256[49] | 102 | 0 | 1568 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _roleMembers | mapping(bytes32 => struct EnumerableSetUpgradeable.AddressSet) | 151 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| __gap | uint256[49] | 152 | 0 | 1568 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _status | uint256 | 201 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| __gap | uint256[49] | 202 | 0 | 1568 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _balances | mapping(address => uint256) | 251 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _allowances | mapping(address => mapping(address => uint256)) | 252 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _totalSupply | uint256 | 253 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _name | string | 254 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _symbol | string | 255 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _decimals | uint8 | 256 | 0 | 1 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| __gap | uint256[44] | 257 | 0 | 1408 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _paused | bool | 301 | 0 | 1 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| __gap | uint256[49] | 302 | 0 | 1568 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| lastRebalanceTime | uint40 | 351 | 0 | 5 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| rebalanceCooldown | uint32 | 351 | 5 | 4 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| targetAllocationWad | uint64 | 351 | 9 | 8 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| minimumRebalanceAmount | uint120 | 351 | 17 | 15 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| asset | contract IERC20 | 352 | 0 | 20 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| gatewayRouter | contract IGatewayRouter | 353 | 0 | 20 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| _whitelistedSubVaults | struct EnumerableSet.AddressSet | 354 | 0 | 64 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| rolesRegistry | contract MasterVaultRoles | 356 | 0 | 20 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| beneficiary | address | 357 | 0 | 20 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +|------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------| +| subVault | contract IERC4626 | 358 | 0 | 20 | contracts/tokenbridge/libraries/vault/MasterVault.sol:MasterVault | +╰------------------------+----------------------------------------------------------------+------+--------+-------+-------------------------------------------------------------------╯ + diff --git a/test/storage/MasterVaultFactory b/test/storage/MasterVaultFactory new file mode 100644 index 000000000..cac3a020d --- /dev/null +++ b/test/storage/MasterVaultFactory @@ -0,0 +1,15 @@ + +╭--------------------+-----------------------------+------+--------+-------+---------------------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++============================================================================================================================================================+ +| _initialized | uint8 | 0 | 0 | 1 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:MasterVaultFactory | +|--------------------+-----------------------------+------+--------+-------+---------------------------------------------------------------------------------| +| _initializing | bool | 0 | 1 | 1 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:MasterVaultFactory | +|--------------------+-----------------------------+------+--------+-------+---------------------------------------------------------------------------------| +| beaconProxyFactory | contract BeaconProxyFactory | 0 | 2 | 20 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:MasterVaultFactory | +|--------------------+-----------------------------+------+--------+-------+---------------------------------------------------------------------------------| +| rolesRegistry | contract MasterVaultRoles | 1 | 0 | 20 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:MasterVaultFactory | +|--------------------+-----------------------------+------+--------+-------+---------------------------------------------------------------------------------| +| gatewayRouter | contract IGatewayRouter | 2 | 0 | 20 | contracts/tokenbridge/libraries/vault/MasterVaultFactory.sol:MasterVaultFactory | +╰--------------------+-----------------------------+------+--------+-------+---------------------------------------------------------------------------------╯ + diff --git a/test/storage/MasterVaultRoles b/test/storage/MasterVaultRoles new file mode 100644 index 000000000..027cfc6da --- /dev/null +++ b/test/storage/MasterVaultRoles @@ -0,0 +1,21 @@ + +╭---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++======================================================================================================================================================================================+ +| _initialized | uint8 | 0 | 0 | 1 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +|---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------| +| _initializing | bool | 0 | 1 | 1 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +|---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------| +| __gap | uint256[50] | 1 | 0 | 1600 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +|---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------| +| __gap | uint256[50] | 51 | 0 | 1600 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +|---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------| +| _roles | mapping(bytes32 => struct AccessControlUpgradeable.RoleData) | 101 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +|---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------| +| __gap | uint256[49] | 102 | 0 | 1568 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +|---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------| +| _roleMembers | mapping(bytes32 => struct EnumerableSetUpgradeable.AddressSet) | 151 | 0 | 32 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +|---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------| +| __gap | uint256[49] | 152 | 0 | 1568 | contracts/tokenbridge/libraries/vault/MasterVaultRoles.sol:MasterVaultRoles | +╰---------------+----------------------------------------------------------------+------+--------+-------+-----------------------------------------------------------------------------╯ +