diff --git a/.changeset/nice-buttons-work.md b/.changeset/nice-buttons-work.md new file mode 100644 index 000000000..478ac9bf1 --- /dev/null +++ b/.changeset/nice-buttons-work.md @@ -0,0 +1,5 @@ +--- +"@layerzerolabs/oft-evm": minor +--- + +Added MintBurnOFTAdapter diff --git a/packages/oft-evm/contracts/MintBurnOFTAdapter.sol b/packages/oft-evm/contracts/MintBurnOFTAdapter.sol new file mode 100644 index 000000000..067b15646 --- /dev/null +++ b/packages/oft-evm/contracts/MintBurnOFTAdapter.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +// External imports +import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// Local imports +import { IMintableBurnable } from "./interfaces/IMintableBurnable.sol"; +import { OFTCore } from "./OFTCore.sol"; + +/** + * @title MintBurnOFTAdapter + * @notice A variant of the standard OFT Adapter that uses an existing ERC20's mint and burn mechanisms for cross-chain transfers. + * + * @dev Inherits from OFTCore and provides implementations for _debit and _credit functions using a mintable and burnable token. + */ +abstract contract MintBurnOFTAdapter is OFTCore { + /// @dev The underlying ERC20 token. + IERC20 internal immutable innerToken; + + /// @notice The contract responsible for minting and burning tokens. + IMintableBurnable public immutable minterBurner; + + /** + * @notice Initializes the MintBurnOFTAdapter contract. + * + * @param _token The address of the underlying ERC20 token. + * @param _minterBurner The contract responsible for minting and burning tokens. + * @param _lzEndpoint The LayerZero endpoint address. + * @param _delegate The address of the delegate. + * + * @dev Calls the OFTCore constructor with the token's decimals, the endpoint, and the delegate. + */ + constructor( + address _token, + IMintableBurnable _minterBurner, + address _lzEndpoint, + address _delegate + ) OFTCore(IERC20Metadata(_token).decimals(), _lzEndpoint, _delegate) { + innerToken = IERC20(_token); + minterBurner = _minterBurner; + } + + /** + * @notice Retrieves the address of the underlying ERC20 token. + * + * @return The address of the adapted ERC20 token. + * + * @dev In the case of MintBurnOFTAdapter, address(this) and erc20 are NOT the same contract. + */ + function token() public view returns (address) { + return address(innerToken); + } + + /** + * @notice Indicates whether the OFT contract requires approval of the underlying token to send. + * + * @return requiresApproval True if approval is required, false otherwise. + * + * @dev In this MintBurnOFTAdapter, approval is NOT required because it uses mint and burn privileges. + */ + function approvalRequired() external pure virtual returns (bool) { + return false; + } + + /** + * @notice Burns tokens from the sender's balance to prepare for sending. + * + * @param _from The address to debit the tokens from. + * @param _amountLD The amount of tokens to send in local decimals. + * @param _minAmountLD The minimum amount to send in local decimals. + * @param _dstEid The destination chain ID. + * + * @return amountSentLD The amount sent in local decimals. + * @return amountReceivedLD The amount received in local decimals on the remote. + * + * @dev WARNING: The default OFTAdapter implementation assumes LOSSLESS transfers, i.e., 1 token in, 1 token out. + * If the 'innerToken' applies something like a transfer fee, the default will NOT work. + * A pre/post balance check will need to be done to calculate the amountReceivedLD. + */ + function _debit( + address _from, + uint256 _amountLD, + uint256 _minAmountLD, + uint32 _dstEid + ) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) { + (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid); + // Burns tokens from the caller. + minterBurner.burn(_from, amountSentLD); + } + + /** + * @notice Mints tokens to the specified address upon receiving them. + * + * @param _to The address to credit the tokens to. + * @param _amountLD The amount of tokens to credit in local decimals. + * + * @return amountReceivedLD The amount of tokens actually received in local decimals. + * + * @dev WARNING: The default OFTAdapter implementation assumes LOSSLESS transfers, i.e., 1 token in, 1 token out. + * If the 'innerToken' applies something like a transfer fee, the default will NOT work. + * A pre/post balance check will need to be done to calculate the amountReceivedLD. + */ + function _credit( + address _to, + uint256 _amountLD, + uint32 /* _srcEid */ + ) internal virtual override returns (uint256 amountReceivedLD) { + if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0) + // Mints the tokens and transfers to the recipient. + minterBurner.mint(_to, _amountLD); + // In the case of NON-default OFTAdapter, the amountLD MIGHT not be equal to amountReceivedLD. + return _amountLD; + } +} diff --git a/packages/oft-evm/contracts/interfaces/IMintableBurnable.sol b/packages/oft-evm/contracts/interfaces/IMintableBurnable.sol new file mode 100644 index 000000000..43c5a9181 --- /dev/null +++ b/packages/oft-evm/contracts/interfaces/IMintableBurnable.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +/// @title Interface for mintable and burnable tokens +interface IMintableBurnable { + + /** + * @notice Burns tokens from a specified account + * @param _from Address from which tokens will be burned + * @param _amount Amount of tokens to be burned + * @return success Indicates whether the operation was successful + */ + function burn(address _from, uint256 _amount) external returns (bool success); + + /** + * @notice Mints tokens to a specified account + * @param _to Address to which tokens will be minted + * @param _amount Amount of tokens to be minted + * @return success Indicates whether the operation was successful + */ + function mint(address _to, uint256 _amount) external returns (bool success); +} \ No newline at end of file diff --git a/packages/oft-evm/test/OFT.t.sol b/packages/oft-evm/test/OFT.t.sol index 2f07ef6ef..86b8de4d8 100644 --- a/packages/oft-evm/test/OFT.t.sol +++ b/packages/oft-evm/test/OFT.t.sol @@ -8,6 +8,9 @@ import { MessagingFee, MessagingReceipt } from "../contracts/OFTCore.sol"; import { NativeOFTAdapterMock } from "./mocks/NativeOFTAdapterMock.sol"; import { OFTAdapterMock } from "./mocks/OFTAdapterMock.sol"; import { ERC20Mock } from "./mocks/ERC20Mock.sol"; +import { MintBurnERC20Mock } from "./mocks/MintBurnERC20Mock.sol"; +import { ElevatedMinterBurnerMock } from "./mocks/ElevatedMinterBurnerMock.sol"; +import { MintBurnOFTAdapterMock } from "./mocks/MintBurnOFTAdapterMock.sol"; import { OFTComposerMock } from "./mocks/OFTComposerMock.sol"; import { OFTInspectorMock, IOAppMsgInspector } from "./mocks/OFTInspectorMock.sol"; import { IOAppOptionsType3, EnforcedOptionParam } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OAppOptionsType3.sol"; @@ -18,6 +21,8 @@ import { OFTComposeMsgCodec } from "../contracts/libs/OFTComposeMsgCodec.sol"; import { IOFT, SendParam, OFTReceipt } from "../contracts/interfaces/IOFT.sol"; import { OFT } from "../contracts/OFT.sol"; +import { MintBurnOFTAdapter } from "../contracts/MintBurnOFTAdapter.sol"; +import { IMintableBurnable } from "../contracts/interfaces/IMintableBurnable.sol"; import { NativeOFTAdapter } from "../contracts/NativeOFTAdapter.sol"; import { OFTAdapter } from "../contracts/OFTAdapter.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; @@ -25,6 +30,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Met import { OFTMockCodec } from "./lib/OFTMockCodec.sol"; import { OFTAdapterMockCodec } from "./lib/OFTAdapterMockCodec.sol"; import { NativeOFTAdapterMockCodec } from "./lib/NativeOFTAdapterMockCodec.sol"; +import { MintBurnOFTAdapterMockCodec } from "./lib/MintBurnOFTAdapterMockCodec.sol"; import "forge-std/console.sol"; import { TestHelperOz5 } from "@layerzerolabs/test-devtools-evm-foundry/contracts/TestHelperOz5.sol"; @@ -34,11 +40,13 @@ contract OFTTest is TestHelperOz5 { using OFTMockCodec for OFT; using OFTAdapterMockCodec for OFTAdapter; using NativeOFTAdapterMockCodec for NativeOFTAdapter; + using MintBurnOFTAdapterMockCodec for MintBurnOFTAdapter; uint32 internal constant A_EID = 1; uint32 internal constant B_EID = 2; uint32 internal constant C_EID = 3; uint32 internal constant D_EID = 4; + uint32 internal constant E_EID = 5; string internal constant A_OFT_NAME = "aOFT"; string internal constant A_OFT_SYMBOL = "aOFT"; @@ -46,12 +54,18 @@ contract OFTTest is TestHelperOz5 { string internal constant B_OFT_SYMBOL = "bOFT"; string internal constant C_TOKEN_NAME = "cToken"; string internal constant C_TOKEN_SYMBOL = "cToken"; + string internal constant E_MINTABLE_TOKEN_NAME = "eMintableToken"; + string internal constant E_MINTABLE_TOKEN_SYMBOL = "eToken"; + OFT internal aOFT; OFT internal bOFT; - NativeOFTAdapter internal dNativeOFTAdapter; OFTAdapter internal cOFTAdapter; ERC20Mock internal cERC20Mock; + NativeOFTAdapter internal dNativeOFTAdapter; + MintBurnOFTAdapter internal eMintBurnOFTAdapter; + ElevatedMinterBurnerMock internal eMinterBurnerMock; + MintBurnERC20Mock internal eMintBurnERC20Mock; OFTInspectorMock internal oAppInspector; @@ -59,6 +73,8 @@ contract OFTTest is TestHelperOz5 { address public userB = makeAddr("userB"); address public userC = makeAddr("userC"); address public userD = makeAddr("userD"); + address public userE = makeAddr("userE"); + address public attacker = makeAddr("attacker"); uint256 public initialBalance = 100 ether; uint256 public initialNativeBalance = 1000 ether; @@ -66,7 +82,7 @@ contract OFTTest is TestHelperOz5 { _deal(); super.setUp(); - setUpEndpoints(4, LibraryType.UltraLightNode); + setUpEndpoints(5, LibraryType.UltraLightNode); aOFT = OFTMock( _deployOApp( @@ -97,18 +113,30 @@ contract OFTTest is TestHelperOz5 { ) ); + eMintBurnERC20Mock = new MintBurnERC20Mock(E_MINTABLE_TOKEN_NAME, E_MINTABLE_TOKEN_SYMBOL); + eMinterBurnerMock = new ElevatedMinterBurnerMock(IMintableBurnable(eMintBurnERC20Mock), address(this)); + eMintBurnOFTAdapter = MintBurnOFTAdapterMock( + _deployOApp( + type(MintBurnOFTAdapterMock).creationCode, + abi.encode(address(eMintBurnERC20Mock), address(eMinterBurnerMock), address(endpoints[E_EID]), address(this)) + ) + ); + eMinterBurnerMock.setOperator(address(eMintBurnOFTAdapter), true); + // config and wire the ofts - address[] memory ofts = new address[](4); + address[] memory ofts = new address[](5); ofts[0] = address(aOFT); ofts[1] = address(bOFT); ofts[2] = address(cOFTAdapter); ofts[3] = address(dNativeOFTAdapter); + ofts[4] = address(eMintBurnOFTAdapter); this.wireOApps(ofts); // mint tokens aOFT.asOFTMock().mint(userA, initialBalance); bOFT.asOFTMock().mint(userB, initialBalance); cERC20Mock.mint(userC, initialBalance); + eMintBurnERC20Mock.mint(userE, initialBalance); // deploy a universal inspector, can be used by each oft oAppInspector = new OFTInspectorMock(); @@ -119,6 +147,7 @@ contract OFTTest is TestHelperOz5 { vm.deal(userB, initialNativeBalance); vm.deal(userC, initialNativeBalance); vm.deal(userD, initialNativeBalance); + vm.deal(userE, initialNativeBalance); } function test_constructor() public { @@ -126,17 +155,21 @@ contract OFTTest is TestHelperOz5 { assertEq(bOFT.owner(), address(this)); assertEq(cOFTAdapter.owner(), address(this)); assertEq(dNativeOFTAdapter.owner(), address(this)); + assertEq(eMintBurnOFTAdapter.owner(), address(this)); assertEq(aOFT.balanceOf(userA), initialBalance); assertEq(bOFT.balanceOf(userB), initialBalance); assertEq(IERC20(cOFTAdapter.token()).balanceOf(userC), initialBalance); + assertEq(IERC20(eMintBurnOFTAdapter.token()).balanceOf(userE), initialBalance); assertEq(aOFT.token(), address(aOFT)); assertEq(bOFT.token(), address(bOFT)); assertEq(cOFTAdapter.token(), address(cERC20Mock)); assertEq(dNativeOFTAdapter.token(), address(0)); + assertEq(eMintBurnOFTAdapter.token(), address(eMintBurnERC20Mock)); assertEq(dNativeOFTAdapter.approvalRequired(), false); + assertEq(eMintBurnOFTAdapter.approvalRequired(), false); } function test_oftVersion() public { @@ -474,6 +507,85 @@ contract OFTTest is TestHelperOz5 { dNativeOFTAdapter.asNativeOFTAdapterMock().send{ value: extraMsgValue}(sendParam, fee, userD); } + function test_set_minter_burner_operator() public { + vm.prank(attacker); + vm.expectRevert(); + eMinterBurnerMock.setOperator(address(eMintBurnOFTAdapter), true); + } + + function test_minter_burner_operator() public { + vm.prank(attacker); + vm.expectRevert(); + eMinterBurnerMock.mint(attacker, initialBalance); + } + + function test_burn_operator() public { + vm.prank(attacker); + vm.expectRevert(); + eMinterBurnerMock.burn(attacker, initialBalance); + } + + function test_mint_burn_oft_adapter_debit() public virtual { + uint256 amountToSendLD = 1 ether; + uint256 minAmountToCreditLD = 1 ether; + uint32 dstEid = E_EID; + + vm.prank(userE); + vm.expectRevert( + abi.encodeWithSelector(IOFT.SlippageExceeded.selector, amountToSendLD, minAmountToCreditLD + 1) + ); + eMintBurnOFTAdapter.asMintBurnOFTAdapterMock().debit(amountToSendLD, minAmountToCreditLD + 1, dstEid); + + vm.prank(userE); + (uint256 amountDebitedLD, uint256 amountToCreditLD) = eMintBurnOFTAdapter.asMintBurnOFTAdapterMock().debit( + amountToSendLD, + minAmountToCreditLD, + dstEid + ); + + assertEq(amountDebitedLD, amountToSendLD); + assertEq(amountToCreditLD, amountToSendLD); + } + + function test_mint_burn_oft_adapter_credit() public { + uint256 amountToCreditLD = 1 ether; + uint32 srcEid = C_EID; + + vm.prank(userC); + cERC20Mock.transfer(address(cOFTAdapter), amountToCreditLD); + + uint256 amountReceived = eMintBurnOFTAdapter.asMintBurnOFTAdapterMock().credit(userE, amountToCreditLD, srcEid); + + assertEq(cERC20Mock.balanceOf(userC), initialBalance - amountToCreditLD); + assertEq(eMintBurnERC20Mock.balanceOf(address(userE)), initialBalance + amountReceived); + } + + function test_mint_burn_oft_adapter_send() public { + uint256 tokensToSend = 1 ether; + bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200000, 0); + SendParam memory sendParam = SendParam( + B_EID, + addressToBytes32(userB), + tokensToSend, + tokensToSend, + options, + "", + "" + ); + MessagingFee memory fee = eMintBurnOFTAdapter.quoteSend(sendParam, false); + + assertEq(eMintBurnERC20Mock.balanceOf(userE), initialBalance); + assertEq(bOFT.balanceOf(userB), initialBalance); + + vm.startPrank(userE); + eMintBurnOFTAdapter.send{ value: fee.nativeFee }(sendParam, fee, payable(address(this))); + vm.stopPrank(); + verifyPackets(B_EID, addressToBytes32(address(bOFT))); + + assertEq(eMintBurnERC20Mock.balanceOf(userE), initialBalance - tokensToSend); + assertEq(bOFT.balanceOf(userB), initialBalance + tokensToSend); + } + function decodeOFTMsgCodec( bytes calldata message ) public pure returns (bool isComposed, bytes32 sendTo, uint64 amountSD, bytes memory composeMsg) { diff --git a/packages/oft-evm/test/lib/MintBurnOFTAdapterMockCodec.sol b/packages/oft-evm/test/lib/MintBurnOFTAdapterMockCodec.sol new file mode 100644 index 000000000..df946a1f6 --- /dev/null +++ b/packages/oft-evm/test/lib/MintBurnOFTAdapterMockCodec.sol @@ -0,0 +1,15 @@ +// SPDX-LICENSE-Identifier: UNLICENSED + +pragma solidity ^0.8.22; + +import { MintBurnOFTAdapterMock } from "../mocks/MintBurnOFTAdapterMock.sol"; +import { MintBurnOFTAdapter } from "../../contracts/MintBurnOFTAdapter.sol"; + +// @title MintBurnOFTAdapterMockCodec +// @notice Codec to convert MintBurnOFTAdapter to MintBurnOFTAdapterMock in a consistent, readable manner. +// @dev For testing purposes only. +library MintBurnOFTAdapterMockCodec { + function asMintBurnOFTAdapterMock(MintBurnOFTAdapter _oft) internal pure returns (MintBurnOFTAdapterMock) { + return MintBurnOFTAdapterMock(address(_oft)); + } +} diff --git a/packages/oft-evm/test/mocks/ElevatedMinterBurnerMock.sol b/packages/oft-evm/test/mocks/ElevatedMinterBurnerMock.sol new file mode 100644 index 000000000..a334a7c3e --- /dev/null +++ b/packages/oft-evm/test/mocks/ElevatedMinterBurnerMock.sol @@ -0,0 +1,66 @@ +pragma solidity ^0.8.20; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { IMintableBurnable } from "../../contracts/interfaces/IMintableBurnable.sol"; + +/// @title Operatable +/// @notice Enables granular access control by designating operators +contract Operatable is Ownable { + /// @notice Triggered when an operator is added or removed + event OperatorChanged(address indexed operator, bool status); + + /// @notice Error to indicate unauthorized access by non-operators + error NotAllowedOperator(); + + /// @dev Mapping of addresses to their operator status + mapping(address => bool) public operators; + + /// @notice Initializes the contract by setting the deployer as an operator + /// @param _owner Address that will own the contract + constructor(address _owner) Ownable(_owner) { + operators[msg.sender] = true; + } + + /// @notice Ensures function is called by an operator + modifier onlyOperators() { + if (!operators[msg.sender]) { + revert NotAllowedOperator(); + } + _; + } + + /** + * @notice Allows the owner to set or unset operator status of an address + * @param operator The address to be modified + * @param status Boolean indicating whether the address should be an operator + */ + function setOperator(address operator, bool status) external onlyOwner { + operators[operator] = status; + emit OperatorChanged(operator, status); + } +} + +/// @title ElevatedMinterBurnerMock +/// @notice Manages minting and burning of tokens through delegated control to operators +contract ElevatedMinterBurnerMock is IMintableBurnable, Operatable { + /// @notice Reference to the token with mint and burn capabilities + IMintableBurnable public immutable token; + + /** + * @notice Initializes the contract by linking a token and setting the owner + * @param token_ The mintable and burnable token interface address + * @param _owner The owner of this contract, who can set operators + */ + constructor(IMintableBurnable token_, address _owner) Operatable(_owner) { + token = token_; + } + + function burn(address from, uint256 amount) external override onlyOperators returns (bool) { + return token.burn(from, amount); + } + + function mint(address to, uint256 amount) external override onlyOperators returns (bool) { + return token.mint(to, amount); + } +} \ No newline at end of file diff --git a/packages/oft-evm/test/mocks/MintBurnERC20Mock.sol b/packages/oft-evm/test/mocks/MintBurnERC20Mock.sol new file mode 100644 index 000000000..981217cce --- /dev/null +++ b/packages/oft-evm/test/mocks/MintBurnERC20Mock.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import { IMintableBurnable } from "../../contracts/interfaces/IMintableBurnable.sol"; + +// @dev WARNING: This is for testing purposes only +contract MintBurnERC20Mock is ERC20, IMintableBurnable { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function burn(address _from, uint256 _amount) external returns (bool) { + _burn(_from, _amount); + return true; + } + + function mint(address _to, uint256 _amount) external returns (bool) { + _mint(_to, _amount); + return true; + } +} \ No newline at end of file diff --git a/packages/oft-evm/test/mocks/MintBurnOFTAdapterMock.sol b/packages/oft-evm/test/mocks/MintBurnOFTAdapterMock.sol new file mode 100644 index 000000000..759126107 --- /dev/null +++ b/packages/oft-evm/test/mocks/MintBurnOFTAdapterMock.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import { MintBurnOFTAdapter } from "../../contracts/MintBurnOFTAdapter.sol"; +import { IMintableBurnable } from "../../contracts/interfaces/IMintableBurnable.sol"; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +// @dev WARNING: This is for testing purposes only +contract MintBurnOFTAdapterMock is MintBurnOFTAdapter { + constructor( + address _token, + IMintableBurnable _minterBurner, + address _lzEndpoint, + address _delegate + ) MintBurnOFTAdapter(_token, _minterBurner, _lzEndpoint, _delegate) Ownable(_delegate) {} + + // @dev expose internal functions for testing purposes + function debit( + uint256 _amountToSendLD, + uint256 _minAmountToCreditLD, + uint32 _dstEid + ) public returns (uint256 amountDebitedLD, uint256 amountToCreditLD) { + return _debit(msg.sender, _amountToSendLD, _minAmountToCreditLD, _dstEid); + } + + function credit(address _to, uint256 _amountToCreditLD, uint32 _srcEid) public returns (uint256 amountReceivedLD) { + return _credit(_to, _amountToCreditLD, _srcEid); + } +} \ No newline at end of file