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/governance/Strategizable.sol b/contracts/contracts/governance/Strategizable.sol index 62a3c0c028..4d823d6d1a 100644 --- a/contracts/contracts/governance/Strategizable.sol +++ b/contracts/contracts/governance/Strategizable.sol @@ -15,7 +15,7 @@ contract Strategizable is Governable { /** * @dev Verifies that the caller is either Governor or Strategist. */ - modifier onlyGovernorOrStrategist() { + modifier onlyGovernorOrStrategist() virtual { require( msg.sender == strategistAddr || isGovernor(), "Caller is not the Strategist or Governor" 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..3006fc5b7e --- /dev/null +++ b/contracts/contracts/strategies/crosschain/AbstractCCTPIntegrator.sol @@ -0,0 +1,630 @@ +// 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"; + +abstract contract AbstractCCTPIntegrator is Governable, IMessageHandlerV2 { + using SafeERC20 for IERC20; + + using BytesHelper for bytes; + using CrossChainStrategyHelper for bytes; + + event CCTPMinFinalityThresholdSet(uint16 minFinalityThreshold); + event CCTPFeePremiumBpsSet(uint16 feePremiumBps); + event OperatorChanged(address operator); + event TokensBridged( + uint32 destinationDomain, + address peerStrategy, + address tokenAddress, + uint256 tokenAmount, + uint256 maxFee, + uint32 minFinalityThreshold, + bytes hookData + ); + event MessageTransmitted( + uint32 destinationDomain, + address peerStrategy, + uint32 minFinalityThreshold, + bytes message + ); + + // 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 private constant BURN_MESSAGE_V2_VERSION_INDEX = 0; + uint8 private constant BURN_MESSAGE_V2_RECIPIENT_INDEX = 36; + uint8 private constant BURN_MESSAGE_V2_AMOUNT_INDEX = 68; + uint8 private constant BURN_MESSAGE_V2_MESSAGE_SENDER_INDEX = 100; + uint8 private constant BURN_MESSAGE_V2_FEE_EXECUTED_INDEX = 164; + uint8 private constant BURN_MESSAGE_V2_HOOK_DATA_INDEX = 228; + + /** + * @notice Max transfer threshold imposed by the CCTP + * Ref: https://developers.circle.com/cctp/evm-smart-contracts#depositforburn + * @dev 10M USDC limit applies to both standard and fast transfer modes. The fast transfer mode has + * an additional limitation that is not present on-chain and Circle may alter that amount off-chain + * at their preference. The amount available for fast transfer can be queried here: + * https://iris-api.circle.com/v2/fastBurn/USDC/allowance . + * If a fast transfer token transaction has been issued and there is not enough allowance for it + * the off-chain Iris component will re-attempt the transaction and if it fails it will fallback + * to a standard transfer. Reference section 4.3 in the whitepaper: + * https://6778953.fs1.hubspotusercontent-na1.net/hubfs/6778953/PDFs/Whitepapers/CCTPV2_White_Paper.pdf + */ + 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 + * @dev When configuring the contract for fast transfer we should check the available + * allowance of USDC that can be bridged using fast mode: + * wget https://iris-api.circle.com/v2/fastBurn/USDC/allowance + */ + 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[48] 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) { + require(_config.usdcToken != address(0), "Invalid USDC address"); + require( + _config.cctpTokenMessenger != address(0), + "Invalid CCTP config" + ); + require( + _config.cctpMessageTransmitter != address(0), + "Invalid CCTP config" + ); + require( + _config.peerStrategy != address(0), + "Invalid peer strategy address" + ); + + 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); + + // Nonce starts at 1, so assume nonce 0 as processed. + // NOTE: This will cause the deposit/withdraw to fail if the + // strategy is not initialized properly (which is expected). + nonceProcessed[0] = true; + } + + /*************************************** + 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) { + // Make sure the finality threshold at execution is at least 2000 + require( + finalityThresholdExecuted >= 2000, + "Finality threshold too low" + ); + + 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" + ); + // Make sure the finality threshold at execution is at least 1000 + require( + finalityThresholdExecuted >= 1000, + "Finality threshold too low" + ); + + 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 + ); + + emit TokensBridged( + peerDomainID, + peerStrategy, + usdcToken, + tokenAmount, + 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 + ); + + emit MessageTransmitted( + peerDomainID, + 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 + ) = message.decodeMessageHeader(); + + // 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 + ****************************************/ + + /*************************************** + 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) { + return !nonceProcessed[lastTransferNonce]; + } + + /** + * @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 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 + // Master strategy when receiving a message from the remote strategy + // will have lastNone == nonce, as the nonce is increase at the start + // of deposit / withdrawal flow. + // Remote strategy will have lastNonce < nonce, as a new nonce initiated + // from master will be greater than the last one. + 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(nonceProcessed[nonce], "Pending token transfer"); + + 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..7f49b59966 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/CrossChainMasterStrategy.sol @@ -0,0 +1,353 @@ +// 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 + * @dev The remote balance is cached and might not reflect the actual + * real-time balance of the remote strategy. + */ + uint256 public remoteStrategyBalance; + + /// @notice Amount that's bridged due to a pending Deposit process + /// but with no acknowledgement from the remote strategy yet + 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) + { + require( + _stratConfig.vaultAddress != address(0), + "Invalid Vault address" + ); + } + + /** + * @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)); + // Deposit if balance is greater than 1 USDC + if (balance >= 1e6) { + _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 { + if (isTransferPending()) { + // Do nothing if there is a pending transfer + return; + } + + // Withdraw everything in Remote strategy + uint256 _remoteBalance = remoteStrategyBalance; + if (_remoteBalance < 1e6) { + // Do nothing if there is less than 1 USDC in the Remote strategy + return; + } + + _withdraw( + usdcToken, + vaultAddress, + // Withdraw at most the max transfer amount + _remoteBalance > MAX_TRANSFER_AMOUNT + ? MAX_TRANSFER_AMOUNT + : _remoteBalance + ); + } + + /** + * @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"); + // Deposit at least 1 USDC + require(depositAmount >= 1e6, "Deposit amount too small"); + 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"); + // Withdraw at least 1 USDC + require(_amount >= 1e6, "Withdraw amount too small"); + 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..f7dbcb0263 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/CrossChainRemoteStrategy.sol @@ -0,0 +1,395 @@ +// 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"; +import { Strategizable } from "../../governance/Strategizable.sol"; + +contract CrossChainRemoteStrategy is + AbstractCCTPIntegrator, + Generalized4626Strategy, + Strategizable +{ + using SafeERC20 for IERC20; + using CrossChainStrategyHelper for bytes; + + event DepositUnderlyingFailed(string reason); + event WithdrawalFailed(uint256 amountRequested, uint256 amountAvailable); + event WithdrawUnderlyingFailed(string reason); + + modifier onlyOperatorOrStrategistOrGovernor() { + require( + msg.sender == operator || + msg.sender == strategistAddr || + isGovernor(), + "Caller is not the Operator, Strategist or the Governor" + ); + _; + } + + modifier onlyGovernorOrStrategist() + override(InitializableAbstractStrategy, Strategizable) { + require( + msg.sender == strategistAddr || isGovernor(), + "Caller is not the Strategist or Governor" + ); + _; + } + + constructor( + BaseStrategyConfig memory _baseConfig, + CCTPIntegrationConfig memory _cctpConfig + ) + AbstractCCTPIntegrator(_cctpConfig) + Generalized4626Strategy(_baseConfig, _cctpConfig.usdcToken) + { + require(usdcToken == address(assetToken), "Token mismatch"); + require( + _baseConfig.platformAddress != address(0), + "Invalid platform address" + ); + // Vault address must always be address(0) for the remote strategy + require( + _baseConfig.vaultAddress == address(0), + "Invalid vault address" + ); + } + + /** + * @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 + ); + } + + /// @inheritdoc Generalized4626Strategy + function deposit(address _asset, uint256 _amount) + external + virtual + override + onlyGovernorOrStrategist + nonReentrant + { + _deposit(_asset, _amount); + } + + /// @inheritdoc Generalized4626Strategy + function depositAll() + external + virtual + override + onlyGovernorOrStrategist + nonReentrant + { + _deposit(usdcToken, IERC20(usdcToken).balanceOf(address(this))); + } + + /// @inheritdoc Generalized4626Strategy + function withdraw( + address _recipient, + address _asset, + uint256 _amount + ) external virtual override onlyGovernorOrStrategist nonReentrant { + _withdraw(_recipient, _asset, _amount); + } + + /// @inheritdoc Generalized4626Strategy + function withdrawAll() + external + virtual + override + onlyGovernorOrStrategist + nonReentrant + { + 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 is part of the _markNonceAsProcessed function + _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. + if (balance >= 1e6) { + _deposit(usdcToken, balance); + } + + // Send balance check message to the peer strategy + bytes memory message = CrossChainStrategyHelper + .encodeBalanceCheckMessage( + lastTransferNonce, + checkBalance(usdcToken), + 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 { + // By design, this function should not revert. Otherwise, it'd + // not be able to process messages and might freeze the contracts + // state. However these two require statements would never fail + // in every function invoking this. The same kind of checks should + // be enforced in all the calling functions for these two and any + // other require statements added to this function. + 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 is part of the _markNonceAsProcessed function + _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 strategyBalance = 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 (withdrawAmount >= 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, + strategyBalance - withdrawAmount, + true + ); + _sendTokens(withdrawAmount, message); + } else { + // Contract either: + // - only has small dust amount of USDC + // - 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, + strategyBalance, + true + ); + _sendMessage(message); + emit WithdrawalFailed(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..8dc5766d19 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/CrossChainStrategyHelper.sol @@ -0,0 +1,254 @@ +// 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; + + // CCTP Message Header fields + // Ref: https://developers.circle.com/cctp/technical-guide#message-header + uint8 private constant VERSION_INDEX = 0; + uint8 private constant SOURCE_DOMAIN_INDEX = 4; + uint8 private constant SENDER_INDEX = 44; + uint8 private constant RECIPIENT_INDEX = 76; + uint8 private constant MESSAGE_BODY_INDEX = 148; + + /** + * @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); + } + + /** + * @dev Decode 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); + } +} diff --git a/contracts/contracts/strategies/crosschain/crosschain-strategy.md b/contracts/contracts/strategies/crosschain/crosschain-strategy.md new file mode 100644 index 0000000000..ab1afed9d9 --- /dev/null +++ b/contracts/contracts/strategies/crosschain/crosschain-strategy.md @@ -0,0 +1,744 @@ +# 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 + +## Analytics & Monitoring + +### Useful Contract Methods and Variables +- `MasterStrategy.checkBalance(address)`: Returns the sum of balance held locally in the master contract, balance reported by the remote strategy and any tokens that are being bridged and are yet to be acknowledged by the rremote strategy +- `MasterStrategy.pendingAmount`: Returns the amount that is being bridged from Master to Remote strategy. Once it's received on Remote strategy and it sends back an acknowledgement, it'll set back to zero. +- `MasterStrategy.remoteStrategyBalacne`: Last reported balance of Remote strategy +- `RemoteStrategy.checkBalance(address)`: Returns the balance held by the remote strategy as well as the amount it has deposited into the underlying 4626 vault +- `RemoteStrategy.platformAddress`: Returns the underlying 4626 Vault to which remote strategy deposits funds to. + + +### Contract Events +The following events need to be monitored from the contracts and an alert be sent to any of the channels as they happen: + + ``` + event CCTPMinFinalityThresholdSet(uint16 minFinalityThreshold); + event CCTPFeePremiumBpsSet(uint16 feePremiumBps); + event OperatorChanged(address operator); + + event TokensBridged( + uint32 destinationDomain, + address peerStrategy, + address tokenAddress, + uint256 tokenAmount, + uint256 maxFee, + uint32 minFinalityThreshold, + bytes hookData + ); + + event MessageTransmitted( + uint32 destinationDomain, + address peerStrategy, + uint32 minFinalityThreshold, + bytes message + ); + + event DepositUnderlyingFailed(string reason); + event WithdrawalFailed(uint256 amountRequested, uint256 amountAvailable); + event WithdrawUnderlyingFailed(string reason); + event StrategistUpdated(address _address); + + event RemoteStrategyBalanceUpdated(uint256 balance); + event WithdrawRequested(address indexed asset, uint256 amount); + ``` + +Out of these, `DepositUnderlyingFailed`, `WithdrawalFailed` and `WithdrawUnderlyingFailed` are of higher importance as they require manual intervention by Guardian when they get emitted. \ No newline at end of file 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/contracts/utils/InitializableAbstractStrategy.sol b/contracts/contracts/utils/InitializableAbstractStrategy.sol index 890da82e57..dbb4adb097 100644 --- a/contracts/contracts/utils/InitializableAbstractStrategy.sol +++ b/contracts/contracts/utils/InitializableAbstractStrategy.sol @@ -81,7 +81,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { /** * @dev Verifies that the caller is the Governor or Strategist. */ - modifier onlyGovernorOrStrategist() { + modifier onlyGovernorOrStrategist() virtual { require( isGovernor() || msg.sender == IVault(vaultAddress).strategistAddr(), "Caller is not the Strategist or Governor" 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..4069db4799 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,319 @@ 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, + addresses.zero, // There is no vault on the remote strategy + ], + [ + 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 +2043,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..8fb9766d03 --- /dev/null +++ b/contracts/test/strategies/crosschain/_crosschain-helpers.js @@ -0,0 +1,257 @@ +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 = 207; + +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, + transferConfirmation +) => { + const encodedPayload = ethers.utils.defaultAbiCoder.encode( + ["uint64", "uint256", "bool"], + [nonce, balance, transferConfirmation] + ); + + // 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..5911e3bf2e --- /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 token transfer" + ); + }); + + 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 token transfer" + ); + }); + + 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 token transfer" + ); + }); +}); 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..ec72199028 --- /dev/null +++ b/contracts/test/strategies/crosschain/crosschain-master-strategy.mainnet.fork-test.js @@ -0,0 +1,497 @@ +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"), + false + ); + 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"), + true // deposit confirmation + ); + 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"), + true // withdrawal confirmation + ); + 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"), + false + ); + 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"), + false // deposit confirmation + ); + 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"), + false + ); + 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();