diff --git a/contracts/.solhintignore b/contracts/.solhintignore index ffbcf74d3a..988f3bc831 100644 --- a/contracts/.solhintignore +++ b/contracts/.solhintignore @@ -1,2 +1,3 @@ node_modules -contracts/interfaces/morpho/Types.sol \ No newline at end of file +contracts/interfaces/morpho/Types.sol +contracts/mocks/**/*.sol \ No newline at end of file diff --git a/contracts/abi/createx.json b/contracts/abi/createx.json index 9e30b0e694..84904ff09a 100644 --- a/contracts/abi/createx.json +++ b/contracts/abi/createx.json @@ -23,6 +23,30 @@ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "initCode", + "type": "bytes" + } + ], + "name": "deployCreate3", + "outputs": [ + { + "internalType": "address", + "name": "newContract", + "type": "address" + } + ], + "stateMutability": "payable", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/contracts/interfaces/cctp/ICCTP.sol b/contracts/contracts/interfaces/cctp/ICCTP.sol new file mode 100644 index 0000000000..639b0ee307 --- /dev/null +++ b/contracts/contracts/interfaces/cctp/ICCTP.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +interface ICCTPTokenMessenger { + function depositForBurn( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold + ) external; + + function depositForBurnWithHook( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold, + bytes memory hookData + ) external; + + function getMinFeeAmount(uint256 amount) external view returns (uint256); +} + +interface ICCTPMessageTransmitter { + function sendMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + bytes memory messageBody + ) external; + + function receiveMessage(bytes calldata message, bytes calldata attestation) + external + returns (bool); +} + +interface IMessageHandlerV2 { + /** + * @notice Handles an incoming finalized message from an IReceiverV2 + * @dev Finalized messages have finality threshold values greater than or equal to 2000 + * @param sourceDomain The source domain of the message + * @param sender The sender of the message + * @param finalityThresholdExecuted the finality threshold at which the message was attested to + * @param messageBody The raw bytes of the message body + * @return success True, if successful; false, if not. + */ + function handleReceiveFinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32 finalityThresholdExecuted, + bytes calldata messageBody + ) external returns (bool); + + /** + * @notice Handles an incoming unfinalized message from an IReceiverV2 + * @dev Unfinalized messages have finality threshold values less than 2000 + * @param sourceDomain The source domain of the message + * @param sender The sender of the message + * @param finalityThresholdExecuted The finality threshold at which the message was attested to + * @param messageBody The raw bytes of the message body + * @return success True, if successful; false, if not. + */ + function handleReceiveUnfinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32 finalityThresholdExecuted, + bytes calldata messageBody + ) external returns (bool); +} diff --git a/contracts/contracts/mocks/MockERC4626Vault.sol b/contracts/contracts/mocks/MockERC4626Vault.sol new file mode 100644 index 0000000000..02b4672c2d --- /dev/null +++ b/contracts/contracts/mocks/MockERC4626Vault.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "../../lib/openzeppelin/interfaces/IERC4626.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MockERC4626Vault is IERC4626, ERC20 { + using SafeERC20 for IERC20; + + address public asset; + uint8 public constant DECIMALS = 18; + + constructor(address _asset) ERC20("Mock Vault Share", "MVS") { + asset = _asset; + } + + // ERC20 totalSupply is inherited + + // ERC20 balanceOf is inherited + + function deposit(uint256 assets, address receiver) + public + override + returns (uint256 shares) + { + shares = previewDeposit(assets); + IERC20(asset).safeTransferFrom(msg.sender, address(this), assets); + _mint(receiver, shares); + return shares; + } + + function mint(uint256 shares, address receiver) + public + override + returns (uint256 assets) + { + assets = previewMint(shares); + IERC20(asset).safeTransferFrom(msg.sender, address(this), assets); + _mint(receiver, shares); + return assets; + } + + function withdraw( + uint256 assets, + address receiver, + address owner + ) public override returns (uint256 shares) { + shares = previewWithdraw(assets); + if (msg.sender != owner) { + // No approval check for mock + } + _burn(owner, shares); + IERC20(asset).safeTransfer(receiver, assets); + return shares; + } + + function redeem( + uint256 shares, + address receiver, + address owner + ) public override returns (uint256 assets) { + assets = previewRedeem(shares); + if (msg.sender != owner) { + // No approval check for mock + } + _burn(owner, shares); + IERC20(asset).safeTransfer(receiver, assets); + return assets; + } + + function totalAssets() public view override returns (uint256) { + return IERC20(asset).balanceOf(address(this)); + } + + function convertToShares(uint256 assets) + public + view + override + returns (uint256 shares) + { + uint256 supply = totalSupply(); // Use ERC20 totalSupply + return + supply == 0 || assets == 0 + ? assets + : (assets * supply) / totalAssets(); + } + + function convertToAssets(uint256 shares) + public + view + override + returns (uint256 assets) + { + uint256 supply = totalSupply(); // Use ERC20 totalSupply + return supply == 0 ? shares : (shares * totalAssets()) / supply; + } + + function maxDeposit(address receiver) + public + view + override + returns (uint256) + { + return type(uint256).max; + } + + function maxMint(address receiver) public view override returns (uint256) { + return type(uint256).max; + } + + function maxWithdraw(address owner) public view override returns (uint256) { + return convertToAssets(balanceOf(owner)); + } + + function maxRedeem(address owner) public view override returns (uint256) { + return balanceOf(owner); + } + + function previewDeposit(uint256 assets) + public + view + override + returns (uint256 shares) + { + return convertToShares(assets); + } + + function previewMint(uint256 shares) + public + view + override + returns (uint256 assets) + { + return convertToAssets(shares); + } + + function previewWithdraw(uint256 assets) + public + view + override + returns (uint256 shares) + { + return convertToShares(assets); + } + + function previewRedeem(uint256 shares) + public + view + override + returns (uint256 assets) + { + return convertToAssets(shares); + } + + function _mint(address account, uint256 amount) internal override { + super._mint(account, amount); + } + + function _burn(address account, uint256 amount) internal override { + super._burn(account, amount); + } + + // Inherited from ERC20 +} diff --git a/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock.sol b/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock.sol new file mode 100644 index 0000000000..8a2a3f9a7a --- /dev/null +++ b/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { ICCTPMessageTransmitter } from "../../interfaces/cctp/ICCTP.sol"; +import { IERC20 } from "../../utils/InitializableAbstractStrategy.sol"; +import { BytesHelper } from "../../utils/BytesHelper.sol"; +import { AbstractCCTPIntegrator } from "../../strategies/crosschain/AbstractCCTPIntegrator.sol"; + +/** + * @title Mock conctract simulating the functionality of the CCTPTokenMessenger contract + * for the porposes of unit testing. + * @author Origin Protocol Inc + */ + +contract CCTPMessageTransmitterMock is ICCTPMessageTransmitter { + using BytesHelper for bytes; + + IERC20 public usdc; + uint256 public nonce = 0; + // Sender index in the burn message v2 + // Ref: https://github.com/circlefin/evm-cctp-contracts/blob/master/src/messages/v2/BurnMessageV2.sol + uint8 constant BURN_MESSAGE_V2_MESSAGE_SENDER_INDEX = 100; + uint8 constant BURN_MESSAGE_V2_HOOK_DATA_INDEX = 228; + + // Full message with header + struct Message { + uint32 version; + uint32 sourceDomain; + uint32 destinationDomain; + bytes32 recipient; + bytes32 sender; + bytes32 destinationCaller; + uint32 minFinalityThreshold; + bool isTokenTransfer; + uint256 tokenAmount; + bytes messageBody; + } + + Message[] public messages; + // map of encoded messages to the corresponding message structs + mapping(bytes32 => Message) public encodedMessages; + + constructor(address _usdc) { + usdc = IERC20(_usdc); + } + + // @dev for the porposes of unit tests queues the message to be mock-sent using + // the cctp bridge. + function sendMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + bytes memory messageBody + ) external virtual override { + bytes32 nonceHash = keccak256(abi.encodePacked(nonce)); + nonce++; + + // If destination is mainnet, source is base and vice versa + uint32 sourceDomain = destinationDomain == 0 ? 6 : 0; + + Message memory message = Message({ + version: 1, + sourceDomain: sourceDomain, + destinationDomain: destinationDomain, + recipient: recipient, + sender: bytes32(uint256(uint160(msg.sender))), + destinationCaller: destinationCaller, + minFinalityThreshold: minFinalityThreshold, + isTokenTransfer: false, + tokenAmount: 0, + messageBody: messageBody + }); + + messages.push(message); + } + + // @dev for the porposes of unit tests queues the USDC burn/mint to be executed + // using the cctp bridge. + function sendTokenTransferMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + uint256 tokenAmount, + bytes memory messageBody + ) external { + bytes32 nonceHash = keccak256(abi.encodePacked(nonce)); + nonce++; + + // If destination is mainnet, source is base and vice versa + uint32 sourceDomain = destinationDomain == 0 ? 6 : 0; + + Message memory message = Message({ + version: 1, + sourceDomain: sourceDomain, + destinationDomain: destinationDomain, + recipient: recipient, + sender: bytes32(uint256(uint160(msg.sender))), + destinationCaller: destinationCaller, + minFinalityThreshold: minFinalityThreshold, + isTokenTransfer: true, + tokenAmount: tokenAmount, + messageBody: messageBody + }); + + messages.push(message); + } + + function receiveMessage(bytes memory message, bytes memory attestation) + public + virtual + override + returns (bool) + { + Message memory storedMsg = encodedMessages[keccak256(message)]; + AbstractCCTPIntegrator recipient = AbstractCCTPIntegrator( + address(uint160(uint256(storedMsg.recipient))) + ); + + bytes32 sender = storedMsg.sender; + bytes memory messageBody = storedMsg.messageBody; + + // Credit USDC in this step as it is done in the live cctp contracts + if (storedMsg.isTokenTransfer) { + usdc.transfer(address(recipient), storedMsg.tokenAmount); + // override the sender with the one stored in the Burn message as the sender int he + // message header is the TokenMessenger. + sender = bytes32( + uint256( + uint160( + storedMsg.messageBody.extractAddress( + BURN_MESSAGE_V2_MESSAGE_SENDER_INDEX + ) + ) + ) + ); + messageBody = storedMsg.messageBody.extractSlice( + BURN_MESSAGE_V2_HOOK_DATA_INDEX, + storedMsg.messageBody.length + ); + } else { + recipient.handleReceiveFinalizedMessage( + storedMsg.sourceDomain, + sender, + 2000, // finality threshold + messageBody + ); + } + + // TODO: should we also handle unfinalized messages: handleReceiveUnfinalizedMessage? + + return true; + } + + function addMessage(Message memory storedMsg) external { + messages.push(storedMsg); + } + + function _encodeMessageHeader( + uint32 version, + uint32 sourceDomain, + bytes32 sender, + bytes32 recipient, + bytes memory messageBody + ) internal pure returns (bytes memory) { + bytes memory header = abi.encodePacked( + version, // 0-3 + sourceDomain, // 4-7 + bytes32(0), // 8-39 destinationDomain + bytes4(0), // 40-43 nonce + sender, // 44-75 sender + recipient, // 76-107 recipient + bytes32(0), // other stuff + bytes8(0) // other stuff + ); + return abi.encodePacked(header, messageBody); + } + + function _removeFront() internal returns (Message memory) { + require(messages.length > 0, "No messages"); + Message memory removed = messages[0]; + // Shift array + for (uint256 i = 0; i < messages.length - 1; i++) { + messages[i] = messages[i + 1]; + } + messages.pop(); + return removed; + } + + function _processMessage(Message memory storedMsg) internal { + bytes memory encodedMessage = _encodeMessageHeader( + storedMsg.version, + storedMsg.sourceDomain, + storedMsg.sender, + storedMsg.recipient, + storedMsg.messageBody + ); + + encodedMessages[keccak256(encodedMessage)] = storedMsg; + + address recipient = address(uint160(uint256(storedMsg.recipient))); + + AbstractCCTPIntegrator(recipient).relay(encodedMessage, bytes("")); + } + + function _removeBack() internal returns (Message memory) { + require(messages.length > 0, "No messages"); + Message memory last = messages[messages.length - 1]; + messages.pop(); + return last; + } + + function messagesInQueue() external view returns (uint256) { + return messages.length; + } + + function processFront() external { + Message memory storedMsg = _removeFront(); + _processMessage(storedMsg); + } + + function processBack() external { + Message memory storedMsg = _removeBack(); + _processMessage(storedMsg); + } + + function getMessagesLength() external view returns (uint256) { + return messages.length; + } +} diff --git a/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock2.sol b/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock2.sol new file mode 100644 index 0000000000..a44d5d3fbe --- /dev/null +++ b/contracts/contracts/mocks/crosschain/CCTPMessageTransmitterMock2.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IMessageHandlerV2 } from "../../interfaces/cctp/ICCTP.sol"; +import { BytesHelper } from "../../utils/BytesHelper.sol"; +import { CCTPMessageTransmitterMock } from "./CCTPMessageTransmitterMock.sol"; + +uint8 constant SOURCE_DOMAIN_INDEX = 4; +uint8 constant RECIPIENT_INDEX = 76; +uint8 constant SENDER_INDEX = 44; +uint8 constant MESSAGE_BODY_INDEX = 148; + +/** + * @title Mock conctract simulating the functionality of the CCTPTokenMessenger contract + * for the porposes of unit testing. + * @author Origin Protocol Inc + */ + +contract CCTPMessageTransmitterMock2 is CCTPMessageTransmitterMock { + using BytesHelper for bytes; + + address public cctpTokenMessenger; + + event MessageReceivedInMockTransmitter(bytes message); + event MessageSent(bytes message); + + constructor(address _usdc) CCTPMessageTransmitterMock(_usdc) {} + + function setCCTPTokenMessenger(address _cctpTokenMessenger) external { + cctpTokenMessenger = _cctpTokenMessenger; + } + + function sendMessage( + uint32 destinationDomain, + bytes32 recipient, + bytes32 destinationCaller, + uint32 minFinalityThreshold, + bytes memory messageBody + ) external virtual override { + bytes memory message = abi.encodePacked( + uint32(1), // version + uint32(destinationDomain == 0 ? 6 : 0), // source domain + uint32(destinationDomain), // destination domain + uint256(0), + bytes32(uint256(uint160(msg.sender))), // sender + recipient, // recipient + destinationCaller, // destination caller + minFinalityThreshold, // min finality threshold + uint32(0), + messageBody // message body + ); + emit MessageSent(message); + } + + function receiveMessage(bytes memory message, bytes memory attestation) + public + virtual + override + returns (bool) + { + uint32 sourceDomain = message.extractUint32(SOURCE_DOMAIN_INDEX); + address recipient = message.extractAddress(RECIPIENT_INDEX); + address sender = message.extractAddress(SENDER_INDEX); + + bytes memory messageBody = message.extractSlice( + MESSAGE_BODY_INDEX, + message.length + ); + + bool isBurnMessage = recipient == cctpTokenMessenger; + + if (isBurnMessage) { + // recipient = messageBody.extractAddress(BURN_MESSAGE_V2_RECIPIENT_INDEX); + // This step won't mint USDC, transfer it to the recipient address + // in your tests + } else { + IMessageHandlerV2(recipient).handleReceiveFinalizedMessage( + sourceDomain, + bytes32(uint256(uint160(sender))), + 2000, + messageBody + ); + } + + // This step won't mint USDC, transfer it to the recipient address + // in your tests + emit MessageReceivedInMockTransmitter(message); + + return true; + } +} diff --git a/contracts/contracts/mocks/crosschain/CCTPTokenMessengerMock.sol b/contracts/contracts/mocks/crosschain/CCTPTokenMessengerMock.sol new file mode 100644 index 0000000000..e33cc9c0d1 --- /dev/null +++ b/contracts/contracts/mocks/crosschain/CCTPTokenMessengerMock.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { ICCTPTokenMessenger } from "../../interfaces/cctp/ICCTP.sol"; +import { CCTPMessageTransmitterMock } from "./CCTPMessageTransmitterMock.sol"; +import { IERC20 } from "../../utils/InitializableAbstractStrategy.sol"; + +/** + * @title Mock conctract simulating the functionality of the CCTPTokenMessenger contract + * for the porposes of unit testing. + * @author Origin Protocol Inc + */ + +contract CCTPTokenMessengerMock is ICCTPTokenMessenger { + IERC20 public usdc; + CCTPMessageTransmitterMock public cctpMessageTransmitterMock; + + constructor(address _usdc, address _cctpMessageTransmitterMock) { + usdc = IERC20(_usdc); + cctpMessageTransmitterMock = CCTPMessageTransmitterMock( + _cctpMessageTransmitterMock + ); + } + + function depositForBurn( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold + ) external override { + revert("Not implemented"); + } + + /** + * @dev mocks the depositForBurnWithHook function by sending the USDC to the CCTPMessageTransmitterMock + * called by the AbstractCCTPIntegrator contract. + */ + function depositForBurnWithHook( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken, + bytes32 destinationCaller, + uint256 maxFee, + uint32 minFinalityThreshold, + bytes memory hookData + ) external override { + require(burnToken == address(usdc), "Invalid burn token"); + + usdc.transferFrom(msg.sender, address(this), maxFee); + uint256 destinationAmount = amount - maxFee; + usdc.transferFrom( + msg.sender, + address(cctpMessageTransmitterMock), + destinationAmount + ); + + bytes memory burnMessage = _encodeBurnMessageV2( + mintRecipient, + amount, + msg.sender, + maxFee, + maxFee, + hookData + ); + + cctpMessageTransmitterMock.sendTokenTransferMessage( + destinationDomain, + mintRecipient, + destinationCaller, + minFinalityThreshold, + destinationAmount, + burnMessage + ); + } + + function _encodeBurnMessageV2( + bytes32 mintRecipient, + uint256 amount, + address messageSender, + uint256 maxFee, + uint256 feeExecuted, + bytes memory hookData + ) internal view returns (bytes memory) { + bytes32 burnTokenBytes32 = bytes32( + abi.encodePacked(bytes12(0), bytes20(uint160(address(usdc)))) + ); + bytes32 messageSenderBytes32 = bytes32( + abi.encodePacked(bytes12(0), bytes20(uint160(messageSender))) + ); + bytes32 expirationBlock = bytes32(0); + + // Ref: https://developers.circle.com/cctp/technical-guide#message-body + return + abi.encodePacked( + uint32(1), // 0-3: version + burnTokenBytes32, // 4-35: burnToken (bytes32 left-padded address) + mintRecipient, // 36-67: mintRecipient (bytes32 left-padded address) + amount, // 68-99: uint256 amount + messageSenderBytes32, // 100-131: messageSender (bytes32 left-padded address) + maxFee, // 132-163: uint256 maxFee + feeExecuted, // 164-195: uint256 feeExecuted + expirationBlock, // 196-227: bytes32 expirationBlock + hookData // 228+: dynamic hookData + ); + } + + function getMinFeeAmount(uint256 amount) + external + view + override + returns (uint256) + { + return 0; + } +} diff --git a/contracts/contracts/proxies/InitializeGovernedUpgradeabilityProxy2.sol b/contracts/contracts/proxies/InitializeGovernedUpgradeabilityProxy2.sol new file mode 100644 index 0000000000..250acbe782 --- /dev/null +++ b/contracts/contracts/proxies/InitializeGovernedUpgradeabilityProxy2.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { InitializeGovernedUpgradeabilityProxy } from "./InitializeGovernedUpgradeabilityProxy.sol"; + +/** + * @title BaseGovernedUpgradeabilityProxy2 + * @dev This is the same as InitializeGovernedUpgradeabilityProxy except that the + * governor is defined in the constructor. + * @author Origin Protocol Inc + */ +contract InitializeGovernedUpgradeabilityProxy2 is + InitializeGovernedUpgradeabilityProxy +{ + /** + * This is used when the msg.sender can not be the governor. E.g. when the proxy is + * deployed via CreateX + */ + constructor(address governor) InitializeGovernedUpgradeabilityProxy() { + _setGovernor(governor); + } +} diff --git a/contracts/contracts/proxies/Proxies.sol b/contracts/contracts/proxies/Proxies.sol index a6055cb95a..03227c25a8 100644 --- a/contracts/contracts/proxies/Proxies.sol +++ b/contracts/contracts/proxies/Proxies.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.0; import { InitializeGovernedUpgradeabilityProxy } from "./InitializeGovernedUpgradeabilityProxy.sol"; +import { InitializeGovernedUpgradeabilityProxy2 } from "./InitializeGovernedUpgradeabilityProxy2.sol"; /** * @notice OUSDProxy delegates calls to an OUSD implementation diff --git a/contracts/contracts/proxies/create2/CrossChainStrategyProxy.sol b/contracts/contracts/proxies/create2/CrossChainStrategyProxy.sol new file mode 100644 index 0000000000..a5feec929b --- /dev/null +++ b/contracts/contracts/proxies/create2/CrossChainStrategyProxy.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { InitializeGovernedUpgradeabilityProxy2 } from "../InitializeGovernedUpgradeabilityProxy2.sol"; + +// ******************************************************** +// ******************************************************** +// IMPORTANT: DO NOT CHANGE ANYTHING IN THIS FILE. +// Any changes to this file (even whitespaces) will +// affect the create2 address of the proxy +// ******************************************************** +// ******************************************************** + +/** + * @notice CrossChainStrategyProxy delegates calls to a + * CrossChainMasterStrategy or CrossChainRemoteStrategy + * implementation contract. + */ +contract CrossChainStrategyProxy is InitializeGovernedUpgradeabilityProxy2 { + constructor(address governor) + InitializeGovernedUpgradeabilityProxy2(governor) + {} +} diff --git a/contracts/contracts/strategies/Generalized4626Strategy.sol b/contracts/contracts/strategies/Generalized4626Strategy.sol index 0695a6fc0c..931e2cfefc 100644 --- a/contracts/contracts/strategies/Generalized4626Strategy.sol +++ b/contracts/contracts/strategies/Generalized4626Strategy.sol @@ -64,6 +64,7 @@ contract Generalized4626Strategy is InitializableAbstractStrategy { */ function deposit(address _asset, uint256 _amount) external + virtual override onlyVault nonReentrant @@ -106,6 +107,14 @@ contract Generalized4626Strategy is InitializableAbstractStrategy { address _asset, uint256 _amount ) external virtual override onlyVault nonReentrant { + _withdraw(_recipient, _asset, _amount); + } + + function _withdraw( + address _recipient, + address _asset, + uint256 _amount + ) internal virtual { require(_amount > 0, "Must withdraw something"); require(_recipient != address(0), "Must specify recipient"); require(_asset == address(assetToken), "Unexpected asset address"); @@ -154,7 +163,7 @@ contract Generalized4626Strategy is InitializableAbstractStrategy { * @return balance Total value of the asset in the platform */ function checkBalance(address _asset) - external + public view virtual override diff --git a/contracts/contracts/strategies/crosschain/AbstractCCTPIntegrator.sol b/contracts/contracts/strategies/crosschain/AbstractCCTPIntegrator.sol new file mode 100644 index 0000000000..fcb9db3807 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/AbstractCCTPIntegrator.sol @@ -0,0 +1,591 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title AbstractCCTPIntegrator + * @author Origin Protocol Inc + * + * @dev Abstract contract that contains all the logic used to integrate with CCTP. + */ + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "../../utils/InitializableAbstractStrategy.sol"; + +import { ICCTPTokenMessenger, ICCTPMessageTransmitter, IMessageHandlerV2 } from "../../interfaces/cctp/ICCTP.sol"; + +import { CrossChainStrategyHelper } from "./CrossChainStrategyHelper.sol"; +import { Governable } from "../../governance/Governable.sol"; +import { BytesHelper } from "../../utils/BytesHelper.sol"; +import "../../utils/Helpers.sol"; + +// CCTP Message Header fields +// Ref: https://developers.circle.com/cctp/technical-guide#message-header +uint8 constant VERSION_INDEX = 0; +uint8 constant SOURCE_DOMAIN_INDEX = 4; +uint8 constant SENDER_INDEX = 44; +uint8 constant RECIPIENT_INDEX = 76; +uint8 constant MESSAGE_BODY_INDEX = 148; + +// Message body V2 fields +// Ref: https://developers.circle.com/cctp/technical-guide#message-body +// Ref: https://github.com/circlefin/evm-cctp-contracts/blob/master/src/messages/v2/BurnMessageV2.sol +uint8 constant BURN_MESSAGE_V2_VERSION_INDEX = 0; +uint8 constant BURN_MESSAGE_V2_RECIPIENT_INDEX = 36; +uint8 constant BURN_MESSAGE_V2_AMOUNT_INDEX = 68; +uint8 constant BURN_MESSAGE_V2_MESSAGE_SENDER_INDEX = 100; +uint8 constant BURN_MESSAGE_V2_FEE_EXECUTED_INDEX = 164; +uint8 constant BURN_MESSAGE_V2_HOOK_DATA_INDEX = 228; + +abstract contract AbstractCCTPIntegrator is Governable, IMessageHandlerV2 { + using SafeERC20 for IERC20; + + using BytesHelper for bytes; + + event CCTPMinFinalityThresholdSet(uint16 minFinalityThreshold); + event CCTPFeePremiumBpsSet(uint16 feePremiumBps); + event OperatorChanged(address operator); + + /** + * @notice Max transfer threshold imposed by the CCTP + * Ref: https://developers.circle.com/cctp/evm-smart-contracts#depositforburn + */ + uint256 public constant MAX_TRANSFER_AMOUNT = 10_000_000 * 10**6; // 10M USDC + + // CCTP contracts + // This implementation assumes that remote and local chains have these contracts + // deployed on the same addresses. + /// @notice CCTP message transmitter contract + ICCTPMessageTransmitter public immutable cctpMessageTransmitter; + /// @notice CCTP token messenger contract + ICCTPTokenMessenger public immutable cctpTokenMessenger; + + /// @notice USDC address on local chain + address public immutable usdcToken; + + /// @notice Domain ID of the chain from which messages are accepted + uint32 public immutable peerDomainID; + + /// @notice Strategy address on other chain + address public immutable peerStrategy; + + /** + * @notice Minimum finality threshold + * Can be 1000 (safe, after 1 epoch) or 2000 (finalized, after 2 epochs). + * Ref: https://developers.circle.com/cctp/technical-guide#finality-thresholds + */ + uint16 public minFinalityThreshold; + + /// @notice Fee premium in basis points + uint16 public feePremiumBps; + + /// @notice Nonce of the last known deposit or withdrawal + uint64 public lastTransferNonce; + + /// @notice Operator address: Can relay CCTP messages + address public operator; + + /// @notice Mapping of processed nonces + mapping(uint64 => bool) private nonceProcessed; + + // For future use + uint256[50] private __gap; + + modifier onlyCCTPMessageTransmitter() { + require( + msg.sender == address(cctpMessageTransmitter), + "Caller is not CCTP transmitter" + ); + _; + } + + modifier onlyOperator() { + require(msg.sender == operator, "Caller is not the Operator"); + _; + } + + /** + * @notice Configuration for CCTP integration + * @param cctpTokenMessenger Address of the CCTP token messenger contract + * @param cctpMessageTransmitter Address of the CCTP message transmitter contract + * @param peerDomainID Domain ID of the chain from which messages are accepted. + * 0 for Ethereum, 6 for Base, etc. + * Ref: https://developers.circle.com/cctp/cctp-supported-blockchains + * @param peerStrategy Address of the master or remote strategy on the other chain + * @param usdcToken USDC address on local chain + */ + struct CCTPIntegrationConfig { + address cctpTokenMessenger; + address cctpMessageTransmitter; + uint32 peerDomainID; + address peerStrategy; + address usdcToken; + } + + constructor(CCTPIntegrationConfig memory _config) { + cctpMessageTransmitter = ICCTPMessageTransmitter( + _config.cctpMessageTransmitter + ); + cctpTokenMessenger = ICCTPTokenMessenger(_config.cctpTokenMessenger); + + // Domain ID of the chain from which messages are accepted + peerDomainID = _config.peerDomainID; + + // Strategy address on other chain, should + // always be same as the proxy of this strategy + peerStrategy = _config.peerStrategy; + + // USDC address on local chain + usdcToken = _config.usdcToken; + + // Just a sanity check to ensure the base token is USDC + uint256 _usdcTokenDecimals = Helpers.getDecimals(_config.usdcToken); + string memory _usdcTokenSymbol = Helpers.getSymbol(_config.usdcToken); + require(_usdcTokenDecimals == 6, "Base token decimals must be 6"); + require( + keccak256(abi.encodePacked(_usdcTokenSymbol)) == + keccak256(abi.encodePacked("USDC")), + "Token symbol must be USDC" + ); + } + + /** + * @dev Initialize the implementation contract + * @param _operator Operator address + * @param _minFinalityThreshold Minimum finality threshold + * @param _feePremiumBps Fee premium in basis points + */ + function _initialize( + address _operator, + uint16 _minFinalityThreshold, + uint16 _feePremiumBps + ) internal { + _setOperator(_operator); + _setMinFinalityThreshold(_minFinalityThreshold); + _setFeePremiumBps(_feePremiumBps); + } + + /*************************************** + Settings + ****************************************/ + /** + * @dev Set the operator address + * @param _operator Operator address + */ + function setOperator(address _operator) external onlyGovernor { + _setOperator(_operator); + } + + /** + * @dev Set the operator address + * @param _operator Operator address + */ + function _setOperator(address _operator) internal { + operator = _operator; + emit OperatorChanged(_operator); + } + + /** + * @dev Set the minimum finality threshold at which + * the message is considered to be finalized to relay. + * Only accepts a value of 1000 (Safe, after 1 epoch) or + * 2000 (Finalized, after 2 epochs). + * @param _minFinalityThreshold Minimum finality threshold + */ + function setMinFinalityThreshold(uint16 _minFinalityThreshold) + external + onlyGovernor + { + _setMinFinalityThreshold(_minFinalityThreshold); + } + + /** + * @dev Set the minimum finality threshold + * @param _minFinalityThreshold Minimum finality threshold + */ + function _setMinFinalityThreshold(uint16 _minFinalityThreshold) internal { + // 1000 for fast transfer and 2000 for standard transfer + require( + _minFinalityThreshold == 1000 || _minFinalityThreshold == 2000, + "Invalid threshold" + ); + + minFinalityThreshold = _minFinalityThreshold; + emit CCTPMinFinalityThresholdSet(_minFinalityThreshold); + } + + /** + * @dev Set the fee premium in basis points. + * Cannot be higher than 30% (3000 basis points). + * @param _feePremiumBps Fee premium in basis points + */ + function setFeePremiumBps(uint16 _feePremiumBps) external onlyGovernor { + _setFeePremiumBps(_feePremiumBps); + } + + /** + * @dev Set the fee premium in basis points + * Cannot be higher than 30% (3000 basis points). + * Ref: https://developers.circle.com/cctp/technical-guide#fees + * @param _feePremiumBps Fee premium in basis points + */ + function _setFeePremiumBps(uint16 _feePremiumBps) internal { + require(_feePremiumBps <= 3000, "Fee premium too high"); // 30% + + feePremiumBps = _feePremiumBps; + emit CCTPFeePremiumBpsSet(_feePremiumBps); + } + + /*************************************** + CCTP message handling + ****************************************/ + + /** + * @dev Handles a finalized CCTP message + * @param sourceDomain Source domain of the message + * @param sender Sender of the message + * @param finalityThresholdExecuted Fidelity threshold executed + * @param messageBody Message body + */ + function handleReceiveFinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32 finalityThresholdExecuted, + bytes memory messageBody + ) external override onlyCCTPMessageTransmitter returns (bool) { + return + _handleReceivedMessage( + sourceDomain, + sender, + finalityThresholdExecuted, + messageBody + ); + } + + /** + * @dev Handles an unfinalized but safe CCTP message + * @param sourceDomain Source domain of the message + * @param sender Sender of the message + * @param finalityThresholdExecuted Fidelity threshold executed + * @param messageBody Message body + */ + function handleReceiveUnfinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32 finalityThresholdExecuted, + bytes memory messageBody + ) external override onlyCCTPMessageTransmitter returns (bool) { + // Make sure the contract is configured to handle unfinalized messages + require( + minFinalityThreshold == 1000, + "Unfinalized messages are not supported" + ); + + return + _handleReceivedMessage( + sourceDomain, + sender, + finalityThresholdExecuted, + messageBody + ); + } + + /** + * @dev Handles a CCTP message + * @param sourceDomain Source domain of the message + * @param sender Sender of the message + * @param finalityThresholdExecuted Fidelity threshold executed + * @param messageBody Message body + */ + function _handleReceivedMessage( + uint32 sourceDomain, + bytes32 sender, + // solhint-disable-next-line no-unused-vars + uint32 finalityThresholdExecuted, + bytes memory messageBody + ) internal returns (bool) { + require(sourceDomain == peerDomainID, "Unknown Source Domain"); + + // Extract address from bytes32 (CCTP stores addresses as right-padded bytes32) + address senderAddress = address(uint160(uint256(sender))); + require(senderAddress == peerStrategy, "Unknown Sender"); + + _onMessageReceived(messageBody); + + return true; + } + + /** + * @dev Sends tokens to the peer strategy using CCTP Token Messenger + * @param tokenAmount Amount of tokens to send + * @param hookData Hook data + */ + function _sendTokens(uint256 tokenAmount, bytes memory hookData) + internal + virtual + { + // CCTP has a maximum transfer amount of 10M USDC per tx + require(tokenAmount <= MAX_TRANSFER_AMOUNT, "Token amount too high"); + + // Approve only what needs to be transferred + IERC20(usdcToken).safeApprove(address(cctpTokenMessenger), tokenAmount); + + // Compute the max fee to be paid. + // Ref: https://developers.circle.com/cctp/evm-smart-contracts#getminfeeamount + // The right way to compute fees would be to use CCTP's getMinFeeAmount function. + // The issue is that the getMinFeeAmount is not present on v2.0 contracts, but is on + // v2.1. Some of CCTP's deployed contracts are v2.0, some are v2.1. + // We will only be using standard transfers and fee on those is 0 for now. If they + // ever start implementing fee for standard transfers or if we decide to use fast + // trasnfer, we can use feePremiumBps as a workaround. + uint256 maxFee = feePremiumBps > 0 + ? (tokenAmount * feePremiumBps) / 10000 + : 0; + + // Send tokens to the peer strategy using CCTP Token Messenger + cctpTokenMessenger.depositForBurnWithHook( + tokenAmount, + peerDomainID, + bytes32(uint256(uint160(peerStrategy))), + address(usdcToken), + bytes32(uint256(uint160(peerStrategy))), + maxFee, + uint32(minFinalityThreshold), + hookData + ); + } + + /** + * @dev Sends a message to the peer strategy using CCTP Message Transmitter + * @param message Payload of the message to send + */ + function _sendMessage(bytes memory message) internal virtual { + cctpMessageTransmitter.sendMessage( + peerDomainID, + bytes32(uint256(uint160(peerStrategy))), + bytes32(uint256(uint160(peerStrategy))), + uint32(minFinalityThreshold), + message + ); + } + + /** + * @dev Receives a message from the peer strategy on the other chain, + * does some basic checks and relays it to the local MessageTransmitterV2. + * If the message is a burn message, it will also handle the hook data + * and call the _onTokenReceived function. + * @param message Payload of the message to send + * @param attestation Attestation of the message + */ + function relay(bytes memory message, bytes memory attestation) + external + onlyOperator + { + ( + uint32 version, + uint32 sourceDomainID, + address sender, + address recipient, + bytes memory messageBody + ) = _decodeMessageHeader(message); + + // Ensure that it's a CCTP message + require( + version == CrossChainStrategyHelper.CCTP_MESSAGE_VERSION, + "Invalid CCTP message version" + ); + + // Ensure that the source domain is the peer domain + require(sourceDomainID == peerDomainID, "Unknown Source Domain"); + + // Ensure message body version + version = messageBody.extractUint32(BURN_MESSAGE_V2_VERSION_INDEX); + + // NOTE: There's a possibility that the CCTP Token Messenger might + // send other types of messages in future, not just the burn message. + // If it ever comes to that, this shouldn't cause us any problems + // because it has to still go through the followign checks: + // - version check + // - message body length check + // - sender and recipient (which should be in the same slots and same as address(this)) + // - hook data handling (which will revert even if all the above checks pass) + bool isBurnMessageV1 = sender == address(cctpTokenMessenger); + + if (isBurnMessageV1) { + // Handle burn message + require( + version == 1 && + messageBody.length >= BURN_MESSAGE_V2_HOOK_DATA_INDEX, + "Invalid burn message" + ); + + // Address of caller of depositForBurn (or depositForBurnWithCaller) on source domain + sender = messageBody.extractAddress( + BURN_MESSAGE_V2_MESSAGE_SENDER_INDEX + ); + + recipient = messageBody.extractAddress( + BURN_MESSAGE_V2_RECIPIENT_INDEX + ); + } else { + // We handle only Burn message or our custom messagee + require( + version == CrossChainStrategyHelper.ORIGIN_MESSAGE_VERSION, + "Unsupported message version" + ); + } + + // Ensure the recipient is this contract + // Both sender and recipient should be deployed to same address on both chains. + require(address(this) == recipient, "Unexpected recipient address"); + require(sender == peerStrategy, "Incorrect sender/recipient address"); + + // Relay the message + // This step also mints USDC and transfers it to the recipient wallet + bool relaySuccess = cctpMessageTransmitter.receiveMessage( + message, + attestation + ); + require(relaySuccess, "Receive message failed"); + + if (isBurnMessageV1) { + // Extract the hook data from the message body + bytes memory hookData = messageBody.extractSlice( + BURN_MESSAGE_V2_HOOK_DATA_INDEX, + messageBody.length + ); + + // Extract the token amount from the message body + uint256 tokenAmount = messageBody.extractUint256( + BURN_MESSAGE_V2_AMOUNT_INDEX + ); + + // Extract the fee executed from the message body + uint256 feeExecuted = messageBody.extractUint256( + BURN_MESSAGE_V2_FEE_EXECUTED_INDEX + ); + + // Call the _onTokenReceived function + _onTokenReceived(tokenAmount - feeExecuted, feeExecuted, hookData); + } + } + + /*************************************** + Message utils + ****************************************/ + /** + * @dev Decodes the CCTP message header + * @param message Message to decode + * @return version Version of the message + * @return sourceDomainID Source domain ID + * @return sender Sender of the message + * @return recipient Recipient of the message + * @return messageBody Message body + */ + function _decodeMessageHeader(bytes memory message) + internal + pure + returns ( + uint32 version, + uint32 sourceDomainID, + address sender, + address recipient, + bytes memory messageBody + ) + { + version = message.extractUint32(VERSION_INDEX); + sourceDomainID = message.extractUint32(SOURCE_DOMAIN_INDEX); + // Address of MessageTransmitterV2 caller on source domain + sender = message.extractAddress(SENDER_INDEX); + // Address to handle message body on destination domain + recipient = message.extractAddress(RECIPIENT_INDEX); + messageBody = message.extractSlice(MESSAGE_BODY_INDEX, message.length); + } + + /*************************************** + Nonce Handling + ****************************************/ + /** + * @dev Checks if the last known transfer is pending. + * Nonce starts at 1, so 0 is disregarded. + * @return True if a transfer is pending, false otherwise + */ + function isTransferPending() public view returns (bool) { + uint64 nonce = lastTransferNonce; + return nonce > 0 && !nonceProcessed[nonce]; + } + + /** + * @dev Checks if a given nonce is processed. + * Nonce starts at 1, so 0 is disregarded. + * @param nonce Nonce to check + * @return True if the nonce is processed, false otherwise + */ + function isNonceProcessed(uint64 nonce) public view returns (bool) { + return nonce == 0 || nonceProcessed[nonce]; + } + + /** + * @dev Marks a given nonce as processed. + * Can only mark nonce as processed once. New nonce should + * always be greater than the last known nonce. Also updates + * the last known nonce. + * @param nonce Nonce to mark as processed + */ + function _markNonceAsProcessed(uint64 nonce) internal { + uint64 lastNonce = lastTransferNonce; + + // Can only mark latest nonce as processed + require(nonce >= lastNonce, "Nonce too low"); + // Can only mark nonce as processed once + require(!nonceProcessed[nonce], "Nonce already processed"); + + nonceProcessed[nonce] = true; + + if (nonce != lastNonce) { + // Update last known nonce + lastTransferNonce = nonce; + } + } + + /** + * @dev Gets the next nonce to use. + * Nonce starts at 1, so 0 is disregarded. + * Reverts if last nonce hasn't been processed yet. + * @return Next nonce + */ + function _getNextNonce() internal returns (uint64) { + uint64 nonce = lastTransferNonce; + + require( + nonce == 0 || nonceProcessed[nonce], + "Pending deposit or withdrawal" + ); + + nonce = nonce + 1; + lastTransferNonce = nonce; + + return nonce; + } + + /*************************************** + Inheritence overrides + ****************************************/ + + /** + * @dev Called when the USDC is received from the CCTP + * @param tokenAmount The actual amount of USDC received (amount sent - fee executed) + * @param feeExecuted The fee executed + * @param payload The payload of the message (hook data) + */ + function _onTokenReceived( + uint256 tokenAmount, + uint256 feeExecuted, + bytes memory payload + ) internal virtual; + + /** + * @dev Called when the message is received + * @param payload The payload of the message + */ + function _onMessageReceived(bytes memory payload) internal virtual; +} diff --git a/contracts/contracts/strategies/crosschain/CrossChainMasterStrategy.sol b/contracts/contracts/strategies/crosschain/CrossChainMasterStrategy.sol new file mode 100644 index 0000000000..c9679fc3a6 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/CrossChainMasterStrategy.sol @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title OUSD Yearn V3 Master Strategy - the Mainnet part + * @author Origin Protocol Inc + * + * @dev This strategy can only perform 1 deposit or withdrawal at a time. For that + * reason it shouldn't be configured as an asset default strategy. + */ + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20, InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; +import { AbstractCCTPIntegrator } from "./AbstractCCTPIntegrator.sol"; +import { CrossChainStrategyHelper } from "./CrossChainStrategyHelper.sol"; + +contract CrossChainMasterStrategy is + AbstractCCTPIntegrator, + InitializableAbstractStrategy +{ + using SafeERC20 for IERC20; + using CrossChainStrategyHelper for bytes; + + /// @notice Remote strategy balance + uint256 public remoteStrategyBalance; + + /// @notice Amount that's bridged due to a pending Deposit process + /// but not yet received on the destination chain + uint256 public pendingAmount; + + event RemoteStrategyBalanceUpdated(uint256 balance); + event WithdrawRequested(address indexed asset, uint256 amount); + + /** + * @param _stratConfig The platform and OToken vault addresses + */ + constructor( + BaseStrategyConfig memory _stratConfig, + CCTPIntegrationConfig memory _cctpConfig + ) + InitializableAbstractStrategy(_stratConfig) + AbstractCCTPIntegrator(_cctpConfig) + {} + + /** + * @dev Initialize the strategy implementation + * @param _operator Address of the operator + * @param _minFinalityThreshold Minimum finality threshold + * @param _feePremiumBps Fee premium in basis points + */ + function initialize( + address _operator, + uint16 _minFinalityThreshold, + uint16 _feePremiumBps + ) external virtual onlyGovernor initializer { + _initialize(_operator, _minFinalityThreshold, _feePremiumBps); + + address[] memory rewardTokens = new address[](0); + address[] memory assets = new address[](0); + address[] memory pTokens = new address[](0); + + InitializableAbstractStrategy._initialize( + rewardTokens, + assets, + pTokens + ); + } + + /// @inheritdoc InitializableAbstractStrategy + function deposit(address _asset, uint256 _amount) + external + override + onlyVault + nonReentrant + { + _deposit(_asset, _amount); + } + + /// @inheritdoc InitializableAbstractStrategy + function depositAll() external override onlyVault nonReentrant { + uint256 balance = IERC20(usdcToken).balanceOf(address(this)); + if (balance > 0) { + _deposit(usdcToken, balance); + } + } + + /// @inheritdoc InitializableAbstractStrategy + function withdraw( + address _recipient, + address _asset, + uint256 _amount + ) external override onlyVault nonReentrant { + require(_recipient == vaultAddress, "Only Vault can withdraw"); + + _withdraw(_asset, _recipient, _amount); + } + + /// @inheritdoc InitializableAbstractStrategy + function withdrawAll() external override onlyVaultOrGovernor nonReentrant { + // Withdraw everything in Remote strategy + _withdraw(usdcToken, vaultAddress, remoteStrategyBalance); + } + + /** + * @notice Check the balance of the strategy that includes + * the balance of the asset on this contract, + * the amount of the asset being bridged, + * and the balance reported by the Remote strategy. + * @param _asset Address of the asset to check + * @return balance Total balance of the asset + */ + function checkBalance(address _asset) + public + view + override + returns (uint256 balance) + { + require(_asset == usdcToken, "Unsupported asset"); + + // USDC balance on this contract + // + USDC being bridged + // + USDC cached in the corresponding Remote part of this contract + return + IERC20(usdcToken).balanceOf(address(this)) + + pendingAmount + + remoteStrategyBalance; + } + + /// @inheritdoc InitializableAbstractStrategy + function supportsAsset(address _asset) public view override returns (bool) { + return _asset == usdcToken; + } + + /// @inheritdoc InitializableAbstractStrategy + function safeApproveAllTokens() + external + override + onlyGovernor + nonReentrant + {} + + /// @inheritdoc InitializableAbstractStrategy + function _abstractSetPToken(address, address) internal override {} + + /// @inheritdoc InitializableAbstractStrategy + function collectRewardTokens() + external + override + onlyHarvester + nonReentrant + {} + + /// @inheritdoc AbstractCCTPIntegrator + function _onMessageReceived(bytes memory payload) internal override { + if ( + payload.getMessageType() == + CrossChainStrategyHelper.BALANCE_CHECK_MESSAGE + ) { + // Received when Remote strategy checks the balance + _processBalanceCheckMessage(payload); + return; + } + + revert("Unknown message type"); + } + + /// @inheritdoc AbstractCCTPIntegrator + function _onTokenReceived( + uint256 tokenAmount, + // solhint-disable-next-line no-unused-vars + uint256 feeExecuted, + bytes memory payload + ) internal override { + uint64 _nonce = lastTransferNonce; + + // Should be expecting an acknowledgement + require(!isNonceProcessed(_nonce), "Nonce already processed"); + + // Now relay to the regular flow + // NOTE: Calling _onMessageReceived would mean that we are bypassing a + // few checks that the regular flow does (like sourceDomainID check + // and sender check in `handleReceiveFinalizedMessage`). However, + // CCTPMessageRelayer relays the message first (which will go through + // all the checks) and not update balance and then finally calls this + // `_onTokenReceived` which will update the balance. + // So, if any of the checks fail during the first no-balance-update flow, + // this won't happen either, since the tx would revert. + _onMessageReceived(payload); + + // Send any tokens in the contract to the Vault + uint256 usdcBalance = IERC20(usdcToken).balanceOf(address(this)); + // Should always have enough tokens + require(usdcBalance >= tokenAmount, "Insufficient balance"); + // Transfer all tokens to the Vault to not leave any dust + IERC20(usdcToken).safeTransfer(vaultAddress, usdcBalance); + + // Emit withdrawal amount + emit Withdrawal(usdcToken, usdcToken, usdcBalance); + } + + /** + * @dev Bridge and deposit asset into the remote strategy + * @param _asset Address of the asset to deposit + * @param depositAmount Amount of the asset to deposit + */ + function _deposit(address _asset, uint256 depositAmount) internal virtual { + require(_asset == usdcToken, "Unsupported asset"); + require(pendingAmount == 0, "Unexpected pending amount"); + require(depositAmount > 0, "Must deposit somethin"); + require( + depositAmount <= MAX_TRANSFER_AMOUNT, + "Deposit amount too high" + ); + + // Get the next nonce + // Note: reverts if a transfer is pending + uint64 nonce = _getNextNonce(); + + // Set pending amount + pendingAmount = depositAmount; + + // Build deposit message payload + bytes memory message = CrossChainStrategyHelper.encodeDepositMessage( + nonce, + depositAmount + ); + + // Send deposit message to the remote strategy + _sendTokens(depositAmount, message); + + // Emit deposit event + emit Deposit(_asset, _asset, depositAmount); + } + + /** + * @dev Send a withdraw request to the remote strategy + * @param _asset Address of the asset to withdraw + * @param _recipient Address to receive the withdrawn asset + * @param _amount Amount of the asset to withdraw + */ + function _withdraw( + address _asset, + address _recipient, + uint256 _amount + ) internal virtual { + require(_asset == usdcToken, "Unsupported asset"); + require(_amount > 0, "Withdraw amount must be greater than 0"); + require(_recipient == vaultAddress, "Only Vault can withdraw"); + require( + _amount <= remoteStrategyBalance, + "Withdraw amount exceeds remote strategy balance" + ); + require( + _amount <= MAX_TRANSFER_AMOUNT, + "Withdraw amount exceeds max transfer amount" + ); + + // Get the next nonce + // Note: reverts if a transfer is pending + uint64 nonce = _getNextNonce(); + + // Build and send withdrawal message with payload + bytes memory message = CrossChainStrategyHelper.encodeWithdrawMessage( + nonce, + _amount + ); + _sendMessage(message); + + // Emit WithdrawRequested event here, + // Withdraw will be emitted in _onTokenReceived + emit WithdrawRequested(usdcToken, _amount); + } + + /** + * @dev Process balance check: + * - Confirms a deposit to the remote strategy + * - Skips balance update if there's a pending withdrawal + * - Updates the remote strategy balance + * @param message The message containing the nonce and balance + */ + function _processBalanceCheckMessage(bytes memory message) + internal + virtual + { + // Decode the message + // When transferConfirmation is true, it means that the message is a result of a deposit or a withdrawal + // process. + (uint64 nonce, uint256 balance, bool transferConfirmation) = message + .decodeBalanceCheckMessage(); + // Get the last cached nonce + uint64 _lastCachedNonce = lastTransferNonce; + + if (nonce != _lastCachedNonce) { + // If nonce is not the last cached nonce, it is an outdated message + // Ignore it + return; + } + + // A received message nonce not yet processed indicates there is a + // deposit or withdrawal in progress. + bool transferInProgress = isTransferPending(); + + if (transferInProgress) { + if (transferConfirmation) { + // Apply the effects of the deposit / withdrawal completion + _markNonceAsProcessed(nonce); + pendingAmount = 0; + } else { + // A balanceCheck arrived that is not part of the deposit / withdrawal process + // that has been generated on the Remote contract after the deposit / withdrawal which is + // still pending. This can happen when the CCTP bridge delivers the messages out of order. + // Ignore it, since the pending deposit / withdrawal must first be cofirmed. + return; + } + } + + // At this point update the strategy balance the balanceCheck message is either: + // - a confirmation of a deposit / withdrawal + // - a message that updates balances when no deposit / withdrawal is in progress + remoteStrategyBalance = balance; + emit RemoteStrategyBalanceUpdated(balance); + } +} diff --git a/contracts/contracts/strategies/crosschain/CrossChainRemoteStrategy.sol b/contracts/contracts/strategies/crosschain/CrossChainRemoteStrategy.sol new file mode 100644 index 0000000000..dafa026bea --- /dev/null +++ b/contracts/contracts/strategies/crosschain/CrossChainRemoteStrategy.sol @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title CrossChainRemoteStrategy + * @author Origin Protocol Inc + * + * @dev Part of the cross-chain strategy that lives on the remote chain. + * Handles deposits and withdrawals from the master strategy on peer chain + * and locally deposits the funds to a 4626 compatible vault. + */ + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "../../utils/InitializableAbstractStrategy.sol"; +import { IERC4626 } from "../../../lib/openzeppelin/interfaces/IERC4626.sol"; +import { Generalized4626Strategy } from "../Generalized4626Strategy.sol"; +import { AbstractCCTPIntegrator } from "./AbstractCCTPIntegrator.sol"; +import { CrossChainStrategyHelper } from "./CrossChainStrategyHelper.sol"; +import { InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; + +contract CrossChainRemoteStrategy is + AbstractCCTPIntegrator, + Generalized4626Strategy +{ + using SafeERC20 for IERC20; + using CrossChainStrategyHelper for bytes; + + event DepositUnderlyingFailed(string reason); + event WithdrawFailed(uint256 amountRequested, uint256 amountAvailable); + event WithdrawUnderlyingFailed(string reason); + event StrategistUpdated(address _address); + + /** + * @notice Address of the strategist. + * This is important to have the variable name same as in IVault. + * Because the parent contract (Generalized4626Strategy) uses this + * function to get the strategist address. + */ + address public strategistAddr; + + modifier onlyOperatorOrStrategistOrGovernor() { + require( + msg.sender == operator || + msg.sender == strategistAddr || + isGovernor(), + "Caller is not the Operator, Strategist or the Governor" + ); + _; + } + + constructor( + BaseStrategyConfig memory _baseConfig, + CCTPIntegrationConfig memory _cctpConfig + ) + AbstractCCTPIntegrator(_cctpConfig) + Generalized4626Strategy(_baseConfig, _cctpConfig.usdcToken) + { + require(usdcToken == address(assetToken), "Token mismatch"); + + // NOTE: Vault address must always be the proxy address + // so that IVault(vaultAddress).strategistAddr() works + } + + /** + * @dev Initialize the strategy implementation + * @param _strategist Address of the strategist + * @param _operator Address of the operator + * @param _minFinalityThreshold Minimum finality threshold + * @param _feePremiumBps Fee premium in basis points + */ + function initialize( + address _strategist, + address _operator, + uint16 _minFinalityThreshold, + uint16 _feePremiumBps + ) external virtual onlyGovernor initializer { + _initialize(_operator, _minFinalityThreshold, _feePremiumBps); + _setStrategistAddr(_strategist); + + address[] memory rewardTokens = new address[](0); + address[] memory assets = new address[](1); + address[] memory pTokens = new address[](1); + + assets[0] = address(usdcToken); + pTokens[0] = address(platformAddress); + + InitializableAbstractStrategy._initialize( + rewardTokens, + assets, + pTokens + ); + } + + /** + * @notice Set address of Strategist. + * This is important to have the function name same as IVault. + * Because the parent contract (Generalized4626Strategy) uses this + * function to get/set the strategist address. + * @param _address Address of Strategist + */ + function setStrategistAddr(address _address) external onlyGovernor { + _setStrategistAddr(_address); + } + + /** + * @dev Set the strategist address + * @param _address Address of the strategist + */ + function _setStrategistAddr(address _address) internal { + strategistAddr = _address; + emit StrategistUpdated(_address); + } + + /// @inheritdoc Generalized4626Strategy + function deposit(address _asset, uint256 _amount) + external + virtual + override + onlyGovernorOrStrategist + { + _deposit(_asset, _amount); + } + + /// @inheritdoc Generalized4626Strategy + function depositAll() external virtual override onlyGovernorOrStrategist { + _deposit(usdcToken, IERC20(usdcToken).balanceOf(address(this))); + } + + /// @inheritdoc Generalized4626Strategy + function withdraw( + address _recipient, + address _asset, + uint256 _amount + ) external virtual override onlyGovernorOrStrategist { + _withdraw(_recipient, _asset, _amount); + } + + /// @inheritdoc Generalized4626Strategy + function withdrawAll() external virtual override onlyGovernorOrStrategist { + IERC4626 platform = IERC4626(platformAddress); + _withdraw( + address(this), + usdcToken, + platform.previewRedeem(platform.balanceOf(address(this))) + ); + } + + /// @inheritdoc AbstractCCTPIntegrator + function _onMessageReceived(bytes memory payload) internal override { + uint32 messageType = payload.getMessageType(); + if (messageType == CrossChainStrategyHelper.DEPOSIT_MESSAGE) { + // Received when Master strategy sends tokens to the remote strategy + // Do nothing because we receive acknowledgement with token transfer, + // so _onTokenReceived will handle it + } else if (messageType == CrossChainStrategyHelper.WITHDRAW_MESSAGE) { + // Received when Master strategy requests a withdrawal + _processWithdrawMessage(payload); + } else { + revert("Unknown message type"); + } + } + + /** + * @dev Process deposit message from peer strategy + * @param tokenAmount Amount of tokens received + * @param feeExecuted Fee executed + * @param payload Payload of the message + */ + function _processDepositMessage( + // solhint-disable-next-line no-unused-vars + uint256 tokenAmount, + // solhint-disable-next-line no-unused-vars + uint256 feeExecuted, + bytes memory payload + ) internal virtual { + (uint64 nonce, ) = payload.decodeDepositMessage(); + + // Replay protection + require(!isNonceProcessed(nonce), "Nonce already processed"); + _markNonceAsProcessed(nonce); + + // Deposit everything we got, not just what was bridged + uint256 balance = IERC20(usdcToken).balanceOf(address(this)); + + // Underlying call to deposit funds can fail. It mustn't affect the overall + // flow as confirmation message should still be sent. + _deposit(usdcToken, balance); + + // Send balance check message to the peer strategy + uint256 balanceAfter = checkBalance(usdcToken); + bytes memory message = CrossChainStrategyHelper + .encodeBalanceCheckMessage(lastTransferNonce, balanceAfter, true); + _sendMessage(message); + } + + /** + * @dev Deposit assets by converting them to shares + * @param _asset Address of asset to deposit + * @param _amount Amount of asset to deposit + */ + function _deposit(address _asset, uint256 _amount) internal override { + require(_amount > 0, "Must deposit something"); + require(_asset == address(usdcToken), "Unexpected asset address"); + + // This call can fail, and the failure doesn't need to bubble up to the _processDepositMessage function + // as the flow is not affected by the failure. + + try IERC4626(platformAddress).deposit(_amount, address(this)) { + emit Deposit(_asset, address(shareToken), _amount); + } catch Error(string memory reason) { + emit DepositUnderlyingFailed( + string(abi.encodePacked("Deposit failed: ", reason)) + ); + } catch (bytes memory lowLevelData) { + emit DepositUnderlyingFailed( + string( + abi.encodePacked( + "Deposit failed: low-level call failed with data ", + lowLevelData + ) + ) + ); + } + } + + /** + * @dev Process withdrawal message from peer strategy + * @param payload Payload of the message + */ + function _processWithdrawMessage(bytes memory payload) internal virtual { + (uint64 nonce, uint256 withdrawAmount) = payload + .decodeWithdrawMessage(); + + // Replay protection + require(!isNonceProcessed(nonce), "Nonce already processed"); + _markNonceAsProcessed(nonce); + + uint256 usdcBalance = IERC20(usdcToken).balanceOf(address(this)); + + if (usdcBalance < withdrawAmount) { + // Withdraw the missing funds from the remote strategy. This call can fail and + // the failure doesn't bubble up to the _processWithdrawMessage function + _withdraw(address(this), usdcToken, withdrawAmount - usdcBalance); + + // Update the possible increase in the balance on the contract. + usdcBalance = IERC20(usdcToken).balanceOf(address(this)); + } + + // Check balance after withdrawal + uint256 balanceAfter = checkBalance(usdcToken); + + // If there are some tokens to be sent AND the balance is sufficient + // to satisfy the withdrawal request then send the funds to the peer strategy. + // In case a direct withdraw(All) has previously been called + // there is a possibility of USDC funds remaining on the contract. + // A separate withdraw to extract or deposit to the Morpho vault needs to be + // initiated from the peer Master strategy to utilise USDC funds. + if (usdcBalance > 1e6 && usdcBalance >= withdrawAmount) { + // The new balance on the contract needs to have USDC subtracted from it as + // that will be withdrawn in the next step + bytes memory message = CrossChainStrategyHelper + .encodeBalanceCheckMessage( + lastTransferNonce, + balanceAfter - withdrawAmount, + true + ); + _sendTokens(withdrawAmount, message); + } else { + // Contract either: + // - only has a small dust + // - doesn't have sufficient funds to satisfy the withdrawal request + // In both cases send the balance update message to the peer strategy. + bytes memory message = CrossChainStrategyHelper + .encodeBalanceCheckMessage( + lastTransferNonce, + balanceAfter, + true + ); + _sendMessage(message); + emit WithdrawFailed(withdrawAmount, usdcBalance); + } + } + + /** + * @dev Withdraw asset by burning shares + * @param _recipient Address to receive withdrawn asset + * @param _asset Address of asset to withdraw + * @param _amount Amount of asset to withdraw + */ + function _withdraw( + address _recipient, + address _asset, + uint256 _amount + ) internal override { + require(_amount > 0, "Must withdraw something"); + require(_recipient == address(this), "Invalid recipient"); + require(_asset == address(usdcToken), "Unexpected asset address"); + + // This call can fail, and the failure doesn't need to bubble up to the _processWithdrawMessage function + // as the flow is not affected by the failure. + try + // slither-disable-next-line unused-return + IERC4626(platformAddress).withdraw( + _amount, + address(this), + address(this) + ) + { + emit Withdrawal(_asset, address(shareToken), _amount); + } catch Error(string memory reason) { + emit WithdrawUnderlyingFailed( + string(abi.encodePacked("Withdrawal failed: ", reason)) + ); + } catch (bytes memory lowLevelData) { + emit WithdrawUnderlyingFailed( + string( + abi.encodePacked( + "Withdrawal failed: low-level call failed with data ", + lowLevelData + ) + ) + ); + } + } + + /** + * @dev Process token received message from peer strategy + * @param tokenAmount Amount of tokens received + * @param feeExecuted Fee executed + * @param payload Payload of the message + */ + function _onTokenReceived( + uint256 tokenAmount, + uint256 feeExecuted, + bytes memory payload + ) internal override { + uint32 messageType = payload.getMessageType(); + + require( + messageType == CrossChainStrategyHelper.DEPOSIT_MESSAGE, + "Invalid message type" + ); + + _processDepositMessage(tokenAmount, feeExecuted, payload); + } + + /** + * @dev Send balance update message to the peer strategy + */ + function sendBalanceUpdate() + external + virtual + onlyOperatorOrStrategistOrGovernor + { + uint256 balance = checkBalance(usdcToken); + bytes memory message = CrossChainStrategyHelper + .encodeBalanceCheckMessage(lastTransferNonce, balance, false); + _sendMessage(message); + } + + /** + * @notice Get the total asset value held in the platform and contract + * @param _asset Address of the asset + * @return balance Total value of the asset in the platform and contract + */ + function checkBalance(address _asset) + public + view + override + returns (uint256) + { + require(_asset == usdcToken, "Unexpected asset address"); + /** + * Balance of USDC on the contract is counted towards the total balance, since a deposit + * to the Morpho V2 might fail and the USDC might remain on this contract as a result of a + * bridged transfer. + */ + uint256 balanceOnContract = IERC20(usdcToken).balanceOf(address(this)); + + IERC4626 platform = IERC4626(platformAddress); + return + platform.previewRedeem(platform.balanceOf(address(this))) + + balanceOnContract; + } +} diff --git a/contracts/contracts/strategies/crosschain/CrossChainStrategyHelper.sol b/contracts/contracts/strategies/crosschain/CrossChainStrategyHelper.sol new file mode 100644 index 0000000000..44cf12e1d3 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/CrossChainStrategyHelper.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title CrossChainStrategyHelper + * @author Origin Protocol Inc + * @dev This library is used to encode and decode the messages for the cross-chain strategy. + * It is used to ensure that the messages are valid and to get the message version and type. + */ + +import { BytesHelper } from "../../utils/BytesHelper.sol"; + +library CrossChainStrategyHelper { + using BytesHelper for bytes; + + uint32 public constant DEPOSIT_MESSAGE = 1; + uint32 public constant WITHDRAW_MESSAGE = 2; + uint32 public constant BALANCE_CHECK_MESSAGE = 3; + + uint32 public constant CCTP_MESSAGE_VERSION = 1; + uint32 public constant ORIGIN_MESSAGE_VERSION = 1010; + + /** + * @dev Get the message version from the message. + * It should always be 4 bytes long, + * starting from the 0th index. + * @param message The message to get the version from + * @return The message version + */ + function getMessageVersion(bytes memory message) + internal + pure + returns (uint32) + { + // uint32 bytes 0 to 4 is Origin message version + // uint32 bytes 4 to 8 is Message type + return message.extractUint32(0); + } + + /** + * @dev Get the message type from the message. + * It should always be 4 bytes long, + * starting from the 4th index. + * @param message The message to get the type from + * @return The message type + */ + function getMessageType(bytes memory message) + internal + pure + returns (uint32) + { + // uint32 bytes 0 to 4 is Origin message version + // uint32 bytes 4 to 8 is Message type + return message.extractUint32(4); + } + + /** + * @dev Verify the message version and type. + * The message version should be the same as the Origin message version, + * and the message type should be the same as the expected message type. + * @param _message The message to verify + * @param _type The expected message type + */ + function verifyMessageVersionAndType(bytes memory _message, uint32 _type) + internal + pure + { + require( + getMessageVersion(_message) == ORIGIN_MESSAGE_VERSION, + "Invalid Origin Message Version" + ); + require(getMessageType(_message) == _type, "Invalid Message type"); + } + + /** + * @dev Get the message payload from the message. + * The payload starts at the 8th byte. + * @param message The message to get the payload from + * @return The message payload + */ + function getMessagePayload(bytes memory message) + internal + pure + returns (bytes memory) + { + // uint32 bytes 0 to 4 is Origin message version + // uint32 bytes 4 to 8 is Message type + // Payload starts at byte 8 + return message.extractSlice(8, message.length); + } + + /** + * @dev Encode the deposit message. + * The message version and type are always encoded in the message. + * @param nonce The nonce of the deposit + * @param depositAmount The amount of the deposit + * @return The encoded deposit message + */ + function encodeDepositMessage(uint64 nonce, uint256 depositAmount) + internal + pure + returns (bytes memory) + { + return + abi.encodePacked( + ORIGIN_MESSAGE_VERSION, + DEPOSIT_MESSAGE, + abi.encode(nonce, depositAmount) + ); + } + + /** + * @dev Decode the deposit message. + * The message version and type are verified in the message. + * @param message The message to decode + * @return The nonce and the amount of the deposit + */ + function decodeDepositMessage(bytes memory message) + internal + pure + returns (uint64, uint256) + { + verifyMessageVersionAndType(message, DEPOSIT_MESSAGE); + + (uint64 nonce, uint256 depositAmount) = abi.decode( + getMessagePayload(message), + (uint64, uint256) + ); + return (nonce, depositAmount); + } + + /** + * @dev Encode the withdrawal message. + * The message version and type are always encoded in the message. + * @param nonce The nonce of the withdrawal + * @param withdrawAmount The amount of the withdrawal + * @return The encoded withdrawal message + */ + function encodeWithdrawMessage(uint64 nonce, uint256 withdrawAmount) + internal + pure + returns (bytes memory) + { + return + abi.encodePacked( + ORIGIN_MESSAGE_VERSION, + WITHDRAW_MESSAGE, + abi.encode(nonce, withdrawAmount) + ); + } + + /** + * @dev Decode the withdrawal message. + * The message version and type are verified in the message. + * @param message The message to decode + * @return The nonce and the amount of the withdrawal + */ + function decodeWithdrawMessage(bytes memory message) + internal + pure + returns (uint64, uint256) + { + verifyMessageVersionAndType(message, WITHDRAW_MESSAGE); + + (uint64 nonce, uint256 withdrawAmount) = abi.decode( + getMessagePayload(message), + (uint64, uint256) + ); + return (nonce, withdrawAmount); + } + + /** + * @dev Encode the balance check message. + * The message version and type are always encoded in the message. + * @param nonce The nonce of the balance check + * @param balance The balance to check + * @param transferConfirmation Indicates if the message is a transfer confirmation. This is true + * when the message is a result of a deposit or a withdrawal. + * @return The encoded balance check message + */ + function encodeBalanceCheckMessage( + uint64 nonce, + uint256 balance, + bool transferConfirmation + ) internal pure returns (bytes memory) { + return + abi.encodePacked( + ORIGIN_MESSAGE_VERSION, + BALANCE_CHECK_MESSAGE, + abi.encode(nonce, balance, transferConfirmation) + ); + } + + /** + * @dev Decode the balance check message. + * The message version and type are verified in the message. + * @param message The message to decode + * @return The nonce, the balance and indicates if the message is a transfer confirmation + */ + function decodeBalanceCheckMessage(bytes memory message) + internal + pure + returns ( + uint64, + uint256, + bool + ) + { + verifyMessageVersionAndType(message, BALANCE_CHECK_MESSAGE); + + (uint64 nonce, uint256 balance, bool transferConfirmation) = abi.decode( + getMessagePayload(message), + (uint64, uint256, bool) + ); + return (nonce, balance, transferConfirmation); + } +} diff --git a/contracts/contracts/strategies/crosschain/crosschain-strategy.md b/contracts/contracts/strategies/crosschain/crosschain-strategy.md new file mode 100644 index 0000000000..aecb142b93 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/crosschain-strategy.md @@ -0,0 +1,698 @@ +# Cross-Chain Strategy Documentation + +## Overview + +The Cross-Chain Strategy enables OUSD Vault to deploy funds across multiple EVM chains using Circle's Cross-Chain Transfer Protocol (CCTP). The strategy consists of two main contracts: + +- **CrossChainMasterStrategy**: Deployed on Ethereum (same chain as OUSD Vault), acts as the primary strategy interface +- **CrossChainRemoteStrategy**: Deployed on a remote EVM chain (e.g., Base), manages funds in a 4626-compatible vault + +### Key Design Decisions + +- **Single Pending Transfer**: Only one deposit or withdrawal can be in-flight at a time to simplify state management and prevent race conditions +- **Nonce-Based Ordering**: All transfers use incrementing nonces to ensure proper sequencing and prevent replay attacks +- **CCTP Integration**: Uses Circle's CCTP for secure cross-chain token transfers and message passing + +--- + +## Architecture + +### High-Level Flow + +```mermaid +graph TB + subgraph Ethereum["Ethereum Chain"] + Vault[OUSD Vault] + Master[CrossChainMasterStrategy] + CCTPTokenMessenger1[CCTP Token Messenger] + CCTPMessageTransmitter1[CCTP Message Transmitter] + end + + subgraph Remote["Remote Chain (Base)"] + RemoteStrategy[CrossChainRemoteStrategy] + Vault4626[4626 Vault] + CCTPTokenMessenger2[CCTP Token Messenger] + CCTPMessageTransmitter2[CCTP Message Transmitter] + end + + Vault -->|deposit/withdraw| Master + Master -->|bridge USDC + messages| CCTPTokenMessenger1 + Master -->|send messages| CCTPMessageTransmitter1 + + CCTPTokenMessenger1 -.->|CCTP Bridge| CCTPTokenMessenger2 + CCTPMessageTransmitter1 -.->|CCTP Bridge| CCTPMessageTransmitter2 + + CCTPTokenMessenger2 -->|mint USDC| RemoteStrategy + CCTPMessageTransmitter2 -->|deliver messages| RemoteStrategy + + RemoteStrategy -->|deposit/withdraw| Vault4626 + RemoteStrategy -->|send balance updates| CCTPMessageTransmitter2 + + style Ethereum fill:#e1f5ff + style Remote fill:#fff4e1 + style Master fill:#c8e6c9 + style RemoteStrategy fill:#c8e6c9 + style Vault fill:#ffccbc + style Vault4626 fill:#ffccbc +``` + +### Contract Inheritance + +```mermaid +classDiagram + class Governable { + <> + +governor: address + +onlyGovernor() + } + + class AbstractCCTPIntegrator { + <> + +cctpMessageTransmitter: ICCTPMessageTransmitter + +cctpTokenMessenger: ICCTPTokenMessenger + +usdcToken: address + +peerDomainID: uint32 + +peerStrategy: address + +lastTransferNonce: uint64 + +operator: address + +_sendTokens(amount, hookData) + +_sendMessage(message) + +relay(message, attestation) + +_getNextNonce() uint64 + +_markNonceAsProcessed(nonce) + +_onTokenReceived()* void + +_onMessageReceived()* void + } + + class InitializableAbstractStrategy { + <> + +vaultAddress: address + +deposit(asset, amount) + +withdraw(recipient, asset, amount) + +checkBalance(asset) uint256 + } + + class Generalized4626Strategy { + <> + +platformAddress: address + +assetToken: address + +shareToken: address + +_deposit(asset, amount) + +_withdraw(recipient, asset, amount) + } + + class CrossChainMasterStrategy { + +remoteStrategyBalance: uint256 + +pendingAmount: uint256 + +transferTypeByNonce: mapping + +deposit(asset, amount) + +withdraw(recipient, asset, amount) + +checkBalance(asset) uint256 + +_processBalanceCheckMessage(message) + } + + class CrossChainRemoteStrategy { + +strategistAddr: address + +deposit(asset, amount) + +withdraw(recipient, asset, amount) + +checkBalance(asset) uint256 + +sendBalanceUpdate() + +_processDepositMessage(tokenAmount, fee, payload) + +_processWithdrawMessage(payload) + } + + Governable <|-- AbstractCCTPIntegrator + AbstractCCTPIntegrator <|-- CrossChainMasterStrategy + AbstractCCTPIntegrator <|-- CrossChainRemoteStrategy + InitializableAbstractStrategy <|-- CrossChainMasterStrategy + Generalized4626Strategy <|-- CrossChainRemoteStrategy + + note for AbstractCCTPIntegrator "_onTokenReceived() and\n_onMessageReceived() are\nabstract functions" + note for CrossChainMasterStrategy "Deployed on Ethereum\nInterfaces with OUSD Vault" + note for CrossChainRemoteStrategy "Deployed on Remote Chain\nManages 4626 Vault" +``` + +--- + +## Contracts and Libraries + +### AbstractCCTPIntegrator + +**Purpose**: Base contract providing CCTP integration functionality shared by both Master and Remote strategies. + +**Key Responsibilities**: +- CCTP message handling (`handleReceiveFinalizedMessage`, `handleReceiveUnfinalizedMessage`) +- Token bridging via CCTP Token Messenger (`_sendTokens`) +- Message sending via CCTP Message Transmitter (`_sendMessage`) +- Message relaying by operators (`relay`) +- Nonce management for transfer ordering +- Security checks (domain validation, sender validation) + +**Key State Variables**: +- `cctpMessageTransmitter`: CCTP Message Transmitter contract +- `cctpTokenMessenger`: CCTP Token Messenger contract +- `usdcToken`: USDC address on local chain +- `peerDomainID`: Domain ID of the peer chain +- `peerStrategy`: Address of the strategy on peer chain +- `minFinalityThreshold`: Minimum finality threshold (1000 or 2000) +- `feePremiumBps`: Fee premium in basis points (max 3000) +- `lastTransferNonce`: Last known transfer nonce +- `nonceProcessed`: Mapping of processed nonces +- `operator`: Address authorized to relay messages + +**Key Functions**: +- `_sendTokens(uint256 tokenAmount, bytes memory hookData)`: Bridges USDC via CCTP with hook data +- `_sendMessage(bytes memory message)`: Sends a message via CCTP +- `relay(bytes memory message, bytes memory attestation)`: Relays a finalized CCTP message (operator-only) +- `_getNextNonce()`: Gets and increments the next nonce +- `_markNonceAsProcessed(uint64 nonce)`: Marks a nonce as processed +- `isTransferPending()`: Checks if there's a pending transfer +- `isNonceProcessed(uint64 nonce)`: Checks if a nonce has been processed + +**Abstract Functions** (implemented by child contracts): +- `_onTokenReceived(uint256 tokenAmount, uint256 feeExecuted, bytes memory payload)`: Called when USDC is received via CCTP +- `_onMessageReceived(bytes memory payload)`: Called when a message is received + +### CrossChainMasterStrategy + +**Purpose**: Strategy deployed on Ethereum that interfaces with OUSD Vault and coordinates with Remote strategy. + +**Key Responsibilities**: +- Receiving deposits from OUSD Vault +- Initiating withdrawals requested by OUSD Vault +- Tracking remote strategy balance +- Managing pending transfer state +- Processing balance check messages from Remote strategy + +**Key State Variables**: +- `remoteStrategyBalance`: Cached balance of funds in Remote strategy +- `pendingAmount`: Amount bridged but not yet confirmed received +- `transferTypeByNonce`: Mapping of nonce to transfer type (Deposit/Withdrawal) + +**Key Functions**: +- `deposit(address _asset, uint256 _amount)`: Called by Vault to deposit funds +- `withdraw(address _recipient, address _asset, uint256 _amount)`: Called by Vault to withdraw funds +- `checkBalance(address _asset)`: Returns total balance (local + pending + remote) +- `_deposit(address _asset, uint256 depositAmount)`: Internal deposit handler +- `_withdraw(address _asset, address _recipient, uint256 _amount)`: Internal withdrawal handler +- `_processBalanceCheckMessage(bytes memory message)`: Processes balance check from Remote + +**Deposit Flow**: +1. Validate no pending transfer exists +2. Get next nonce and mark as Deposit type +3. Set `pendingAmount` +4. Bridge USDC via CCTP with deposit message in hook data +5. Wait for balance check message to confirm + +**Withdrawal Flow**: +1. Validate no pending transfer exists +2. Validate sufficient remote balance +3. Get next nonce and mark as Withdrawal type +4. Send withdrawal message via CCTP +5. Wait for tokens to be bridged back with balance check in hook data + +**Balance Check Processing**: +- Validates nonce matches `lastTransferNonce` +- Updates `remoteStrategyBalance` +- If pending deposit: marks nonce as processed, resets `pendingAmount` +- If pending withdrawal: skips balance update (handled in `_onTokenReceived`) + +### CrossChainRemoteStrategy + +**Purpose**: Strategy deployed on remote chain that manages funds in a 4626 vault and responds to Master strategy commands. + +**Key Responsibilities**: +- Receiving bridged USDC from Master strategy +- Depositing to 4626 vault +- Withdrawing from 4626 vault +- Sending balance check messages to Master strategy +- Managing strategist permissions + +**Key State Variables**: +- `strategistAddr`: Address of strategist (for compatibility with Generalized4626Strategy) + +**Key Functions**: +- `deposit(address _asset, uint256 _amount)`: Deposits to 4626 vault (governor/strategist only) +- `withdraw(address _recipient, address _asset, uint256 _amount)`: Withdraws from 4626 vault (governor/strategist only) +- `checkBalance(address _asset)`: Returns total balance (4626 vault + contract balance) +- `sendBalanceUpdate()`: Manually sends balance check message (operator/strategist/governor) +- `_processDepositMessage(uint256 tokenAmount, uint256 feeExecuted, bytes memory payload)`: Handles deposit message +- `_processWithdrawMessage(bytes memory payload)`: Handles withdrawal message +- `_deposit(address _asset, uint256 _amount)`: Internal deposit to 4626 vault (with error handling) +- `_withdraw(address _recipient, address _asset, uint256 _amount)`: Internal withdrawal from 4626 vault (with error handling) + +**Deposit Message Handling**: +1. Decode nonce and amount from payload +2. Verify nonce not already processed +3. Mark nonce as processed +4. Deposit all USDC balance to 4626 vault (may fail silently) +5. Send balance check message with updated balance + +**Withdrawal Message Handling**: +1. Decode nonce and amount from payload +2. Verify nonce not already processed +3. Mark nonce as processed +4. Withdraw from 4626 vault (may fail silently) +5. Bridge USDC back to Master (if balance > 1e6) with balance check in hook data +6. If balance <= 1e6, send balance check message only + +### CrossChainStrategyHelper + +**Purpose**: Library for encoding and decoding cross-chain messages. + +**Message Constants**: +- `DEPOSIT_MESSAGE = 1` +- `WITHDRAW_MESSAGE = 2` +- `BALANCE_CHECK_MESSAGE = 3` +- `CCTP_MESSAGE_VERSION = 1` +- `ORIGIN_MESSAGE_VERSION = 1010` + +**Message Format**: +``` +[0-4 bytes]: Origin Message Version (1010) +[4-8 bytes]: Message Type (1, 2, or 3) +[8+ bytes]: Message Payload (ABI-encoded) +``` + +**Key Functions**: +- `encodeDepositMessage(uint64 nonce, uint256 depositAmount)`: Encodes deposit message +- `decodeDepositMessage(bytes memory message)`: Decodes deposit message +- `encodeWithdrawMessage(uint64 nonce, uint256 withdrawAmount)`: Encodes withdrawal message +- `decodeWithdrawMessage(bytes memory message)`: Decodes withdrawal message +- `encodeBalanceCheckMessage(uint64 nonce, uint256 balance)`: Encodes balance check message +- `decodeBalanceCheckMessage(bytes memory message)`: Decodes balance check message +- `getMessageVersion(bytes memory message)`: Extracts message version +- `getMessageType(bytes memory message)`: Extracts message type +- `verifyMessageVersionAndType(bytes memory _message, uint32 _type)`: Validates message format + +**Message Payloads**: +- **Deposit**: `abi.encode(nonce, depositAmount)` +- **Withdraw**: `abi.encode(nonce, withdrawAmount)` +- **Balance Check**: `abi.encode(nonce, balance)` + +### BytesHelper + +**Purpose**: Utility library for extracting typed data from byte arrays. + +**Key Functions**: +- `extractSlice(bytes memory data, uint256 start, uint256 end)`: Extracts a byte slice +- `extractUint32(bytes memory data, uint256 start)`: Extracts uint32 at offset +- `extractUint256(bytes memory data, uint256 start)`: Extracts uint256 at offset +- `extractAddress(bytes memory data, uint256 start)`: Extracts address at offset (32-byte padded) + +**Usage**: Used by `CrossChainStrategyHelper` and `AbstractCCTPIntegrator` to parse CCTP message headers and bodies. + +--- + +## Message Protocol + +### CCTP Message Structure + +CCTP messages have a header and body: + +**Header**: +Ref: https://developers.circle.com/cctp/technical-guide#message-header + +**Message Body for Burn Messages (V2)**: +Ref: https://developers.circle.com/cctp/technical-guide#message-body +Ref: https://github.com/circlefin/evm-cctp-contracts/blob/master/src/messages/v2/BurnMessageV2.sol + +### Origin's Custom Message Body + +All Origin messages follow this format: +``` +[0-4 bytes]: ORIGIN_MESSAGE_VERSION (1010) +[4-8 bytes]: MESSAGE_TYPE (1, 2, or 3) +[8+ bytes]: Payload (ABI-encoded) +``` + +### Message Types + +#### 1. Deposit Message + +**Sent By**: Master Strategy +**Sent Via**: CCTP Token Messenger (as hook data) +**Contains**: +- Nonce (uint64) +- Deposit Amount (uint256) + +**Encoding**: `abi.encodePacked(ORIGIN_MESSAGE_VERSION, DEPOSIT_MESSAGE, abi.encode(nonce, depositAmount))` + +**Flow**: +1. Master bridges USDC with deposit message as hook data +2. Remote receives USDC and hook data via `_onTokenReceived` +3. Remote deposits to 4626 vault +4. Remote sends balance check message + +#### 2. Withdraw Message + +**Sent By**: Master Strategy +**Sent Via**: CCTP Message Transmitter +**Contains**: +- Nonce (uint64) +- Withdraw Amount (uint256) + +**Encoding**: `abi.encodePacked(ORIGIN_MESSAGE_VERSION, WITHDRAW_MESSAGE, abi.encode(nonce, withdrawAmount))` + +**Flow**: +1. Master sends withdrawal message +2. Remote receives via `_onMessageReceived` +3. Remote withdraws from 4626 vault +4. Remote bridges USDC back with balance check as hook data +5. Master receives USDC and processes balance check + +#### 3. Balance Check Message + +**Sent By**: Remote Strategy +**Sent Via**: CCTP Message Transmitter (or as hook data in burn message) +**Contains**: +- Nonce (uint64) +- Balance (uint256) + +**Encoding**: `abi.encodePacked(ORIGIN_MESSAGE_VERSION, BALANCE_CHECK_MESSAGE, abi.encode(nonce, balance))` + +**Flow**: +1. Remote sends balance check after deposit/withdrawal +2. Master receives and validates nonce +3. Master updates `remoteStrategyBalance` +4. If nonce matches pending transfer, marks as processed + +--- + +## Communication Flows + +### Deposit Flow + +```mermaid +sequenceDiagram + participant Vault as OUSD Vault + participant Master as CrossChainMasterStrategy
(Ethereum) + participant CCTP1 as CCTP Token Messenger
(Ethereum) + participant Bridge as CCTP Bridge + participant CCTP2 as CCTP Token Messenger
(Base) + participant Operator as Operator + participant Remote as CrossChainRemoteStrategy
(Base) + participant Vault4626 as 4626 Vault + participant CCTPMsg1 as CCTP Message Transmitter
(Base) + participant CCTPMsg2 as CCTP Message Transmitter
(Ethereum) + + Vault->>Master: deposit(USDC, amount) + Note over Master: Validates: no pending transfer,
amount > 0, amount <= MAX + Note over Master: Increments nonce, sets pendingAmount,
marks as Deposit type + Master->>CCTP1: depositForBurnWithHook(amount, hookData) + Note over Master: hookData = DepositMessage(nonce, amount) + Master-->>Master: Deposit event emitted + + CCTP1->>Bridge: Burn USDC + Send message + Bridge->>CCTP2: Message with attestation + + Operator->>Remote: relay(message, attestation) + Note over Remote: Validates message:
domain, sender, version + Remote->>CCTPMsg1: receiveMessage(message, attestation) + Note over CCTPMsg1: Detects burn message,
forwards to TokenMessenger + CCTPMsg1->>CCTP2: Process burn message + CCTP2->>Remote: Mint USDC (amount - fee) + Remote->>Remote: _onTokenReceived(tokenAmount, fee, hookData) + Note over Remote: Decodes DepositMessage(nonce, amount) + Note over Remote: Marks nonce as processed + Remote->>Vault4626: deposit(USDC balance) + Note over Remote: May fail silently, emits DepositFailed if fails + Remote->>Remote: Calculate new balance + Remote->>CCTPMsg1: sendMessage(BalanceCheckMessage) + Note over Remote: BalanceCheckMessage(nonce, balance) + + CCTPMsg1->>Bridge: Message with attestation + Bridge->>CCTPMsg2: Message with attestation + + Operator->>Master: relay(message, attestation) + Note over Master: Validates message + Master->>CCTPMsg2: receiveMessage(message, attestation) + Note over CCTPMsg2: Not a burn message,
forwards to handleReceiveFinalizedMessage + CCTPMsg2->>Master: handleReceiveFinalizedMessage(...) + Master->>Master: _onMessageReceived(payload) + Note over Master: Processes BalanceCheckMessage + Note over Master: Validates nonce matches lastTransferNonce + Note over Master: Marks nonce as processed + Note over Master: Resets pendingAmount = 0 + Note over Master: Updates remoteStrategyBalance +``` + +### Withdrawal Flow + +```mermaid +sequenceDiagram + participant Vault as OUSD Vault + participant Master as CrossChainMasterStrategy
(Ethereum) + participant CCTPMsg1 as CCTP Message Transmitter
(Ethereum) + participant Bridge as CCTP Bridge + participant CCTPMsg2 as CCTP Message Transmitter
(Base) + participant Operator as Operator + participant Remote as CrossChainRemoteStrategy
(Base) + participant Vault4626 as 4626 Vault + participant CCTP1 as CCTP Token Messenger
(Base) + participant CCTP2 as CCTP Token Messenger
(Ethereum) + + Vault->>Master: withdraw(vault, USDC, amount) + Note over Master: Validates: no pending transfer,
amount > 0, amount <= remoteBalance,
amount <= MAX_TRANSFER_AMOUNT + Note over Master: Increments nonce,
marks as Withdrawal type + Master->>CCTPMsg1: sendMessage(WithdrawMessage) + Note over Master: WithdrawMessage(nonce, amount) + Master-->>Master: WithdrawRequested event emitted + + CCTPMsg1->>Bridge: Message with attestation + Bridge->>CCTPMsg2: Message with attestation + + Operator->>Remote: relay(message, attestation) + Note over Remote: Validates message + Remote->>CCTPMsg2: receiveMessage(message, attestation) + Note over CCTPMsg2: Not a burn message,
forwards to handleReceiveFinalizedMessage + CCTPMsg2->>Remote: handleReceiveFinalizedMessage(...) + Remote->>Remote: _onMessageReceived(payload) + Note over Remote: Decodes WithdrawMessage(nonce, amount) + Note over Remote: Marks nonce as processed + Remote->>Vault4626: withdraw(amount) + Note over Remote: May fail silently, emits WithdrawFailed if fails + Remote->>Remote: Calculate new balance + + alt USDC balance > 1e6 + Remote->>CCTP1: depositForBurnWithHook(usdcBalance, hookData) + Note over Remote: hookData = BalanceCheckMessage(nonce, balance) + else USDC balance <= 1e6 + Remote->>CCTPMsg2: sendMessage(BalanceCheckMessage) + Note over Remote: BalanceCheckMessage(nonce, balance) + end + + CCTP1->>Bridge: Burn USDC + Send message + Bridge->>CCTP2: Message with attestation + + Operator->>Master: relay(message, attestation) + Note over Master: Validates message + Master->>CCTPMsg2: receiveMessage(message, attestation) + Note over CCTPMsg2: Detects burn message,
forwards to TokenMessenger + CCTPMsg2->>CCTP2: Process burn message + CCTP2->>Master: Mint USDC (amount - fee) + CCTPMsg2->>Master: _onTokenReceived(tokenAmount, fee, hookData) + Note over Master: Validates nonce matches lastTransferNonce + Note over Master: Validates transfer type is Withdrawal + Note over Master: Marks nonce as processed + Master->>Master: _onMessageReceived(payload) + Note over Master: Processes BalanceCheckMessage + Note over Master: Updates remoteStrategyBalance + Master->>Vault: Transfer all USDC + Master-->>Master: Withdrawal event emitted +``` + +### Balance Update Flow (Manual) + +```mermaid +sequenceDiagram + participant Caller as Operator/Strategist/
Governor + participant Remote as CrossChainRemoteStrategy
(Base) + participant Vault4626 as 4626 Vault + participant CCTPMsg1 as CCTP Message Transmitter
(Base) + participant Bridge as CCTP Bridge + participant CCTPMsg2 as CCTP Message Transmitter
(Ethereum) + participant Operator as Operator + participant Master as CrossChainMasterStrategy
(Ethereum) + + Caller->>Remote: sendBalanceUpdate() + Note over Remote: Calculates current balance:
4626 vault + contract balance + Remote->>Vault4626: previewRedeem(shares) + Vault4626-->>Remote: Asset value + Remote->>Remote: balance = vaultValue + contractBalance + Remote->>CCTPMsg1: sendMessage(BalanceCheckMessage) + Note over Remote: BalanceCheckMessage(lastTransferNonce, balance) + + CCTPMsg1->>Bridge: Message with attestation + Bridge->>CCTPMsg2: Message with attestation + + Operator->>Master: relay(message, attestation) + Note over Master: Validates message + Master->>CCTPMsg2: receiveMessage(message, attestation) + Note over CCTPMsg2: Not a burn message,
forwards to handleReceiveFinalizedMessage + CCTPMsg2->>Master: handleReceiveFinalizedMessage(...) + Master->>Master: _onMessageReceived(payload) + Note over Master: Processes BalanceCheckMessage + + alt nonce matches lastTransferNonce + alt no pending transfer + Note over Master: Updates remoteStrategyBalance + else pending deposit + Note over Master: Marks nonce as processed + Note over Master: Resets pendingAmount = 0 + Note over Master: Updates remoteStrategyBalance + else pending withdrawal + Note over Master: Ignores (handled by _onTokenReceived) + end + else nonce doesn't match + Note over Master: Ignores message (out of order) + end +``` + +--- + +## Nonce Management + +### Nonce Lifecycle + +1. **Initialization**: Nonces start at 0 (but 0 is disregarded, first nonce is 1) +2. **Increment**: `_getNextNonce()` increments `lastTransferNonce` and returns new value +3. **Processing**: `_markNonceAsProcessed(nonce)` marks nonce as processed +4. **Validation**: `isNonceProcessed(nonce)` checks if nonce has been processed + +### Nonce Rules + +- Nonces must be strictly increasing +- A nonce can only be marked as processed once +- Only the latest nonce can be marked as processed (nonce >= lastTransferNonce) +- New transfers cannot start if last nonce hasn't been processed + +### Replay Protection + +- Each message includes a nonce +- Nonces are checked before processing +- Once processed, a nonce cannot be processed again +- Out-of-order messages with non-matching nonces are ignored + +--- + +## State Management + +### Master Strategy State + +**Local State**: +- `IERC20(usdcToken).balanceOf(address(this))`: USDC held locally +- `pendingAmount`: USDC bridged but not confirmed +- `remoteStrategyBalance`: Cached balance in Remote strategy + +**Total Balance**: `localBalance + pendingAmount + remoteStrategyBalance` + +**Transfer State**: +- `lastTransferNonce`: Last known nonce +- `transferTypeByNonce`: Type of each transfer (Deposit/Withdrawal) +- `nonceProcessed`: Which nonces have been processed + +### Remote Strategy State + +**Local State**: +- `IERC20(usdcToken).balanceOf(address(this))`: USDC held locally +- `IERC4626(platformAddress).balanceOf(address(this))`: Shares in 4626 vault + +**Total Balance**: `contractBalance + previewRedeem(shares)` + +**Transfer State**: +- `lastTransferNonce`: Last known nonce +- `nonceProcessed`: Which nonces have been processed + +--- + +## Error Handling and Edge Cases + +### Deposit Failures + +**Remote Strategy Deposit Failure**: +- If 4626 vault deposit fails, Remote emits `DepositFailed` event +- Balance check message is still sent (includes undeposited USDC) +- Master strategy updates balance correctly +- Funds remain on Remote contract until manual deposit by the Guardian + +### Withdrawal Failures + +**Remote Strategy Withdrawal Failure**: +- If 4626 vault withdrawal fails, Remote emits `WithdrawFailed` event +- Balance check message is still sent (with original balance) +- Master strategy updates balance correctly +- No tokens are bridged back (or minimal dust if balance <= 1e6) +- Guardian will have to manually call the public `withdraw` method later to process the withdrawal and then call the `relay` method the WithdrawMessage again + +### Message Ordering + +**Out-of-Order Messages**: +- Balance check messages with non-matching nonces are ignored +- Master strategy only processes balance checks for `lastTransferNonce` +- Older messages are safely discarded + +**Race Conditions**: +- Single pending transfer design prevents most race conditions +- Withdrawal balance checks are ignored if withdrawal is pending (handled by `_onTokenReceived`) + +### Nonce Edge Cases + +**Nonce Too Low**: +- `_markNonceAsProcessed` reverts if nonce < lastTransferNonce +- Prevents replay attacks with old nonces + +**Nonce Already Processed**: +- `_markNonceAsProcessed` reverts if nonce already processed +- Prevents duplicate processing + +**Pending Transfer**: +- `_getNextNonce` reverts if last nonce not processed +- Prevents starting new transfer while one is pending + +### CCTP Limitations + +**Max Transfer Amount**: +- CCTP limits transfers to 10M USDC per transaction +- Both strategies enforce `MAX_TRANSFER_AMOUNT = 10_000_000 * 10**6` + +**Finality Thresholds**: +- Supports 1000 (safe, 1 epoch) or 2000 (finalized, 2 epochs) +- Configurable via `setMinFinalityThreshold` +- Unfinalized messages only supported if threshold is 1000 + +**Fee Handling**: +- Fee premium configurable up to 30% (3000 bps) +- Fees are deducted from bridged amount +- Remote strategy receives `tokenAmount - feeExecuted` + +### Operator Requirements + +**Message Relaying**: +- Only `operator` can call `relay()` +- Operator must provide valid CCTP attestation +- Operator is responsible for monitoring and relaying finalized messages + +**Security**: +- Messages are validated for domain, sender, and recipient +- Only messages from `peerStrategy` are accepted +- Only messages to `address(this)` are processed + +--- + +## Other Notes + +### Proxies +- Both strategies use Create2 to deploy their proxy to the same address on all networks + +### Initialization + +Both strategies require initialization: +- **Master**: `initialize(operator, minFinalityThreshold, feePremiumBps)` +- **Remote**: `initialize(strategist, operator, minFinalityThreshold, feePremiumBps)` + +### Governance + +- Both strategies inherit from `Governable` +- Governor can upgrade implementation, update operator, finality threshold, fee premium +- Remote strategy governor can update strategist address diff --git a/contracts/contracts/utils/BytesHelper.sol b/contracts/contracts/utils/BytesHelper.sol new file mode 100644 index 0000000000..75a0fa1875 --- /dev/null +++ b/contracts/contracts/utils/BytesHelper.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +uint256 constant UINT32_LENGTH = 4; +uint256 constant UINT64_LENGTH = 8; +uint256 constant UINT256_LENGTH = 32; +// Address is 20 bytes, but we expect the data to be padded with 0s to 32 bytes +uint256 constant ADDRESS_LENGTH = 32; + +library BytesHelper { + /** + * @dev Extract a slice from bytes memory + * @param data The bytes memory to slice + * @param start The start index (inclusive) + * @param end The end index (exclusive) + * @return result A new bytes memory containing the slice + */ + function extractSlice( + bytes memory data, + uint256 start, + uint256 end + ) internal pure returns (bytes memory) { + require(end >= start, "Invalid slice range"); + require(end <= data.length, "Slice end exceeds data length"); + + uint256 length = end - start; + bytes memory result = new bytes(length); + + // Simple byte-by-byte copy + for (uint256 i = 0; i < length; i++) { + result[i] = data[start + i]; + } + + return result; + } + + /** + * @dev Decode a uint32 from a bytes memory + * @param data The bytes memory to decode + * @return uint32 The decoded uint32 + */ + function decodeUint32(bytes memory data) internal pure returns (uint32) { + require(data.length == 4, "Invalid data length"); + return uint32(uint256(bytes32(data)) >> 224); + } + + /** + * @dev Extract a uint32 from a bytes memory + * @param data The bytes memory to extract from + * @param start The start index (inclusive) + * @return uint32 The extracted uint32 + */ + function extractUint32(bytes memory data, uint256 start) + internal + pure + returns (uint32) + { + return decodeUint32(extractSlice(data, start, start + UINT32_LENGTH)); + } + + /** + * @dev Decode an address from a bytes memory. + * Expects the data to be padded with 0s to 32 bytes. + * @param data The bytes memory to decode + * @return address The decoded address + */ + function decodeAddress(bytes memory data) internal pure returns (address) { + // We expect the data to be padded with 0s, so length is 32 not 20 + require(data.length == 32, "Invalid data length"); + return abi.decode(data, (address)); + } + + /** + * @dev Extract an address from a bytes memory + * @param data The bytes memory to extract from + * @param start The start index (inclusive) + * @return address The extracted address + */ + function extractAddress(bytes memory data, uint256 start) + internal + pure + returns (address) + { + return decodeAddress(extractSlice(data, start, start + ADDRESS_LENGTH)); + } + + /** + * @dev Decode a uint256 from a bytes memory + * @param data The bytes memory to decode + * @return uint256 The decoded uint256 + */ + function decodeUint256(bytes memory data) internal pure returns (uint256) { + require(data.length == 32, "Invalid data length"); + return abi.decode(data, (uint256)); + } + + /** + * @dev Extract a uint256 from a bytes memory + * @param data The bytes memory to extract from + * @param start The start index (inclusive) + * @return uint256 The extracted uint256 + */ + function extractUint256(bytes memory data, uint256 start) + internal + pure + returns (uint256) + { + return decodeUint256(extractSlice(data, start, start + UINT256_LENGTH)); + } +} diff --git a/contracts/deploy/base/040_crosschain_strategy_proxies.js b/contracts/deploy/base/040_crosschain_strategy_proxies.js new file mode 100644 index 0000000000..d13f925ae1 --- /dev/null +++ b/contracts/deploy/base/040_crosschain_strategy_proxies.js @@ -0,0 +1,21 @@ +const { deployOnBase } = require("../../utils/deploy-l2"); +const { deployProxyWithCreateX } = require("../deployActions"); + +module.exports = deployOnBase( + { + deployName: "040_crosschain_strategy_proxies", + }, + async () => { + // the salt needs to match the salt on the base chain deploying the other part of the strategy + const salt = "Morpho V2 Crosschain Strategy"; + const proxyAddress = await deployProxyWithCreateX( + salt, + "CrossChainStrategyProxy" + ); + console.log(`CrossChainStrategyProxy address: ${proxyAddress}`); + + return { + actions: [], + }; + } +); diff --git a/contracts/deploy/base/041_crosschain_strategy.js b/contracts/deploy/base/041_crosschain_strategy.js new file mode 100644 index 0000000000..996b96bd2a --- /dev/null +++ b/contracts/deploy/base/041_crosschain_strategy.js @@ -0,0 +1,59 @@ +const { deployOnBase } = require("../../utils/deploy-l2"); +const addresses = require("../../utils/addresses"); +const { + deployCrossChainRemoteStrategyImpl, + getCreate2ProxyAddress, +} = require("../deployActions"); +const { withConfirmation } = require("../../utils/deploy.js"); +const { cctpDomainIds } = require("../../utils/cctp"); + +module.exports = deployOnBase( + { + deployName: "041_crosschain_strategy", + }, + async () => { + const { deployerAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + const crossChainStrategyProxyAddress = await getCreate2ProxyAddress( + "CrossChainStrategyProxy" + ); + console.log( + `CrossChainStrategyProxy address: ${crossChainStrategyProxyAddress}` + ); + + const implAddress = await deployCrossChainRemoteStrategyImpl( + "0xbeeF010f9cb27031ad51e3333f9aF9C6B1228183", // 4626 Vault + crossChainStrategyProxyAddress, + cctpDomainIds.Ethereum, + crossChainStrategyProxyAddress, + addresses.base.USDC, + "CrossChainRemoteStrategy", + addresses.CCTPTokenMessengerV2, + addresses.CCTPMessageTransmitterV2, + deployerAddr + ); + console.log(`CrossChainRemoteStrategyImpl address: ${implAddress}`); + + const cCrossChainRemoteStrategy = await ethers.getContractAt( + "CrossChainRemoteStrategy", + crossChainStrategyProxyAddress + ); + console.log( + `CrossChainRemoteStrategy address: ${cCrossChainRemoteStrategy.address}` + ); + + // TODO: Move to governance actions when going live + await withConfirmation( + cCrossChainRemoteStrategy.connect(sDeployer).safeApproveAllTokens() + ); + + return { + // actions: [{ + // contract: cCrossChainRemoteStrategy, + // signature: "safeApproveAllTokens()", + // args: [], + // }], + }; + } +); diff --git a/contracts/deploy/deployActions.js b/contracts/deploy/deployActions.js index 94692f1a98..8387848452 100644 --- a/contracts/deploy/deployActions.js +++ b/contracts/deploy/deployActions.js @@ -1,4 +1,6 @@ const hre = require("hardhat"); +const fs = require("fs"); +const path = require("path"); const { setStorageAt } = require("@nomicfoundation/hardhat-network-helpers"); const { getNetworkName } = require("../utils/hardhat-helpers"); const { parseUnits } = require("ethers/lib/utils.js"); @@ -13,17 +15,26 @@ const { isSonicOrFork, isTest, isFork, + isForkTest, + isCI, isPlume, isHoodi, isHoodiOrFork, } = require("../test/helpers.js"); -const { deployWithConfirmation, withConfirmation } = require("../utils/deploy"); +const { + deployWithConfirmation, + verifyContractOnEtherscan, + withConfirmation, + encodeSaltForCreateX, +} = require("../utils/deploy"); const { metapoolLPCRVPid } = require("../utils/constants"); const { replaceContractAt } = require("../utils/hardhat"); const { resolveContract } = require("../utils/resolvers"); const { impersonateAccount, getSigner } = require("../utils/signers"); const { getDefenderSigner } = require("../utils/signersNoHardhat"); const { getTxOpts } = require("../utils/tx"); +const createxAbi = require("../abi/createx.json"); + const { beaconChainGenesisTimeHoodi, beaconChainGenesisTimeMainnet, @@ -1682,6 +1693,321 @@ const deploySonicSwapXAMOStrategyImplementation = async () => { return cSonicSwapXAMOStrategy; }; +const getCreate2ProxiesFilePath = async () => { + const networkName = + isFork || isForkTest || isCI ? "localhost" : await getNetworkName(); + return path.resolve( + __dirname, + `./../deployments/${networkName}/create2Proxies.json` + ); +}; + +const storeCreate2ProxyAddress = async (proxyName, proxyAddress) => { + const filePath = await getCreate2ProxiesFilePath(); + + // Ensure the directory exists before writing the file + const dirPath = path.dirname(filePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + let existingContents = {}; + if (fs.existsSync(filePath)) { + existingContents = JSON.parse(fs.readFileSync(filePath, "utf8")); + } + + await new Promise((resolve, reject) => { + fs.writeFile( + filePath, + JSON.stringify( + { + ...existingContents, + [proxyName]: proxyAddress, + }, + undefined, + 2 + ), + (err) => { + if (err) { + console.log("Err:", err); + reject(err); + return; + } + console.log( + `Stored create2 proxy address for ${proxyName} at ${filePath}` + ); + resolve(); + } + ); + }); +}; + +const getCreate2ProxyAddress = async (proxyName) => { + const filePath = await getCreate2ProxiesFilePath(); + if (!fs.existsSync(filePath)) { + throw new Error(`Create2 proxies file not found at ${filePath}`); + } + const contents = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (!contents[proxyName]) { + throw new Error(`Proxy ${proxyName} not found in ${filePath}`); + } + return contents[proxyName]; +}; + +// deploys an instance of InitializeGovernedUpgradeabilityProxy where address is defined by salt +const deployProxyWithCreateX = async ( + salt, + proxyName, + verifyContract = false, + contractPath = null +) => { + const { deployerAddr } = await getNamedAccounts(); + + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + // Basically hex of "originprotocol" padded to 20 bytes to mimic an address + const addrForSalt = "0x0000000000006f726967696e70726f746f636f6c"; + // NOTE: We always use fixed address to compute the salt for the proxy. + // It makes the address predictable, easier to verify and easier to use + // with CI and local fork testing. + log( + `Deploying ${proxyName} with salt: ${salt} and fixed address: ${addrForSalt}` + ); + + const cCreateX = await ethers.getContractAt(createxAbi, addresses.createX); + const factoryEncodedSalt = encodeSaltForCreateX(addrForSalt, false, salt); + + const getFactoryBytecode = async () => { + // No deployment needed—get factory directly from artifacts + const ProxyContract = await ethers.getContractFactory(proxyName); + const encodedArgs = ProxyContract.interface.encodeDeploy([deployerAddr]); + return ethers.utils.hexConcat([ProxyContract.bytecode, encodedArgs]); + }; + + const txResponse = await withConfirmation( + cCreateX + .connect(sDeployer) + .deployCreate2(factoryEncodedSalt, await getFactoryBytecode()) + ); + + // // // Create3ProxyContractCreation + // const create3ContractCreationTopic = + // "0x2feea65dd4e9f9cbd86b74b7734210c59a1b2981b5b137bd0ee3e208200c9067"; + const contractCreationTopic = + "0xb8fda7e00c6b06a2b54e58521bc5894fee35f1090e5a3bb6390bfe2b98b497f7"; + + // const topicToUse = isCreate3 ? create3ContractCreationTopic : contractCreationTopic; + const txReceipt = await txResponse.wait(); + const proxyAddress = ethers.utils.getAddress( + `0x${txReceipt.events + .find((event) => event.topics[0] === contractCreationTopic) + .topics[1].slice(26)}` + ); + + log(`Deployed ${proxyName} at ${proxyAddress}`); + + await storeCreate2ProxyAddress(proxyName, proxyAddress); + + // Verify contract on Etherscan if requested and on a live network + // Can be enabled via parameter or VERIFY_CONTRACTS environment variable + const shouldVerify = + verifyContract || process.env.VERIFY_CONTRACTS === "true"; + if (shouldVerify && !isTest && !isFork && proxyAddress) { + // Constructor args for the proxy are [deployerAddr] + const constructorArgs = [deployerAddr]; + await verifyContractOnEtherscan( + proxyName, + proxyAddress, + constructorArgs, + proxyName, + contractPath + ); + } + + return proxyAddress; +}; + +// deploys and initializes the CrossChain master strategy +const deployCrossChainMasterStrategyImpl = async ( + proxyAddress, + targetDomainId, + remoteStrategyAddress, + baseToken, + vaultAddress, + implementationName = "CrossChainMasterStrategy", + skipInitialize = false, + tokenMessengerAddress = addresses.CCTPTokenMessengerV2, + messageTransmitterAddress = addresses.CCTPMessageTransmitterV2, + governor = addresses.mainnet.Timelock +) => { + const { deployerAddr, multichainStrategistAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + log(`Deploying CrossChainMasterStrategyImpl as deployer ${deployerAddr}`); + + const cCrossChainStrategyProxy = await ethers.getContractAt( + "CrossChainStrategyProxy", + proxyAddress + ); + + await deployWithConfirmation(implementationName, [ + [ + addresses.zero, // platform address + vaultAddress, // vault address + ], + [ + tokenMessengerAddress, + messageTransmitterAddress, + targetDomainId, + remoteStrategyAddress, + baseToken, + ], + ]); + const dCrossChainMasterStrategy = await ethers.getContract( + implementationName + ); + + if (!skipInitialize) { + const initData = dCrossChainMasterStrategy.interface.encodeFunctionData( + "initialize(address,uint16,uint16)", + [multichainStrategistAddr, 2000, 0] + ); + + // Init the proxy to point at the implementation, set the governor, and call initialize + const initFunction = "initialize(address,address,bytes)"; + await withConfirmation( + cCrossChainStrategyProxy.connect(sDeployer)[initFunction]( + dCrossChainMasterStrategy.address, + governor, // governor + initData, // data for delegate call to the initialize function on the strategy + await getTxOpts() + ) + ); + } + + return dCrossChainMasterStrategy.address; +}; + +// deploys and initializes the CrossChain remote strategy +const deployCrossChainRemoteStrategyImpl = async ( + platformAddress, // underlying 4626 vault address + proxyAddress, + targetDomainId, + remoteStrategyAddress, + baseToken, + implementationName = "CrossChainRemoteStrategy", + tokenMessengerAddress = addresses.CCTPTokenMessengerV2, + messageTransmitterAddress = addresses.CCTPMessageTransmitterV2, + governor = addresses.base.timelock +) => { + const { deployerAddr, multichainStrategistAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + log(`Deploying CrossChainRemoteStrategyImpl as deployer ${deployerAddr}`); + + const cCrossChainStrategyProxy = await ethers.getContractAt( + "CrossChainStrategyProxy", + proxyAddress + ); + + await deployWithConfirmation(implementationName, [ + [ + platformAddress, + // Vault address should be same as the proxy address + proxyAddress, // vault address + // addresses.mainnet.VaultProxy, + ], + [ + tokenMessengerAddress, + messageTransmitterAddress, + targetDomainId, + remoteStrategyAddress, + baseToken, + ], + ]); + const dCrossChainRemoteStrategy = await ethers.getContract( + implementationName + ); + + const initData = dCrossChainRemoteStrategy.interface.encodeFunctionData( + "initialize(address,address,uint16,uint16)", + [multichainStrategistAddr, multichainStrategistAddr, 2000, 0] + ); + + // Init the proxy to point at the implementation, set the governor, and call initialize + const initFunction = "initialize(address,address,bytes)"; + await withConfirmation( + cCrossChainStrategyProxy.connect(sDeployer)[initFunction]( + dCrossChainRemoteStrategy.address, + governor, // governor + //initData, // data for delegate call to the initialize function on the strategy + initData, + await getTxOpts() + ) + ); + + return dCrossChainRemoteStrategy.address; +}; + +// deploy the corss chain Master / Remote strategy pair for unit testing +const deployCrossChainUnitTestStrategy = async (usdcAddress) => { + const { deployerAddr, governorAddr } = await getNamedAccounts(); + // const sDeployer = await ethers.provider.getSigner(deployerAddr); + const sGovernor = await ethers.provider.getSigner(governorAddr); + const dMasterProxy = await deployWithConfirmation( + "CrossChainMasterStrategyProxy", + [deployerAddr], + "CrossChainStrategyProxy" + ); + const dRemoteProxy = await deployWithConfirmation( + "CrossChainRemoteStrategyProxy", + [deployerAddr], + "CrossChainStrategyProxy" + ); + + const cVaultProxy = await ethers.getContract("VaultProxy"); + const messageTransmitter = await ethers.getContract( + "CCTPMessageTransmitterMock" + ); + const tokenMessenger = await ethers.getContract("CCTPTokenMessengerMock"); + const c4626Vault = await ethers.getContract("MockERC4626Vault"); + + await deployCrossChainMasterStrategyImpl( + dMasterProxy.address, + 6, // Base domain id + // unit tests differ from mainnet where remote strategy has a different address + dRemoteProxy.address, + usdcAddress, + cVaultProxy.address, + "CrossChainMasterStrategy", + false, + tokenMessenger.address, + messageTransmitter.address, + governorAddr + ); + + await deployCrossChainRemoteStrategyImpl( + c4626Vault.address, + dRemoteProxy.address, + 0, // Ethereum domain id + dMasterProxy.address, + usdcAddress, + "CrossChainRemoteStrategy", + tokenMessenger.address, + messageTransmitter.address, + governorAddr + ); + + const cCrossChainRemoteStrategy = await ethers.getContractAt( + "CrossChainRemoteStrategy", + dRemoteProxy.address + ); + await withConfirmation( + cCrossChainRemoteStrategy.connect(sGovernor).safeApproveAllTokens() + ); + // await withConfirmation( + // messageTransmitter.connect(sDeployer).setCCTPTokenMessenger(tokenMessenger.address) + // ); +}; + module.exports = { deployOracles, deployCore, @@ -1719,4 +2045,10 @@ module.exports = { deployPlumeMockRoosterAMOStrategyImplementation, getPlumeContracts, deploySonicSwapXAMOStrategyImplementation, + deployProxyWithCreateX, + deployCrossChainMasterStrategyImpl, + deployCrossChainRemoteStrategyImpl, + deployCrossChainUnitTestStrategy, + + getCreate2ProxyAddress, }; diff --git a/contracts/deploy/mainnet/000_mock.js b/contracts/deploy/mainnet/000_mock.js index f2a598e705..7a5f2d9976 100644 --- a/contracts/deploy/mainnet/000_mock.js +++ b/contracts/deploy/mainnet/000_mock.js @@ -28,6 +28,7 @@ const { const deployMocks = async ({ getNamedAccounts, deployments }) => { const { deploy } = deployments; const { deployerAddr, governorAddr } = await getNamedAccounts(); + // const sDeployer = await ethers.provider.getSigner(deployerAddr); console.log("Running 000_mock deployment..."); console.log("Deployer address", deployerAddr); @@ -447,6 +448,26 @@ const deployMocks = async ({ getNamedAccounts, deployments }) => { const mockBeaconRoots = await ethers.getContract("MockBeaconRoots"); await replaceContractAt(addresses.mainnet.beaconRoots, mockBeaconRoots); + await deploy("CCTPMessageTransmitterMock", { + from: deployerAddr, + args: [usdc.address], + }); + const messageTransmitter = await ethers.getContract( + "CCTPMessageTransmitterMock" + ); + await deploy("CCTPTokenMessengerMock", { + from: deployerAddr, + args: [usdc.address, messageTransmitter.address], + }); + await deploy("MockERC4626Vault", { + from: deployerAddr, + args: [usdc.address], + }); + // const tokenMessenger = await ethers.getContract("CCTPTokenMessengerMock"); + // await messageTransmitter + // .connect(sDeployer) + // .setCCTPTokenMessenger(tokenMessenger.address); + console.log("000_mock deploy done."); return true; diff --git a/contracts/deploy/mainnet/001_core.js b/contracts/deploy/mainnet/001_core.js index 024146b8b9..37cc3fc746 100644 --- a/contracts/deploy/mainnet/001_core.js +++ b/contracts/deploy/mainnet/001_core.js @@ -21,10 +21,13 @@ const { deployWOeth, deployOETHSwapper, deployOUSDSwapper, + deployCrossChainUnitTestStrategy, } = require("../deployActions"); const main = async () => { console.log("Running 001_core deployment..."); + const usdc = await ethers.getContract("MockUSDC"); + await deployOracles(); await deployCore(); await deployCurveMetapoolMocks(); @@ -48,6 +51,7 @@ const main = async () => { await deployWOeth(); await deployOETHSwapper(); await deployOUSDSwapper(); + await deployCrossChainUnitTestStrategy(usdc.address); console.log("001_core deploy done."); return true; }; diff --git a/contracts/deploy/mainnet/162_crosschain_strategy_proxies.js b/contracts/deploy/mainnet/162_crosschain_strategy_proxies.js new file mode 100644 index 0000000000..ec959f27c8 --- /dev/null +++ b/contracts/deploy/mainnet/162_crosschain_strategy_proxies.js @@ -0,0 +1,25 @@ +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); +const { deployProxyWithCreateX } = require("../deployActions"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "162_crosschain_strategy_proxies", + forceDeploy: false, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + }, + async () => { + // the salt needs to match the salt on the base chain deploying the other part of the strategy + const salt = "Morpho V2 Crosschain Strategy"; + const proxyAddress = await deployProxyWithCreateX( + salt, + "CrossChainStrategyProxy" + ); + console.log(`CrossChainStrategyProxy address: ${proxyAddress}`); + + return { + actions: [], + }; + } +); diff --git a/contracts/deploy/mainnet/163_crosschain_strategy.js b/contracts/deploy/mainnet/163_crosschain_strategy.js new file mode 100644 index 0000000000..cb12ff12df --- /dev/null +++ b/contracts/deploy/mainnet/163_crosschain_strategy.js @@ -0,0 +1,53 @@ +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); +const addresses = require("../../utils/addresses"); +const { cctpDomainIds } = require("../../utils/cctp"); +const { + deployCrossChainMasterStrategyImpl, + getCreate2ProxyAddress, +} = require("../deployActions"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "163_crosschain_strategy", + forceDeploy: false, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + }, + async () => { + const { deployerAddr } = await getNamedAccounts(); + const crossChainStrategyProxyAddress = await getCreate2ProxyAddress( + "CrossChainStrategyProxy" + ); + const cProxy = await ethers.getContractAt( + "CrossChainStrategyProxy", + crossChainStrategyProxyAddress + ); + console.log(`CrossChainStrategyProxy address: ${cProxy.address}`); + + const implAddress = await deployCrossChainMasterStrategyImpl( + crossChainStrategyProxyAddress, + cctpDomainIds.Base, + // Same address for both master and remote strategy + crossChainStrategyProxyAddress, + addresses.mainnet.USDC, + deployerAddr, + "CrossChainMasterStrategy" + ); + console.log(`CrossChainMasterStrategyImpl address: ${implAddress}`); + + const cCrossChainMasterStrategy = await ethers.getContractAt( + "CrossChainMasterStrategy", + crossChainStrategyProxyAddress + ); + console.log( + `CrossChainMasterStrategy address: ${cCrossChainMasterStrategy.address}` + ); + + // TODO: Set reward tokens to Morpho + + return { + actions: [], + }; + } +); diff --git a/contracts/deployments/base/create2Proxies.json b/contracts/deployments/base/create2Proxies.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/contracts/deployments/base/create2Proxies.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/contracts/deployments/mainnet/create2Proxies.json b/contracts/deployments/mainnet/create2Proxies.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/contracts/deployments/mainnet/create2Proxies.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/contracts/test/_fixture-base.js b/contracts/test/_fixture-base.js index 028e10d850..88a18314a5 100644 --- a/contracts/test/_fixture-base.js +++ b/contracts/test/_fixture-base.js @@ -1,13 +1,14 @@ const hre = require("hardhat"); const { ethers } = hre; const mocha = require("mocha"); -const { isFork, isBaseFork, oethUnits } = require("./helpers"); +const { isFork, isBaseFork, oethUnits, usdcUnits } = require("./helpers"); const { impersonateAndFund, impersonateAccount } = require("../utils/signers"); const { nodeRevert, nodeSnapshot } = require("./_fixture"); const { deployWithConfirmation } = require("../utils/deploy"); const addresses = require("../utils/addresses"); const erc20Abi = require("./abi/erc20.json"); const hhHelpers = require("@nomicfoundation/hardhat-network-helpers"); +const { getCreate2ProxyAddress } = require("../deploy/deployActions"); const log = require("../utils/logger")("test:fixtures-base"); @@ -150,11 +151,12 @@ const defaultFixture = async () => { ); // WETH - let weth, aero; + let weth, aero, usdc; if (isFork) { weth = await ethers.getContractAt("IWETH9", addresses.base.WETH); aero = await ethers.getContractAt(erc20Abi, addresses.base.AERO); + usdc = await ethers.getContractAt(erc20Abi, addresses.base.USDC); } else { weth = await ethers.getContract("MockWETH"); aero = await ethers.getContract("MockAero"); @@ -275,8 +277,9 @@ const defaultFixture = async () => { aerodromeAmoStrategy, curveAMOStrategy, - // WETH + // Tokens weth, + usdc, // Signers governor, @@ -335,6 +338,58 @@ const bridgeHelperModuleFixture = deployments.createFixture(async () => { }; }); +const crossChainFixture = deployments.createFixture(async () => { + const fixture = await defaultBaseFixture(); + const crossChainStrategyProxyAddress = await getCreate2ProxyAddress( + "CrossChainStrategyProxy" + ); + const crossChainRemoteStrategy = await ethers.getContractAt( + "CrossChainRemoteStrategy", + crossChainStrategyProxyAddress + ); + + await deployWithConfirmation("CCTPMessageTransmitterMock2", [ + fixture.usdc.address, + ]); + const mockMessageTransmitter = await ethers.getContract( + "CCTPMessageTransmitterMock2" + ); + await deployWithConfirmation("CCTPTokenMessengerMock", [ + fixture.usdc.address, + mockMessageTransmitter.address, + ]); + const mockTokenMessenger = await ethers.getContract("CCTPTokenMessengerMock"); + await mockMessageTransmitter.setCCTPTokenMessenger( + addresses.CCTPTokenMessengerV2 + ); + + const usdcMinter = await impersonateAndFund( + "0x2230393EDAD0299b7E7B59F20AA856cD1bEd52e1" + ); + const usdcContract = await ethers.getContractAt( + [ + "function mint(address to, uint256 amount) external", + "function configureMinter(address minter, uint256 minterAmount) external", + ], + addresses.base.USDC + ); + + await usdcContract + .connect(usdcMinter) + .configureMinter(fixture.rafael.address, usdcUnits("100000000")); + + await usdcContract + .connect(fixture.rafael) + .mint(fixture.rafael.address, usdcUnits("1000000")); + + return { + ...fixture, + crossChainRemoteStrategy, + mockMessageTransmitter, + mockTokenMessenger, + }; +}); + mocha.after(async () => { if (snapshotId) { await nodeRevert(snapshotId); @@ -347,4 +402,5 @@ module.exports = { MINTER_ROLE, BURNER_ROLE, bridgeHelperModuleFixture, + crossChainFixture, }; diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 8173f8e4f8..05cf394252 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -24,6 +24,7 @@ const { getOracleAddresses, oethUnits, ousdUnits, + usdcUnits, units, isTest, isFork, @@ -31,6 +32,7 @@ const { isHoleskyFork, } = require("./helpers"); const { hardhatSetBalance, setERC20TokenBalance } = require("./_fund"); +const { getCreate2ProxyAddress } = require("../deploy/deployActions"); const usdsAbi = require("./abi/usds.json").abi; const usdtAbi = require("./abi/usdt.json").abi; @@ -2610,6 +2612,59 @@ async function instantRebaseVaultFixture() { return fixture; } +// Unit test cross chain fixture where both contracts are deployed on the same chain for the +// purposes of unit testing +async function crossChainFixtureUnit() { + const fixture = await defaultFixture(); + const { governor, vault } = fixture; + + const crossChainMasterStrategyProxy = await ethers.getContract( + "CrossChainMasterStrategyProxy" + ); + const crossChainRemoteStrategyProxy = await ethers.getContract( + "CrossChainRemoteStrategyProxy" + ); + + const cCrossChainMasterStrategy = await ethers.getContractAt( + "CrossChainMasterStrategy", + crossChainMasterStrategyProxy.address + ); + + const cCrossChainRemoteStrategy = await ethers.getContractAt( + "CrossChainRemoteStrategy", + crossChainRemoteStrategyProxy.address + ); + + await vault + .connect(governor) + .approveStrategy(cCrossChainMasterStrategy.address); + + const messageTransmitter = await ethers.getContract( + "CCTPMessageTransmitterMock" + ); + const tokenMessenger = await ethers.getContract("CCTPTokenMessengerMock"); + + // In unit test environment it is not the off-chain defender action that calls the "relay" + // to relay the messages but rather the message transmitter. + await cCrossChainMasterStrategy + .connect(governor) + .setOperator(messageTransmitter.address); + await cCrossChainRemoteStrategy + .connect(governor) + .setOperator(messageTransmitter.address); + + const morphoVault = await ethers.getContract("MockERC4626Vault"); + + return { + ...fixture, + crossChainMasterStrategy: cCrossChainMasterStrategy, + crossChainRemoteStrategy: cCrossChainRemoteStrategy, + messageTransmitter: messageTransmitter, + tokenMessenger: tokenMessenger, + morphoVault: morphoVault, + }; +} + /** * Configure a reborn hack attack */ @@ -2943,6 +2998,46 @@ async function enableExecutionLayerGeneralPurposeRequests() { }; } +async function crossChainFixture() { + const fixture = await defaultFixture(); + + const crossChainStrategyProxyAddress = await getCreate2ProxyAddress( + "CrossChainStrategyProxy" + ); + const cCrossChainMasterStrategy = await ethers.getContractAt( + "CrossChainMasterStrategy", + crossChainStrategyProxyAddress + ); + + await deployWithConfirmation("CCTPMessageTransmitterMock2", [ + fixture.usdc.address, + ]); + const mockMessageTransmitter = await ethers.getContract( + "CCTPMessageTransmitterMock2" + ); + await deployWithConfirmation("CCTPTokenMessengerMock", [ + fixture.usdc.address, + mockMessageTransmitter.address, + ]); + const mockTokenMessenger = await ethers.getContract("CCTPTokenMessengerMock"); + await mockMessageTransmitter.setCCTPTokenMessenger( + addresses.CCTPTokenMessengerV2 + ); + + await setERC20TokenBalance( + fixture.matt.address, + fixture.usdc, + usdcUnits("1000000") + ); + + return { + ...fixture, + crossChainMasterStrategy: cCrossChainMasterStrategy, + mockMessageTransmitter: mockMessageTransmitter, + mockTokenMessenger: mockTokenMessenger, + }; +} + /** * A fixture is a setup function that is run only the first time it's invoked. On subsequent invocations, * Hardhat will reset the state of the network to what it was at the point after the fixture was initially executed. @@ -3036,4 +3131,6 @@ module.exports = { bridgeHelperModuleFixture, beaconChainFixture, claimRewardsModuleFixture, + crossChainFixtureUnit, + crossChainFixture, }; diff --git a/contracts/test/strategies/crosschain/_crosschain-helpers.js b/contracts/test/strategies/crosschain/_crosschain-helpers.js new file mode 100644 index 0000000000..e5cbce5c0b --- /dev/null +++ b/contracts/test/strategies/crosschain/_crosschain-helpers.js @@ -0,0 +1,253 @@ +const { expect } = require("chai"); + +const addresses = require("../../../utils/addresses"); +const { replaceContractAt } = require("../../../utils/hardhat"); +const { setStorageAt } = require("@nomicfoundation/hardhat-network-helpers"); + +const DEPOSIT_FOR_BURN_EVENT_TOPIC = + "0x0c8c1cbdc5190613ebd485511d4e2812cfa45eecb79d845893331fedad5130a5"; +const MESSAGE_SENT_EVENT_TOPIC = + "0x8c5261668696ce22758910d05bab8f186d6eb247ceac2af2e82c7dc17669b036"; + +const emptyByte = "0000"; +const empty2Bytes = emptyByte.repeat(2); +const empty4Bytes = emptyByte.repeat(4); +const empty16Bytes = empty4Bytes.repeat(4); +const empty18Bytes = `${empty2Bytes}${empty16Bytes}`; +const empty20Bytes = empty4Bytes.repeat(5); + +const REMOTE_STRATEGY_BALANCE_SLOT = 210; + +const decodeDepositForBurnEvent = (event) => { + const [ + amount, + mintRecipient, + destinationDomain, + destinationTokenMessenger, + destinationCaller, + maxFee, + hookData, + ] = ethers.utils.defaultAbiCoder.decode( + ["uint256", "address", "uint32", "address", "address", "uint256", "bytes"], + event.data + ); + + const [burnToken] = ethers.utils.defaultAbiCoder.decode( + ["address"], + event.topics[1] + ); + const [depositer] = ethers.utils.defaultAbiCoder.decode( + ["address"], + event.topics[2] + ); + const [minFinalityThreshold] = ethers.utils.defaultAbiCoder.decode( + ["uint256"], + event.topics[3] + ); + + return { + amount, + mintRecipient, + destinationDomain, + destinationTokenMessenger, + destinationCaller, + maxFee, + hookData, + burnToken, + depositer, + minFinalityThreshold, + }; +}; + +const decodeMessageSentEvent = (event) => { + const evData = event.data.slice(130); // ignore first two slots along with 0x prefix + + const version = ethers.BigNumber.from(`0x${evData.slice(0, 8)}`); + const sourceDomain = ethers.BigNumber.from(`0x${evData.slice(8, 16)}`); + const desinationDomain = ethers.BigNumber.from(`0x${evData.slice(16, 24)}`); + // Ignore empty nonce from 24 to 88 + const [sender, recipient, destinationCaller] = + ethers.utils.defaultAbiCoder.decode( + ["address", "address", "address"], + `0x${evData.slice(88, 280)}` + ); + const minFinalityThreshold = ethers.BigNumber.from( + `0x${evData.slice(280, 288)}` + ); + // Ignore empty threshold from 288 to 296 + const endIndex = evData.endsWith("00000000") + ? evData.length - 8 + : evData.length; + const payload = `0x${evData.slice(296, endIndex)}`; + + return { + version, + sourceDomain, + desinationDomain, + sender, + recipient, + destinationCaller, + minFinalityThreshold, + payload, + }; +}; + +const encodeDepositMessageBody = (nonce, amount) => { + const encodedPayload = ethers.utils.defaultAbiCoder.encode( + ["uint64", "uint256"], + [nonce, amount] + ); + return `0x000003f200000001${encodedPayload.slice(2)}`; +}; + +const encodeWithdrawMessageBody = (nonce, amount) => { + const encodedPayload = ethers.utils.defaultAbiCoder.encode( + ["uint64", "uint256"], + [nonce, amount] + ); + return `0x000003f200000002${encodedPayload.slice(2)}`; +}; + +const decodeDepositOrWithdrawMessage = (message) => { + message = message.slice(2); // Ignore 0x prefix + + const originMessageVersion = ethers.BigNumber.from( + `0x${message.slice(0, 8)}` + ); + const messageType = ethers.BigNumber.from(`0x${message.slice(8, 16)}`); + expect(originMessageVersion).to.eq(1010); + + const [nonce, amount] = ethers.utils.defaultAbiCoder.decode( + ["uint64", "uint256"], + `0x${message.slice(16)}` + ); + + return { + messageType, + nonce, + amount, + }; +}; + +const encodeCCTPMessage = ( + sourceDomain, + sender, + recipient, + messageBody, + version = 1 +) => { + const versionStr = version.toString(16).padStart(8, "0"); + const sourceDomainStr = sourceDomain.toString(16).padStart(8, "0"); + const senderStr = sender.replace("0x", "").toLowerCase().padStart(64, "0"); + const recipientStr = recipient + .replace("0x", "") + .toLowerCase() + .padStart(64, "0"); + const messageBodyStr = messageBody.slice(2); + return `0x${versionStr}${sourceDomainStr}${empty18Bytes}${senderStr}${recipientStr}${empty20Bytes}${messageBodyStr}`; +}; + +const encodeBurnMessageBody = (sender, recipient, amount, hookData) => { + const senderEncoded = ethers.utils.defaultAbiCoder + .encode(["address"], [sender]) + .slice(2); + const recipientEncoded = ethers.utils.defaultAbiCoder + .encode(["address"], [recipient]) + .slice(2); + const amountEncoded = ethers.utils.defaultAbiCoder + .encode(["uint256"], [amount]) + .slice(2); + const encodedHookData = hookData.slice(2); + return `0x00000001${empty16Bytes}${recipientEncoded}${amountEncoded}${senderEncoded}${empty16Bytes.repeat( + 3 + )}${encodedHookData}`; +}; +const decodeBurnMessageBody = (message) => { + message = message.slice(2); // Ignore 0x prefix + + const version = ethers.BigNumber.from(`0x${message.slice(0, 8)}`); + expect(version).to.eq(1); + const [burnToken, recipient, amount, sender] = + ethers.utils.defaultAbiCoder.decode( + ["address", "address", "uint256", "address"], + `0x${message.slice(8, 264)}` + ); + + const hookData = `0x${message.slice(456)}`; // Ignore 0x prefix and following 96 bytes + return { version, burnToken, recipient, amount, sender, hookData }; +}; + +const encodeBalanceCheckMessageBody = (nonce, balance) => { + const encodedPayload = ethers.utils.defaultAbiCoder.encode( + ["uint64", "uint256"], + [nonce, balance] + ); + + // const version = 1010; // ORIGIN_MESSAGE_VERSION + // const messageType = 3; // BALANCE_CHECK_MESSAGE + return `0x000003f200000003${encodedPayload.slice(2)}`; +}; + +const decodeBalanceCheckMessageBody = (message) => { + message = message.slice(2); // Ignore 0x prefix + const version = ethers.BigNumber.from(`0x${message.slice(0, 8)}`); + const messageType = ethers.BigNumber.from(`0x${message.slice(8, 16)}`); + expect(version).to.eq(1010); + expect(messageType).to.eq(3); + const [nonce, balance] = ethers.utils.defaultAbiCoder.decode( + ["uint64", "uint256"], + `0x${message.slice(16)}` + ); + return { version, messageType, nonce, balance }; +}; + +const replaceMessageTransmitter = async () => { + const mockMessageTransmitter = await ethers.getContract( + "CCTPMessageTransmitterMock2" + ); + await replaceContractAt( + addresses.CCTPMessageTransmitterV2, + mockMessageTransmitter + ); + const replacedTransmitter = await ethers.getContractAt( + "CCTPMessageTransmitterMock2", + addresses.CCTPMessageTransmitterV2 + ); + await replacedTransmitter.setCCTPTokenMessenger( + addresses.CCTPTokenMessengerV2 + ); + + return replacedTransmitter; +}; + +const setRemoteStrategyBalance = async (strategy, balance) => { + await setStorageAt( + strategy.address, + `0x${REMOTE_STRATEGY_BALANCE_SLOT.toString(16)}`, + balance.toHexString() + ); +}; + +module.exports = { + DEPOSIT_FOR_BURN_EVENT_TOPIC, + MESSAGE_SENT_EVENT_TOPIC, + emptyByte, + empty2Bytes, + empty4Bytes, + empty16Bytes, + empty18Bytes, + empty20Bytes, + REMOTE_STRATEGY_BALANCE_SLOT, + setRemoteStrategyBalance, + decodeDepositForBurnEvent, + decodeMessageSentEvent, + decodeDepositOrWithdrawMessage, + encodeCCTPMessage, + encodeDepositMessageBody, + encodeWithdrawMessageBody, + encodeBurnMessageBody, + decodeBurnMessageBody, + encodeBalanceCheckMessageBody, + decodeBalanceCheckMessageBody, + replaceMessageTransmitter, +}; diff --git a/contracts/test/strategies/crosschain/cross-chain-strategy.js b/contracts/test/strategies/crosschain/cross-chain-strategy.js new file mode 100644 index 0000000000..62de14f88c --- /dev/null +++ b/contracts/test/strategies/crosschain/cross-chain-strategy.js @@ -0,0 +1,454 @@ +const { expect } = require("chai"); +const { isCI, ousdUnits } = require("../../helpers"); +const { + createFixtureLoader, + crossChainFixtureUnit, +} = require("../../_fixture"); +const { units } = require("../../helpers"); +const { impersonateAndFund } = require("../../../utils/signers"); + +const loadFixture = createFixtureLoader(crossChainFixtureUnit); + +describe("ForkTest: CrossChainRemoteStrategy", function () { + this.timeout(0); + + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + + let fixture, + josh, + governor, + usdc, + crossChainRemoteStrategy, + crossChainMasterStrategy, + vault, + initialVaultValue; + beforeEach(async () => { + fixture = await loadFixture(); + josh = fixture.josh; + governor = fixture.governor; + usdc = fixture.usdc; + crossChainRemoteStrategy = fixture.crossChainRemoteStrategy; + crossChainMasterStrategy = fixture.crossChainMasterStrategy; + vault = fixture.vault; + initialVaultValue = await vault.totalValue(); + }); + + const mint = async (amount) => { + await usdc.connect(josh).approve(vault.address, await units(amount, usdc)); + await vault.connect(josh).mint(usdc.address, await units(amount, usdc), 0); + }; + + const depositToMasterStrategy = async (amount) => { + await vault + .connect(governor) + .depositToStrategy( + crossChainMasterStrategy.address, + [usdc.address], + [await units(amount, usdc)] + ); + }; + + // Even though remote strategy has funds withdrawn the message initiates on master strategy + const withdrawFromRemoteStrategy = async (amount) => { + await vault + .connect(governor) + .withdrawFromStrategy( + crossChainMasterStrategy.address, + [usdc.address], + [await units(amount, usdc)] + ); + }; + + // Withdraws from the remote strategy directly, without going through the master strategy + const directWithdrawFromRemoteStrategy = async (amount) => { + await crossChainRemoteStrategy + .connect(governor) + .withdraw( + crossChainRemoteStrategy.address, + usdc.address, + await units(amount, usdc) + ); + }; + + // Withdraws all the remote strategy directly, without going through the master strategy + const directWithdrawAllFromRemoteStrategy = async () => { + await crossChainRemoteStrategy.connect(governor).withdrawAll(); + }; + + const sendBalanceUpdateToMaster = async () => { + await crossChainRemoteStrategy.connect(governor).sendBalanceUpdate(); + }; + + // Checks the diff in the total expected value in the vault + // (plus accompanying strategy value) + const assertVaultTotalValue = async (amountExpected) => { + const amountToCompare = + typeof amountExpected === "string" + ? ousdUnits(amountExpected) + : amountExpected; + + await expect((await vault.totalValue()).sub(initialVaultValue)).to.eq( + amountToCompare + ); + }; + + const mintToMasterDepositToRemote = async (amount) => { + const { messageTransmitter, morphoVault } = fixture; + const amountBn = await units(amount, usdc); + + await mint(amount); + const vaultDiffAfterMint = (await vault.totalValue()).sub( + initialVaultValue + ); + + const remoteBalanceBefore = await crossChainRemoteStrategy.checkBalance( + usdc.address + ); + const remoteBalanceRecByMasterBefore = + await crossChainMasterStrategy.remoteStrategyBalance(); + const messagesinQueueBefore = await messageTransmitter.messagesInQueue(); + await assertVaultTotalValue(vaultDiffAfterMint); + + await depositToMasterStrategy(amount); + await expect(await messageTransmitter.messagesInQueue()).to.eq( + messagesinQueueBefore + 1 + ); + await assertVaultTotalValue(vaultDiffAfterMint); + + // Simulate off chain component processing deposit message + await expect(messageTransmitter.processFront()) + .to.emit(crossChainRemoteStrategy, "Deposit") + .withArgs(usdc.address, morphoVault.address, amountBn); + + await assertVaultTotalValue(vaultDiffAfterMint); + // 1 message is processed, another one (checkBalance) has entered the queue + await expect(await messageTransmitter.messagesInQueue()).to.eq( + messagesinQueueBefore + 1 + ); + await expect( + await morphoVault.balanceOf(crossChainRemoteStrategy.address) + ).to.eq(remoteBalanceBefore + amountBn); + + // Simulate off chain component processing checkBalance message + await expect(messageTransmitter.processFront()) + .to.emit(crossChainMasterStrategy, "RemoteStrategyBalanceUpdated") + .withArgs(amountBn); + + await expect(await messageTransmitter.messagesInQueue()).to.eq( + messagesinQueueBefore + ); + await assertVaultTotalValue(vaultDiffAfterMint); + await expect(await crossChainMasterStrategy.remoteStrategyBalance()).to.eq( + remoteBalanceRecByMasterBefore + amountBn + ); + }; + + const withdrawFromRemoteToVault = async (amount, expectWithdrawalEvent) => { + const { messageTransmitter, morphoVault } = fixture; + const amountBn = await units(amount, usdc); + const remoteBalanceBefore = await crossChainRemoteStrategy.checkBalance( + usdc.address + ); + const remoteBalanceRecByMasterBefore = + await crossChainMasterStrategy.remoteStrategyBalance(); + + const messagesinQueueBefore = await messageTransmitter.messagesInQueue(); + + await withdrawFromRemoteStrategy(amount); + await expect(await messageTransmitter.messagesInQueue()).to.eq( + messagesinQueueBefore + 1 + ); + + if (expectWithdrawalEvent) { + await expect(messageTransmitter.processFront()) + .to.emit(crossChainRemoteStrategy, "Withdrawal") + .withArgs(usdc.address, morphoVault.address, amountBn); + } else { + await messageTransmitter.processFront(); + } + + await expect(await messageTransmitter.messagesInQueue()).to.eq( + messagesinQueueBefore + 1 + ); + + // master strategy still has the old value fo the remote strategy balance + await expect(await crossChainMasterStrategy.remoteStrategyBalance()).to.eq( + remoteBalanceRecByMasterBefore + ); + + const remoteBalanceAfter = remoteBalanceBefore - amountBn; + + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(remoteBalanceAfter); + + // Simulate off chain component processing checkBalance message + await expect(messageTransmitter.processFront()) + .to.emit(crossChainMasterStrategy, "RemoteStrategyBalanceUpdated") + .withArgs(remoteBalanceAfter); + + await expect(await crossChainMasterStrategy.remoteStrategyBalance()).to.eq( + remoteBalanceAfter + ); + }; + + it("Should mint USDC to master strategy, transfer to remote and update balance", async function () { + const { morphoVault } = fixture; + await assertVaultTotalValue("0"); + await expect(await morphoVault.totalAssets()).to.eq(await units("0", usdc)); + + await mintToMasterDepositToRemote("1000"); + await assertVaultTotalValue("1000"); + + await expect(await morphoVault.totalAssets()).to.eq( + await units("1000", usdc) + ); + }); + + it("Should be able to withdraw from the remote strategy", async function () { + const { morphoVault } = fixture; + await mintToMasterDepositToRemote("1000"); + await assertVaultTotalValue("1000"); + + await expect(await morphoVault.totalAssets()).to.eq( + await units("1000", usdc) + ); + await withdrawFromRemoteToVault("500", true); + await assertVaultTotalValue("1000"); + }); + + it("Should be able to direct withdraw from the remote strategy directly and collect to master", async function () { + const { morphoVault } = fixture; + await mintToMasterDepositToRemote("1000"); + await assertVaultTotalValue("1000"); + + await expect(await morphoVault.totalAssets()).to.eq( + await units("1000", usdc) + ); + await directWithdrawFromRemoteStrategy("500"); + await assertVaultTotalValue("1000"); + + // 500 has been withdrawn from the Morpho vault but still remains on the + // remote strategy + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("1000", usdc)); + + // Next withdraw should not withdraw any additional funds from Morpho and just send + // 450 USDC to the master. + await withdrawFromRemoteToVault("450", false); + + await assertVaultTotalValue("1000"); + // The remote strategy should have 500 USDC in Morpho vault and 50 USDC on the contract + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("550", usdc)); + await expect(await usdc.balanceOf(crossChainRemoteStrategy.address)).to.eq( + await units("50", usdc) + ); + }); + + it("Should be able to direct withdraw from the remote strategy directly and withdrawing More from Morpho when collecting to the master", async function () { + const { morphoVault } = fixture; + await mintToMasterDepositToRemote("1000"); + await assertVaultTotalValue("1000"); + + await expect(await morphoVault.totalAssets()).to.eq( + await units("1000", usdc) + ); + await directWithdrawFromRemoteStrategy("500"); + await assertVaultTotalValue("1000"); + + // 500 has been withdrawn from the Morpho vault but still remains on the + // remote strategy + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("1000", usdc)); + + // Next withdraw should withdraw 50 additional funds and send them with existing + // 500 USDC to the master. + await withdrawFromRemoteToVault("550", false); + + await assertVaultTotalValue("1000"); + // The remote strategy should have 500 USDC in Morpho vault and 50 USDC on the contract + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("450", usdc)); + await expect(await usdc.balanceOf(crossChainRemoteStrategy.address)).to.eq( + await units("0", usdc) + ); + }); + + it("Should fail when a withdrawal too large is requested", async function () { + const { morphoVault } = fixture; + await mintToMasterDepositToRemote("1000"); + await assertVaultTotalValue("1000"); + + await expect(await morphoVault.totalAssets()).to.eq( + await units("1000", usdc) + ); + + // Master strategy should prevent withdrawing more than is available in the remote strategy + await expect(withdrawFromRemoteStrategy("1001")).to.be.revertedWith( + "Withdraw amount exceeds remote strategy balance" + ); + + await assertVaultTotalValue("1000"); + }); + + it("Should be able to direct withdraw all from the remote strategy directly and collect to master", async function () { + const { morphoVault, messageTransmitter } = fixture; + await mintToMasterDepositToRemote("1000"); + await assertVaultTotalValue("1000"); + + await expect(await morphoVault.totalAssets()).to.eq( + await units("1000", usdc) + ); + await directWithdrawAllFromRemoteStrategy(); + await assertVaultTotalValue("1000"); + + // All has been withdrawn from the Morpho vault but still remains on the + // remote strategy + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("1000", usdc)); + + await withdrawFromRemoteStrategy("1000"); + await expect(messageTransmitter.processFront()).not.to.emit( + crossChainRemoteStrategy, + "WithdrawUnderlyingFailed" + ); + await expect(messageTransmitter.processFront()) + .to.emit(crossChainMasterStrategy, "RemoteStrategyBalanceUpdated") + .withArgs(await units("0", usdc)); + + await assertVaultTotalValue("1000"); + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("0", usdc)); + }); + + it("Should fail when a withdrawal too large is requested on the remote strategy", async function () { + const { messageTransmitter } = fixture; + const remoteStrategySigner = await impersonateAndFund( + crossChainRemoteStrategy.address + ); + + await mintToMasterDepositToRemote("1000"); + await assertVaultTotalValue("1000"); + + await directWithdrawFromRemoteStrategy("10"); + + // Trick the remote strategy into thinking it has 10 USDC more than it actually does + await usdc + .connect(remoteStrategySigner) + .transfer(vault.address, await units("10", usdc)); + // Vault has 10 USDC more & Master strategy still thinks it has 1000 USDC + await assertVaultTotalValue("1010"); + + // This step should fail because the remote strategy no longer holds 1000 USDC + await withdrawFromRemoteStrategy("1000"); + + // Process on remote strategy + await expect(messageTransmitter.processFront()) + .to.emit(crossChainRemoteStrategy, "WithdrawFailed") + .withArgs(await units("1000", usdc), await units("0", usdc)); + + // Process on master strategy + // This event doesn't get triggerred as the master strategy considers the balance check update + // as a race condition, and is exoecting an "on TokenReceived " to be called instead + + // which also causes the master strategy not to update the balance of the remote strategy + await expect(messageTransmitter.processFront()) + .to.emit(crossChainMasterStrategy, "RemoteStrategyBalanceUpdated") + .withArgs(await units("990", usdc)); + + await expect(await messageTransmitter.messagesInQueue()).to.eq(0); + + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("990", usdc)); + + await expect( + await crossChainMasterStrategy.checkBalance(usdc.address) + ).to.eq(await units("990", usdc)); + }); + + it("Should be able to process withdrawal & checkBalance on Remote strategy and in reverse order on master strategy", async function () { + const { messageTransmitter } = fixture; + + await mintToMasterDepositToRemote("1000"); + + await withdrawFromRemoteStrategy("300"); + + // Process on remote strategy + await expect(messageTransmitter.processFront()); + // This sends a second balanceUpdate message to the CCTP bridge + await sendBalanceUpdateToMaster(); + + await expect(await messageTransmitter.messagesInQueue()).to.eq(2); + + // first process the standalone balanceCheck message - meaning we process messages out of order + // this message should be ignored on Master + await expect(messageTransmitter.processBack()).to.not.emit( + crossChainMasterStrategy, + "RemoteStrategyBalanceUpdated" + ); + + // Second balance update message is part of the deposit / withdrawal process and should be processed + await expect(messageTransmitter.processFront()) + .to.emit(crossChainMasterStrategy, "RemoteStrategyBalanceUpdated") + .withArgs(await units("700", usdc)); + + await expect(await messageTransmitter.messagesInQueue()).to.eq(0); + + await expect( + await crossChainRemoteStrategy.checkBalance(usdc.address) + ).to.eq(await units("700", usdc)); + + await expect( + await crossChainMasterStrategy.checkBalance(usdc.address) + ).to.eq(await units("700", usdc)); + + await assertVaultTotalValue("1000"); + }); + + it("Should fail on deposit if a previous one has not completed", async function () { + await mint("100"); + await depositToMasterStrategy("50"); + + await expect(depositToMasterStrategy("50")).to.be.revertedWith( + "Unexpected pending amount" + ); + }); + + it("Should fail to withdraw if a previous deposit has not completed", async function () { + await mintToMasterDepositToRemote("40"); + await mint("50"); + await depositToMasterStrategy("50"); + + await expect(withdrawFromRemoteStrategy("40")).to.be.revertedWith( + "Pending deposit or withdrawal" + ); + }); + + it("Should fail on deposit if a previous withdrawal has not completed", async function () { + await mintToMasterDepositToRemote("230"); + await withdrawFromRemoteStrategy("50"); + + await mint("30"); + await expect(depositToMasterStrategy("30")).to.be.revertedWith( + "Pending deposit or withdrawal" + ); + }); + + it("Should fail to withdraw if a previous withdrawal has not completed", async function () { + await mintToMasterDepositToRemote("230"); + await withdrawFromRemoteStrategy("50"); + + await expect(withdrawFromRemoteStrategy("40")).to.be.revertedWith( + "Pending deposit or withdrawal" + ); + }); +}); diff --git a/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js b/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js new file mode 100644 index 0000000000..295dbb2b52 --- /dev/null +++ b/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js @@ -0,0 +1,491 @@ +const { expect } = require("chai"); + +const { usdcUnits, isCI } = require("../../helpers"); +const { createFixtureLoader, crossChainFixture } = require("../../_fixture"); +const { impersonateAndFund } = require("../../../utils/signers"); +const addresses = require("../../../utils/addresses"); +const loadFixture = createFixtureLoader(crossChainFixture); +const { + DEPOSIT_FOR_BURN_EVENT_TOPIC, + MESSAGE_SENT_EVENT_TOPIC, + setRemoteStrategyBalance, + decodeDepositForBurnEvent, + decodeMessageSentEvent, + decodeDepositOrWithdrawMessage, + encodeCCTPMessage, + encodeBurnMessageBody, + encodeBalanceCheckMessageBody, + replaceMessageTransmitter, +} = require("./_crosschain-helpers"); + +describe("ForkTest: CrossChainMasterStrategy", function () { + this.timeout(0); + + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + + let fixture; + beforeEach(async () => { + fixture = await loadFixture(); + }); + + describe("Message sending", function () { + it("Should initiate bridging of deposited USDC", async function () { + const { matt, crossChainMasterStrategy, usdc } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping deposit fork test because there's a pending transfer" + ); + return; + } + + const vaultAddr = await crossChainMasterStrategy.vaultAddress(); + + const impersonatedVault = await impersonateAndFund(vaultAddr); + + // Let the strategy hold some USDC + await usdc + .connect(matt) + .transfer(crossChainMasterStrategy.address, usdcUnits("1000")); + + const usdcBalanceBefore = await usdc.balanceOf( + crossChainMasterStrategy.address + ); + const strategyBalanceBefore = await crossChainMasterStrategy.checkBalance( + usdc.address + ); + + // Simulate deposit call + const tx = await crossChainMasterStrategy + .connect(impersonatedVault) + .deposit(usdc.address, usdcUnits("1000")); + + const usdcBalanceAfter = await usdc.balanceOf( + crossChainMasterStrategy.address + ); + expect(usdcBalanceAfter).to.eq(usdcBalanceBefore.sub(usdcUnits("1000"))); + + const strategyBalanceAfter = await crossChainMasterStrategy.checkBalance( + usdc.address + ); + expect(strategyBalanceAfter).to.eq(strategyBalanceBefore); + + expect(await crossChainMasterStrategy.pendingAmount()).to.eq( + usdcUnits("1000") + ); + + // Check for message sent event + const receipt = await tx.wait(); + const depositForBurnEvent = receipt.events.find((e) => + e.topics.includes(DEPOSIT_FOR_BURN_EVENT_TOPIC) + ); + const burnEventData = decodeDepositForBurnEvent(depositForBurnEvent); + + expect(burnEventData.amount).to.eq(usdcUnits("1000")); + expect(burnEventData.mintRecipient.toLowerCase()).to.eq( + crossChainMasterStrategy.address.toLowerCase() + ); + expect(burnEventData.destinationDomain).to.eq(6); + expect(burnEventData.destinationTokenMessenger.toLowerCase()).to.eq( + addresses.CCTPTokenMessengerV2.toLowerCase() + ); + expect(burnEventData.destinationCaller.toLowerCase()).to.eq( + crossChainMasterStrategy.address.toLowerCase() + ); + expect(burnEventData.maxFee).to.eq(0); + expect(burnEventData.burnToken).to.eq(usdc.address); + + expect(burnEventData.depositer.toLowerCase()).to.eq( + crossChainMasterStrategy.address.toLowerCase() + ); + expect(burnEventData.minFinalityThreshold).to.eq(2000); + expect(burnEventData.burnToken.toLowerCase()).to.eq( + usdc.address.toLowerCase() + ); + + // Decode and verify payload + const { messageType, nonce, amount } = decodeDepositOrWithdrawMessage( + burnEventData.hookData + ); + expect(messageType).to.eq(1); + expect(nonce).to.eq(1); + expect(amount).to.eq(usdcUnits("1000")); + }); + + it("Should request withdrawal", async function () { + const { crossChainMasterStrategy, usdc } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping deposit fork test because there's a pending transfer" + ); + return; + } + + const vaultAddr = await crossChainMasterStrategy.vaultAddress(); + const impersonatedVault = await impersonateAndFund(vaultAddr); + + // set an arbitrary remote strategy balance + await setRemoteStrategyBalance( + crossChainMasterStrategy, + usdcUnits("1000") + ); + + const tx = await crossChainMasterStrategy + .connect(impersonatedVault) + .withdraw(vaultAddr, usdc.address, usdcUnits("1000")); + const receipt = await tx.wait(); + const messageSentEvent = receipt.events.find((e) => + e.topics.includes(MESSAGE_SENT_EVENT_TOPIC) + ); + + const { + version, + sourceDomain, + desinationDomain, + sender, + recipient, + destinationCaller, + minFinalityThreshold, + payload, + } = decodeMessageSentEvent(messageSentEvent); + + expect(version).to.eq(1); + expect(sourceDomain).to.eq(0); + expect(desinationDomain).to.eq(6); + expect(sender.toLowerCase()).to.eq( + crossChainMasterStrategy.address.toLowerCase() + ); + expect(recipient.toLowerCase()).to.eq( + crossChainMasterStrategy.address.toLowerCase() + ); + expect(destinationCaller.toLowerCase()).to.eq( + crossChainMasterStrategy.address.toLowerCase() + ); + expect(minFinalityThreshold).to.eq(2000); + + // Decode and verify payload + const { messageType, nonce, amount } = + decodeDepositOrWithdrawMessage(payload); + expect(messageType).to.eq(2); + expect(nonce).to.eq(1); + expect(amount).to.eq(usdcUnits("1000")); + }); + }); + + describe("Message receiving", function () { + it("Should handle balance check message", async function () { + const { crossChainMasterStrategy, strategist } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping balance check message fork test because there's a pending transfer" + ); + return; + } + + const lastNonce = ( + await crossChainMasterStrategy.lastTransferNonce() + ).toNumber(); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + // Build check balance payload + const balancePayload = encodeBalanceCheckMessageBody( + lastNonce, + usdcUnits("12345") + ); + const message = encodeCCTPMessage( + 6, + crossChainMasterStrategy.address, + crossChainMasterStrategy.address, + balancePayload + ); + + // Relay the message with fake attestation + await crossChainMasterStrategy.connect(strategist).relay(message, "0x"); + + const remoteStrategyBalance = + await crossChainMasterStrategy.remoteStrategyBalance(); + expect(remoteStrategyBalance).to.eq(usdcUnits("12345")); + }); + + it("Should handle balance check message for a pending deposit", async function () { + const { crossChainMasterStrategy, strategist, usdc, matt } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping balance check message fork test because there's a pending transfer" + ); + return; + } + + // Do a pre-deposit + const vaultAddr = await crossChainMasterStrategy.vaultAddress(); + + const impersonatedVault = await impersonateAndFund(vaultAddr); + + // Let the strategy hold some USDC + await usdc + .connect(matt) + .transfer(crossChainMasterStrategy.address, usdcUnits("1000")); + + // Simulate deposit call + await crossChainMasterStrategy + .connect(impersonatedVault) + .deposit(usdc.address, usdcUnits("1000")); + + const lastNonce = ( + await crossChainMasterStrategy.lastTransferNonce() + ).toNumber(); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + // Build check balance payload + const payload = encodeBalanceCheckMessageBody( + lastNonce, + usdcUnits("10000") + ); + const message = encodeCCTPMessage( + 6, + crossChainMasterStrategy.address, + crossChainMasterStrategy.address, + payload + ); + + // Relay the message with fake attestation + await crossChainMasterStrategy.connect(strategist).relay(message, "0x"); + + const remoteStrategyBalance = + await crossChainMasterStrategy.remoteStrategyBalance(); + // We did a deposit of 1000 USDC but had the remote strategy report 10k for the test. + expect(remoteStrategyBalance).to.eq(usdcUnits("10000")); + + expect(await crossChainMasterStrategy.pendingAmount()).to.eq( + usdcUnits("0") + ); + }); + + it("Should accept tokens for a pending withdrawal", async function () { + const { crossChainMasterStrategy, strategist, matt, usdc } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping balance check message fork test because there's a pending transfer" + ); + return; + } + + const vaultAddr = await crossChainMasterStrategy.vaultAddress(); + const impersonatedVault = await impersonateAndFund(vaultAddr); + + // set an arbitrary remote strategy balance + await setRemoteStrategyBalance( + crossChainMasterStrategy, + usdcUnits("123456") + ); + + // Simulate withdrawal call + await crossChainMasterStrategy + .connect(impersonatedVault) + .withdraw(vaultAddr, usdc.address, usdcUnits("1000")); + + const lastNonce = ( + await crossChainMasterStrategy.lastTransferNonce() + ).toNumber(); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + // Build check balance payload + const balancePayload = encodeBalanceCheckMessageBody( + lastNonce, + usdcUnits("12345") + ); + const burnPayload = encodeBurnMessageBody( + crossChainMasterStrategy.address, + crossChainMasterStrategy.address, + usdcUnits("2342"), + balancePayload + ); + const message = encodeCCTPMessage( + 6, + addresses.CCTPTokenMessengerV2, + addresses.CCTPTokenMessengerV2, + burnPayload + ); + + // transfer some USDC to master strategy + await usdc + .connect(matt) + .transfer(crossChainMasterStrategy.address, usdcUnits("2342")); + + // Relay the message with fake attestation + await crossChainMasterStrategy.connect(strategist).relay(message, "0x"); + + const remoteStrategyBalance = + await crossChainMasterStrategy.remoteStrategyBalance(); + expect(remoteStrategyBalance).to.eq(usdcUnits("12345")); + }); + + it("Should ignore balance check message for a pending withdrawal", async function () { + const { crossChainMasterStrategy, strategist, usdc } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping balance check message fork test because there's a pending transfer" + ); + return; + } + + const vaultAddr = await crossChainMasterStrategy.vaultAddress(); + const impersonatedVault = await impersonateAndFund(vaultAddr); + + // set an arbitrary remote strategy balance + await setRemoteStrategyBalance( + crossChainMasterStrategy, + usdcUnits("1000") + ); + + const remoteStrategyBalanceBefore = + await crossChainMasterStrategy.remoteStrategyBalance(); + + // Simulate withdrawal call + await crossChainMasterStrategy + .connect(impersonatedVault) + .withdraw(vaultAddr, usdc.address, usdcUnits("1000")); + + const lastNonce = ( + await crossChainMasterStrategy.lastTransferNonce() + ).toNumber(); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + // Build check balance payload + const payload = encodeBalanceCheckMessageBody( + lastNonce, + usdcUnits("10000") + ); + const message = encodeCCTPMessage( + 6, + crossChainMasterStrategy.address, + crossChainMasterStrategy.address, + payload + ); + + // Relay the message with fake attestation + await crossChainMasterStrategy.connect(strategist).relay(message, "0x"); + + // Should've ignore the message + const remoteStrategyBalance = + await crossChainMasterStrategy.remoteStrategyBalance(); + expect(remoteStrategyBalance).to.eq(remoteStrategyBalanceBefore); + }); + + it("Should ignore balance check message with older nonce", async function () { + const { crossChainMasterStrategy, strategist, matt, usdc } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping balance check message fork test because there's a pending transfer" + ); + return; + } + + const lastNonce = ( + await crossChainMasterStrategy.lastTransferNonce() + ).toNumber(); + + // Do a pre-deposit + const vaultAddr = await crossChainMasterStrategy.vaultAddress(); + + const impersonatedVault = await impersonateAndFund(vaultAddr); + + // Let the strategy hold some USDC + await usdc + .connect(matt) + .transfer(crossChainMasterStrategy.address, usdcUnits("1000")); + + // Simulate deposit call + await crossChainMasterStrategy + .connect(impersonatedVault) + .deposit(usdc.address, usdcUnits("1000")); + + const remoteStrategyBalanceBefore = + await crossChainMasterStrategy.remoteStrategyBalance(); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + // Build check balance payload + const payload = encodeBalanceCheckMessageBody( + lastNonce, + usdcUnits("123244") + ); + const message = encodeCCTPMessage( + 6, + crossChainMasterStrategy.address, + crossChainMasterStrategy.address, + payload + ); + + // Relay the message with fake attestation + await crossChainMasterStrategy.connect(strategist).relay(message, "0x"); + + const remoteStrategyBalance = + await crossChainMasterStrategy.remoteStrategyBalance(); + expect(remoteStrategyBalance).to.eq(remoteStrategyBalanceBefore); + }); + + it("Should ignore if nonce is higher", async function () { + const { crossChainMasterStrategy, strategist } = fixture; + + if (await crossChainMasterStrategy.isTransferPending()) { + // Skip if there's a pending transfer + console.log( + "Skipping balance check message fork test because there's a pending transfer" + ); + return; + } + + const lastNonce = ( + await crossChainMasterStrategy.lastTransferNonce() + ).toNumber(); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + const remoteStrategyBalanceBefore = + await crossChainMasterStrategy.remoteStrategyBalance(); + + // Build check balance payload + const payload = encodeBalanceCheckMessageBody( + lastNonce + 2, + usdcUnits("123244") + ); + const message = encodeCCTPMessage( + 6, + crossChainMasterStrategy.address, + crossChainMasterStrategy.address, + payload + ); + + // Relay the message with fake attestation + await crossChainMasterStrategy.connect(strategist).relay(message, "0x"); + const remoteStrategyBalanceAfter = + await crossChainMasterStrategy.remoteStrategyBalance(); + expect(remoteStrategyBalanceAfter).to.eq(remoteStrategyBalanceBefore); + }); + }); +}); diff --git a/contracts/test/strategies/crosschain/crosschain-remote-strategy.base.fork-test.js b/contracts/test/strategies/crosschain/crosschain-remote-strategy.base.fork-test.js new file mode 100644 index 0000000000..4398e768bf --- /dev/null +++ b/contracts/test/strategies/crosschain/crosschain-remote-strategy.base.fork-test.js @@ -0,0 +1,249 @@ +const { expect } = require("chai"); + +const { isCI, usdcUnits } = require("../../helpers"); +const { createFixtureLoader } = require("../../_fixture"); +const { crossChainFixture } = require("../../_fixture-base"); +const { + MESSAGE_SENT_EVENT_TOPIC, + decodeMessageSentEvent, + decodeBalanceCheckMessageBody, + replaceMessageTransmitter, + encodeBurnMessageBody, + decodeBurnMessageBody, + encodeCCTPMessage, + encodeDepositMessageBody, + encodeWithdrawMessageBody, +} = require("./_crosschain-helpers"); +const addresses = require("../../../utils/addresses"); + +const loadFixture = createFixtureLoader(crossChainFixture); + +describe("ForkTest: CrossChainRemoteStrategy", function () { + this.timeout(0); + + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + + let fixture; + beforeEach(async () => { + fixture = await loadFixture(); + }); + + const verifyBalanceCheckMessage = ( + messageSentEvent, + expectedNonce, + expectedBalance, + transferAmount = "0" + ) => { + const { crossChainRemoteStrategy, usdc } = fixture; + const { + version, + sourceDomain, + desinationDomain, + sender, + recipient, + destinationCaller, + minFinalityThreshold, + payload, + } = decodeMessageSentEvent(messageSentEvent); + + expect(version).to.eq(1); + expect(sourceDomain).to.eq(6); + expect(desinationDomain).to.eq(0); + expect(destinationCaller.toLowerCase()).to.eq( + crossChainRemoteStrategy.address.toLowerCase() + ); + expect(minFinalityThreshold).to.eq(2000); + + let balanceCheckPayload = payload; + + const isBurnMessage = + sender.toLowerCase() == addresses.CCTPTokenMessengerV2.toLowerCase(); + if (isBurnMessage) { + // Verify burn message + const { burnToken, recipient, amount, sender, hookData } = + decodeBurnMessageBody(payload); + expect(burnToken.toLowerCase()).to.eq(usdc.address.toLowerCase()); + expect(recipient.toLowerCase()).to.eq( + crossChainRemoteStrategy.address.toLowerCase() + ); + expect(amount).to.eq(transferAmount); + expect(sender.toLowerCase()).to.eq( + crossChainRemoteStrategy.address.toLowerCase() + ); + balanceCheckPayload = hookData; + } else { + // Ensure sender and recipient are the strategy address + expect(sender.toLowerCase()).to.eq( + crossChainRemoteStrategy.address.toLowerCase() + ); + expect(recipient.toLowerCase()).to.eq( + crossChainRemoteStrategy.address.toLowerCase() + ); + } + + const { + version: balanceCheckVersion, + messageType, + nonce, + balance, + } = decodeBalanceCheckMessageBody(balanceCheckPayload); + + expect(balanceCheckVersion).to.eq(1010); + expect(messageType).to.eq(3); + expect(nonce).to.eq(expectedNonce); + expect(balance).to.approxEqual(expectedBalance); + }; + + it("Should send a balance update message", async function () { + const { crossChainRemoteStrategy, strategist, rafael, usdc } = fixture; + // Send some USDC to the remote strategy + await usdc + .connect(rafael) + .transfer(crossChainRemoteStrategy.address, usdcUnits("1234")); + + const balanceBefore = await crossChainRemoteStrategy.checkBalance( + usdc.address + ); + const nonceBefore = await crossChainRemoteStrategy.lastTransferNonce(); + + const tx = await crossChainRemoteStrategy + .connect(strategist) + .sendBalanceUpdate(); + const receipt = await tx.wait(); + const messageSentEvent = receipt.events.find((e) => + e.topics.includes(MESSAGE_SENT_EVENT_TOPIC) + ); + + verifyBalanceCheckMessage( + messageSentEvent, + nonceBefore.toNumber(), + balanceBefore + ); + }); + + it("Should handle deposits", async function () { + const { crossChainRemoteStrategy, strategist, rafael, usdc } = fixture; + + // snapshot state + const balanceBefore = await crossChainRemoteStrategy.checkBalance( + usdc.address + ); + const nonceBefore = await crossChainRemoteStrategy.lastTransferNonce(); + + const depositAmount = usdcUnits("1234.56"); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + const nextNonce = nonceBefore.toNumber() + 1; + + // Build deposit message + const depositPayload = encodeDepositMessageBody(nextNonce, depositAmount); + const burnPayload = encodeBurnMessageBody( + crossChainRemoteStrategy.address, + crossChainRemoteStrategy.address, + depositAmount, + depositPayload + ); + const message = encodeCCTPMessage( + 0, + addresses.CCTPTokenMessengerV2, + addresses.CCTPTokenMessengerV2, + burnPayload + ); + + // Simulate token transfer + await usdc + .connect(rafael) + .transfer(crossChainRemoteStrategy.address, depositAmount); + + // Relay the message + const tx = await crossChainRemoteStrategy + .connect(strategist) + .relay(message, "0x"); + + // Check if it sent the check balance message + const receipt = await tx.wait(); + const messageSentEvent = receipt.events.find((e) => + e.topics.includes(MESSAGE_SENT_EVENT_TOPIC) + ); + + // Verify the balance check message + const expectedBalance = balanceBefore.add(depositAmount); + verifyBalanceCheckMessage(messageSentEvent, nextNonce, expectedBalance); + + const nonceAfter = await crossChainRemoteStrategy.lastTransferNonce(); + expect(nonceAfter).to.eq(nextNonce); + + const balanceAfter = await crossChainRemoteStrategy.checkBalance( + usdc.address + ); + expect(balanceAfter).to.approxEqual(expectedBalance); + }); + + it("Should handle withdrawals", async function () { + const { crossChainRemoteStrategy, strategist, rafael, usdc } = fixture; + + const withdrawalAmount = usdcUnits("1234.56"); + + // Make sure the strategy has enough balance + const depositAmount = withdrawalAmount.mul(2); + await usdc + .connect(rafael) + .transfer(crossChainRemoteStrategy.address, depositAmount); + await crossChainRemoteStrategy + .connect(strategist) + .deposit(usdc.address, depositAmount); + + // snapshot state + const balanceBefore = await crossChainRemoteStrategy.checkBalance( + usdc.address + ); + const nonceBefore = await crossChainRemoteStrategy.lastTransferNonce(); + const nextNonce = nonceBefore.toNumber() + 1; + + // Build withdrawal message + const withdrawalPayload = encodeWithdrawMessageBody( + nextNonce, + withdrawalAmount + ); + const message = encodeCCTPMessage( + 0, + crossChainRemoteStrategy.address, + crossChainRemoteStrategy.address, + withdrawalPayload + ); + + // Replace transmitter to mock transmitter + await replaceMessageTransmitter(); + + // Relay the message + const tx = await crossChainRemoteStrategy + .connect(strategist) + .relay(message, "0x"); + + // Check if it sent the check balance message + const receipt = await tx.wait(); + const messageSentEvent = receipt.events.find((e) => + e.topics.includes(MESSAGE_SENT_EVENT_TOPIC) + ); + + // Verify the balance check message + const expectedBalance = balanceBefore.sub(withdrawalAmount); + verifyBalanceCheckMessage( + messageSentEvent, + nextNonce, + expectedBalance, + withdrawalAmount + ); + + const nonceAfter = await crossChainRemoteStrategy.lastTransferNonce(); + expect(nonceAfter).to.eq(nextNonce); + + const balanceAfter = await crossChainRemoteStrategy.checkBalance( + usdc.address + ); + expect(balanceAfter).to.approxEqual(expectedBalance); + }); +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 96a8c71356..b5df71600d 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -10,6 +10,11 @@ addresses.multichainBuybackOperator = "0xBB077E716A5f1F1B63ed5244eBFf5214E50fec8c"; addresses.votemarket = "0x8c2c5A295450DDFf4CB360cA73FCCC12243D14D9"; +// CCTP contracts (uses same addresses on all chains) +addresses.CCTPTokenMessengerV2 = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d"; +addresses.CCTPMessageTransmitterV2 = + "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"; + addresses.mainnet = {}; addresses.base = {}; addresses.sonic = {}; @@ -448,6 +453,8 @@ addresses.base.CCIPRouter = "0x881e3A65B4d4a04dD529061dd0071cf975F58bCD"; addresses.base.MerklDistributor = "0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd"; +addresses.base.USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; + // Sonic addresses.sonic.wS = "0x039e2fB66102314Ce7b64Ce5Ce3E5183bc94aD38"; addresses.sonic.WETH = "0x309C92261178fA0CF748A855e90Ae73FDb79EBc7"; @@ -681,4 +688,12 @@ addresses.hoodi.beaconChainDepositContract = addresses.hoodi.defenderRelayer = "0x419B6BdAE482f41b8B194515749F3A2Da26d583b"; addresses.hoodi.mockBeaconRoots = "0xdCfcAE4A084AA843eE446f400B23aA7B6340484b"; +// Crosschain Strategy +// addresses.CrossChainStrategyProxy = +// "TBD"; +// addresses.mainnet.CrossChainStrategyProxy = +// "TBD"; +// addresses.base.CrossChainStrategyProxy = +// "TBD"; + module.exports = addresses; diff --git a/contracts/utils/cctp.js b/contracts/utils/cctp.js new file mode 100644 index 0000000000..3422aba26c --- /dev/null +++ b/contracts/utils/cctp.js @@ -0,0 +1,8 @@ +const cctpDomainIds = { + Ethereum: 0, + Base: 6, +}; + +module.exports = { + cctpDomainIds, +}; diff --git a/contracts/utils/deploy.js b/contracts/utils/deploy.js index bd75e0bba0..8461aa5165 100644 --- a/contracts/utils/deploy.js +++ b/contracts/utils/deploy.js @@ -328,6 +328,11 @@ const _verifyProxyInitializedWithCorrectGovernor = (transactionData) => { return; } + if (isMainnet || isBase || isFork || isBaseFork) { + // TODO: Skip verification for Fork for now + return; + } + const initProxyGovernor = ( "0x" + transactionData.slice(10 + 64 + 24, 10 + 64 + 64) ).toLowerCase();