diff --git a/contracts/package.json b/contracts/package.json index ca007745ce0..c27fa01eed8 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -27,10 +27,7 @@ }, "files": [ "src/v0.8/ccip/**/*.sol", - "src/v0.8/shared/access/ConfirmedOwner.sol", - "src/v0.8/shared/access/ConfirmedOwnerWithProposal.sol", - "src/v0.8/shared/access/OwnerIsCreator.sol", - "src/v0.8/shared/access/AuthorizedCallers.sol", + "src/v0.8/shared/access/*.sol", "src/v0.8/shared/call/CallWithExactGas.sol", "src/v0.8/shared/enumerable/EnumerableMapBytes32.sol", "src/v0.8/shared/enumerable/EnumerableMapAddresses.sol", diff --git a/contracts/src/v0.8/ccip/interfaces/ITokenAdminRegistry.sol b/contracts/src/v0.8/ccip/interfaces/ITokenAdminRegistry.sol index 04bacbac5a0..484289d1e72 100644 --- a/contracts/src/v0.8/ccip/interfaces/ITokenAdminRegistry.sol +++ b/contracts/src/v0.8/ccip/interfaces/ITokenAdminRegistry.sol @@ -11,4 +11,24 @@ interface ITokenAdminRegistry { /// @param localToken The token to register the administrator for. /// @param administrator The administrator to register. function proposeAdministrator(address localToken, address administrator) external; + + /// @notice Accepts the administrator role for a token. + /// @param localToken The token to accept the administrator role for. + /// @dev This function can only be called by the pending administrator. + function acceptAdminRole( + address localToken + ) external; + + /// @notice Sets the pool for a token. Setting the pool to address(0) effectively delists the token + /// from CCIP. Setting the pool to any other address enables the token on CCIP. + /// @param localToken The token to set the pool for. + /// @param pool The pool to set for the token. + function setPool(address localToken, address pool) external; + + /// @notice Transfers the administrator role for a token to a new address with a 2-step process. + /// @param localToken The token to transfer the administrator role for. + /// @param newAdmin The address to transfer the administrator role to. Can be address(0) to cancel + /// a pending transfer. + /// @dev The new admin must call `acceptAdminRole` to accept the role. + function transferAdminRole(address localToken, address newAdmin) external; } diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/BurnMintERC20Setup.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/BurnMintERC20Setup.t.sol new file mode 100644 index 00000000000..098d5e4601e --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/BurnMintERC20Setup.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BaseTest} from "../../BaseTest.t.sol"; + +contract BurnMintERC20Setup is BaseTest { + FactoryBurnMintERC20 internal s_burnMintERC20; + + address internal s_mockPool = makeAddr("s_mockPool"); + uint256 internal s_amount = 1e18; + + address internal s_alice; + + function setUp() public virtual override { + BaseTest.setUp(); + + s_alice = makeAddr("alice"); + + s_burnMintERC20 = new FactoryBurnMintERC20("Chainlink Token", "LINK", 18, 1e27, 0, s_alice); + + // Set s_mockPool to be a burner and minter + s_burnMintERC20.grantMintAndBurnRoles(s_mockPool); + deal(address(s_burnMintERC20), OWNER, s_amount); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.approve.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.approve.t.sol new file mode 100644 index 00000000000..9ba6da0186d --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.approve.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_approve is BurnMintERC20Setup { + function test_Approve_Success() public { + uint256 balancePre = s_burnMintERC20.balanceOf(STRANGER); + uint256 sendingAmount = s_amount / 2; + + s_burnMintERC20.approve(STRANGER, sendingAmount); + + changePrank(STRANGER); + + s_burnMintERC20.transferFrom(OWNER, STRANGER, sendingAmount); + + assertEq(sendingAmount + balancePre, s_burnMintERC20.balanceOf(STRANGER)); + } + + // Reverts + + function test_InvalidAddress_Reverts() public { + vm.expectRevert(); + + s_burnMintERC20.approve(address(s_burnMintERC20), s_amount); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burn.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burn.t.sol new file mode 100644 index 00000000000..5f6e7ee4d04 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burn.t.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IERC20} from "../../../../vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_burn is BurnMintERC20Setup { + function test_BasicBurn_Success() public { + s_burnMintERC20.grantBurnRole(OWNER); + deal(address(s_burnMintERC20), OWNER, s_amount); + + vm.expectEmit(); + emit IERC20.Transfer(OWNER, address(0), s_amount); + + s_burnMintERC20.burn(s_amount); + + assertEq(0, s_burnMintERC20.balanceOf(OWNER)); + } + + // Revert + + function test_SenderNotBurner_Reverts() public { + vm.expectRevert(abi.encodeWithSelector(FactoryBurnMintERC20.SenderNotBurner.selector, OWNER)); + + s_burnMintERC20.burnFrom(STRANGER, s_amount); + } + + function test_ExceedsBalance_Reverts() public { + changePrank(s_mockPool); + + vm.expectRevert("ERC20: burn amount exceeds balance"); + + s_burnMintERC20.burn(s_amount * 2); + } + + function test_BurnFromZeroAddress_Reverts() public { + s_burnMintERC20.grantBurnRole(address(0)); + changePrank(address(0)); + + vm.expectRevert("ERC20: burn from the zero address"); + + s_burnMintERC20.burn(0); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burnFrom.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burnFrom.t.sol new file mode 100644 index 00000000000..e2dcaf28563 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burnFrom.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_burnFrom is BurnMintERC20Setup { + function setUp() public virtual override { + BurnMintERC20Setup.setUp(); + } + + function test_BurnFrom_Success() public { + s_burnMintERC20.approve(s_mockPool, s_amount); + + changePrank(s_mockPool); + + s_burnMintERC20.burnFrom(OWNER, s_amount); + + assertEq(0, s_burnMintERC20.balanceOf(OWNER)); + } + + // Reverts + + function test_SenderNotBurner_Reverts() public { + vm.expectRevert(abi.encodeWithSelector(FactoryBurnMintERC20.SenderNotBurner.selector, OWNER)); + + s_burnMintERC20.burnFrom(OWNER, s_amount); + } + + function test_InsufficientAllowance_Reverts() public { + changePrank(s_mockPool); + + vm.expectRevert("ERC20: insufficient allowance"); + + s_burnMintERC20.burnFrom(OWNER, s_amount); + } + + function test_ExceedsBalance_Reverts() public { + s_burnMintERC20.approve(s_mockPool, s_amount * 2); + + changePrank(s_mockPool); + + vm.expectRevert("ERC20: burn amount exceeds balance"); + + s_burnMintERC20.burnFrom(OWNER, s_amount * 2); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burnFromAlias.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burnFromAlias.t.sol new file mode 100644 index 00000000000..0d46f1d54a5 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.burnFromAlias.t.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_burnFromAlias is BurnMintERC20Setup { + function setUp() public virtual override { + BurnMintERC20Setup.setUp(); + } + + function test_BurnFrom_Success() public { + s_burnMintERC20.approve(s_mockPool, s_amount); + + changePrank(s_mockPool); + + s_burnMintERC20.burn(OWNER, s_amount); + + assertEq(0, s_burnMintERC20.balanceOf(OWNER)); + } + + // Reverts + + function test_SenderNotBurner_Reverts() public { + vm.expectRevert(abi.encodeWithSelector(FactoryBurnMintERC20.SenderNotBurner.selector, OWNER)); + + s_burnMintERC20.burn(OWNER, s_amount); + } + + function test_InsufficientAllowance_Reverts() public { + changePrank(s_mockPool); + + vm.expectRevert("ERC20: insufficient allowance"); + + s_burnMintERC20.burn(OWNER, s_amount); + } + + function test_ExceedsBalance_Reverts() public { + s_burnMintERC20.approve(s_mockPool, s_amount * 2); + + changePrank(s_mockPool); + + vm.expectRevert("ERC20: burn amount exceeds balance"); + + s_burnMintERC20.burn(OWNER, s_amount * 2); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.constructor.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.constructor.t.sol new file mode 100644 index 00000000000..f1ee0866abe --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.constructor.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_constructor is BurnMintERC20Setup { + function test_Constructor_Success() public { + string memory name = "Chainlink token v2"; + string memory symbol = "LINK2"; + uint8 decimals = 19; + uint256 maxSupply = 1e33; + + s_burnMintERC20 = new FactoryBurnMintERC20(name, symbol, decimals, maxSupply, 1e18, s_alice); + + assertEq(name, s_burnMintERC20.name()); + assertEq(symbol, s_burnMintERC20.symbol()); + assertEq(decimals, s_burnMintERC20.decimals()); + assertEq(maxSupply, s_burnMintERC20.maxSupply()); + + assertTrue(s_burnMintERC20.isMinter(s_alice)); + assertTrue(s_burnMintERC20.isBurner(s_alice)); + assertEq(s_burnMintERC20.balanceOf(s_alice), 1e18); + assertEq(s_burnMintERC20.totalSupply(), 1e18); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.decreaseApproval.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.decreaseApproval.t.sol new file mode 100644 index 00000000000..aa621a998ed --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.decreaseApproval.t.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_decreaseApproval is BurnMintERC20Setup { + function test_DecreaseApproval_Success() public { + s_burnMintERC20.approve(s_mockPool, s_amount); + uint256 allowance = s_burnMintERC20.allowance(OWNER, s_mockPool); + assertEq(allowance, s_amount); + s_burnMintERC20.decreaseApproval(s_mockPool, s_amount); + assertEq(s_burnMintERC20.allowance(OWNER, s_mockPool), allowance - s_amount); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.getCCIPAdmin.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.getCCIPAdmin.t.sol new file mode 100644 index 00000000000..fc6a81a712b --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.getCCIPAdmin.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_getCCIPAdmin is BurnMintERC20Setup { + function test_getCCIPAdmin_Success() public view { + assertEq(s_alice, s_burnMintERC20.getCCIPAdmin()); + } + + function test_setCCIPAdmin_Success() public { + address newAdmin = makeAddr("newAdmin"); + + vm.expectEmit(); + emit FactoryBurnMintERC20.CCIPAdminTransferred(s_alice, newAdmin); + + s_burnMintERC20.setCCIPAdmin(newAdmin); + + assertEq(newAdmin, s_burnMintERC20.getCCIPAdmin()); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.grantMintAndBurnRoles.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.grantMintAndBurnRoles.t.sol new file mode 100644 index 00000000000..aaa967edc15 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.grantMintAndBurnRoles.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_grantMintAndBurnRoles is BurnMintERC20Setup { + function test_GrantMintAndBurnRoles_Success() public { + assertFalse(s_burnMintERC20.isMinter(STRANGER)); + assertFalse(s_burnMintERC20.isBurner(STRANGER)); + + vm.expectEmit(); + emit FactoryBurnMintERC20.MintAccessGranted(STRANGER); + vm.expectEmit(); + emit FactoryBurnMintERC20.BurnAccessGranted(STRANGER); + + s_burnMintERC20.grantMintAndBurnRoles(STRANGER); + + assertTrue(s_burnMintERC20.isMinter(STRANGER)); + assertTrue(s_burnMintERC20.isBurner(STRANGER)); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.grantRole.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.grantRole.t.sol new file mode 100644 index 00000000000..a06b52ac338 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.grantRole.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_grantRole is BurnMintERC20Setup { + function test_GrantMintAccess_Success() public { + assertFalse(s_burnMintERC20.isMinter(STRANGER)); + + vm.expectEmit(); + emit FactoryBurnMintERC20.MintAccessGranted(STRANGER); + + s_burnMintERC20.grantMintAndBurnRoles(STRANGER); + + assertTrue(s_burnMintERC20.isMinter(STRANGER)); + + vm.expectEmit(); + emit FactoryBurnMintERC20.MintAccessRevoked(STRANGER); + + s_burnMintERC20.revokeMintRole(STRANGER); + + assertFalse(s_burnMintERC20.isMinter(STRANGER)); + } + + function test_GrantBurnAccess_Success() public { + assertFalse(s_burnMintERC20.isBurner(STRANGER)); + + vm.expectEmit(); + emit FactoryBurnMintERC20.BurnAccessGranted(STRANGER); + + s_burnMintERC20.grantBurnRole(STRANGER); + + assertTrue(s_burnMintERC20.isBurner(STRANGER)); + + vm.expectEmit(); + emit FactoryBurnMintERC20.BurnAccessRevoked(STRANGER); + + s_burnMintERC20.revokeBurnRole(STRANGER); + + assertFalse(s_burnMintERC20.isBurner(STRANGER)); + } + + function test_GrantMany_Success() public { + // Since alice was already granted mint and burn roles in the setup, we will revoke them + // and then grant them again for the purposes of the test + s_burnMintERC20.revokeMintRole(s_alice); + s_burnMintERC20.revokeBurnRole(s_alice); + + uint256 numberOfPools = 10; + address[] memory permissionedAddresses = new address[](numberOfPools + 1); + permissionedAddresses[0] = s_mockPool; + + for (uint160 i = 0; i < numberOfPools; ++i) { + permissionedAddresses[i + 1] = address(i); + s_burnMintERC20.grantMintAndBurnRoles(address(i)); + } + + assertEq(permissionedAddresses, s_burnMintERC20.getBurners()); + assertEq(permissionedAddresses, s_burnMintERC20.getMinters()); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.increaseApproval.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.increaseApproval.t.sol new file mode 100644 index 00000000000..e93cc2a71e6 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.increaseApproval.t.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_increaseApproval is BurnMintERC20Setup { + function test_IncreaseApproval_Success() public { + s_burnMintERC20.approve(s_mockPool, s_amount); + uint256 allowance = s_burnMintERC20.allowance(OWNER, s_mockPool); + assertEq(allowance, s_amount); + s_burnMintERC20.increaseApproval(s_mockPool, s_amount); + assertEq(s_burnMintERC20.allowance(OWNER, s_mockPool), allowance + s_amount); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.mint.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.mint.t.sol new file mode 100644 index 00000000000..b22783a3c75 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.mint.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; + +import {IERC20} from "../../../../vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_mint is BurnMintERC20Setup { + function test_BasicMint_Success() public { + uint256 balancePre = s_burnMintERC20.balanceOf(OWNER); + + s_burnMintERC20.grantMintAndBurnRoles(OWNER); + + vm.expectEmit(); + emit IERC20.Transfer(address(0), OWNER, s_amount); + + s_burnMintERC20.mint(OWNER, s_amount); + + assertEq(balancePre + s_amount, s_burnMintERC20.balanceOf(OWNER)); + } + + // Revert + + function test_SenderNotMinter_Reverts() public { + vm.expectRevert(abi.encodeWithSelector(FactoryBurnMintERC20.SenderNotMinter.selector, OWNER)); + s_burnMintERC20.mint(STRANGER, 1e18); + } + + function test_MaxSupplyExceeded_Reverts() public { + changePrank(s_mockPool); + + // Mint max supply + s_burnMintERC20.mint(OWNER, s_burnMintERC20.maxSupply()); + + vm.expectRevert( + abi.encodeWithSelector(FactoryBurnMintERC20.MaxSupplyExceeded.selector, s_burnMintERC20.maxSupply() + 1) + ); + + // Attempt to mint 1 more than max supply + s_burnMintERC20.mint(OWNER, 1); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.supportsInterface.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.supportsInterface.t.sol new file mode 100644 index 00000000000..bdf3c3e7ae3 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.supportsInterface.t.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IBurnMintERC20} from "../../../../shared/token/ERC20/IBurnMintERC20.sol"; +import {IERC20} from "../../../../vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; +import {IERC165} from "../../../../vendor/openzeppelin-solidity/v4.8.3/contracts/utils/introspection/IERC165.sol"; +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_supportsInterface is BurnMintERC20Setup { + function test_SupportsInterface_Success() public view { + assertTrue(s_burnMintERC20.supportsInterface(type(IERC20).interfaceId)); + assertTrue(s_burnMintERC20.supportsInterface(type(IBurnMintERC20).interfaceId)); + assertTrue(s_burnMintERC20.supportsInterface(type(IERC165).interfaceId)); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.transfer.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.transfer.t.sol new file mode 100644 index 00000000000..333d50d333e --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/FactoryBurnMintERC20/FactoryBurnMintERC20.transfer.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {BurnMintERC20Setup} from "./BurnMintERC20Setup.t.sol"; + +contract FactoryBurnMintERC20_transfer is BurnMintERC20Setup { + function test_Transfer_Success() public { + uint256 balancePre = s_burnMintERC20.balanceOf(STRANGER); + uint256 sendingAmount = s_amount / 2; + + s_burnMintERC20.transfer(STRANGER, sendingAmount); + + assertEq(sendingAmount + balancePre, s_burnMintERC20.balanceOf(STRANGER)); + } + + // Reverts + + function test_InvalidAddress_Reverts() public { + vm.expectRevert(); + + s_burnMintERC20.transfer(address(s_burnMintERC20), s_amount); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenAdminRegistry/TokenAdminRegistrySetup.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenAdminRegistry/TokenAdminRegistrySetup.t.sol new file mode 100644 index 00000000000..b3ca4a50535 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenAdminRegistry/TokenAdminRegistrySetup.t.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {TokenSetup} from "../../TokenSetup.t.sol"; + +contract TokenAdminRegistrySetup is TokenSetup { + address internal s_registryModule = makeAddr("registryModule"); + + function setUp() public virtual override { + TokenSetup.setUp(); + + s_tokenAdminRegistry.addRegistryModule(s_registryModule); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.constructor.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.constructor.t.sol new file mode 100644 index 00000000000..fbba0ccbb06 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.constructor.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {ITokenAdminRegistry} from "../../../interfaces/ITokenAdminRegistry.sol"; + +import {RegistryModuleOwnerCustom} from "../../../tokenAdminRegistry/RegistryModuleOwnerCustom.sol"; +import {TokenPoolFactory} from "../../../tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.sol"; + +import {Create2} from "../../../../vendor/openzeppelin-solidity/v5.0.2/contracts/utils/Create2.sol"; +import {TokenPoolFactorySetup} from "./TokenPoolFactorySetup.t.sol"; + +contract TokenPoolFactory_constructor is TokenPoolFactorySetup { + using Create2 for bytes32; + + function test_constructor_Revert() public { + // Revert cause the tokenAdminRegistry is address(0) + vm.expectRevert(TokenPoolFactory.InvalidZeroAddress.selector); + new TokenPoolFactory(ITokenAdminRegistry(address(0)), RegistryModuleOwnerCustom(address(0)), address(0), address(0)); + + new TokenPoolFactory( + ITokenAdminRegistry(address(0xdeadbeef)), + RegistryModuleOwnerCustom(address(0xdeadbeef)), + address(0xdeadbeef), + address(0xdeadbeef) + ); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.createTokenPool.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.createTokenPool.t.sol new file mode 100644 index 00000000000..301e26173bf --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.createTokenPool.t.sol @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {IBurnMintERC20} from "../../../../shared/token/ERC20/IBurnMintERC20.sol"; +import {IOwner} from "../../../interfaces/IOwner.sol"; + +import {Ownable2Step} from "../../../../shared/access/Ownable2Step.sol"; + +import {Router} from "../../../Router.sol"; +import {RateLimiter} from "../../../libraries/RateLimiter.sol"; +import {BurnFromMintTokenPool} from "../../../pools/BurnFromMintTokenPool.sol"; +import {BurnMintTokenPool} from "../../../pools/BurnMintTokenPool.sol"; +import {LockReleaseTokenPool} from "../../../pools/LockReleaseTokenPool.sol"; +import {TokenPool} from "../../../pools/TokenPool.sol"; +import {RegistryModuleOwnerCustom} from "../../../tokenAdminRegistry/RegistryModuleOwnerCustom.sol"; +import {TokenAdminRegistry} from "../../../tokenAdminRegistry/TokenAdminRegistry.sol"; +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {TokenPoolFactory} from "../../../tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.sol"; +import {TokenPoolFactorySetup} from "./TokenPoolFactorySetup.t.sol"; + +import {IERC20Metadata} from + "../../../../vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {Create2} from "../../../../vendor/openzeppelin-solidity/v5.0.2/contracts/utils/Create2.sol"; + +contract TokenPoolFactory_createTokenPool is TokenPoolFactorySetup { + using Create2 for bytes32; + + uint8 private constant LOCAL_TOKEN_DECIMALS = 18; + uint8 private constant REMOTE_TOKEN_DECIMALS = 6; + + address internal s_burnMintOffRamp = makeAddr("burn_mint_offRamp"); + + function setUp() public override { + TokenPoolFactorySetup.setUp(); + + Router.OffRamp[] memory offRampUpdates = new Router.OffRamp[](1); + offRampUpdates[0] = Router.OffRamp({sourceChainSelector: DEST_CHAIN_SELECTOR, offRamp: s_burnMintOffRamp}); + s_sourceRouter.applyRampUpdates(new Router.OnRamp[](0), new Router.OffRamp[](0), offRampUpdates); + } + + function test_createTokenPool_WithNoExistingTokenOnRemoteChain_Success() public { + vm.startPrank(OWNER); + + bytes32 dynamicSalt = keccak256(abi.encodePacked(FAKE_SALT, OWNER)); + + address predictedTokenAddress = + Create2.computeAddress(dynamicSalt, keccak256(s_tokenInitCode), address(s_tokenPoolFactory)); + + // Create the constructor params for the predicted pool + bytes memory poolCreationParams = + abi.encode(predictedTokenAddress, LOCAL_TOKEN_DECIMALS, new address[](0), s_rmnProxy, s_sourceRouter); + + // Predict the address of the pool before we make the tx by using the init code and the params + bytes memory predictedPoolInitCode = abi.encodePacked(s_poolInitCode, poolCreationParams); + + address predictedPoolAddress = + dynamicSalt.computeAddress(keccak256(predictedPoolInitCode), address(s_tokenPoolFactory)); + + (address tokenAddress, address poolAddress) = s_tokenPoolFactory.deployTokenAndTokenPool( + new TokenPoolFactory.RemoteTokenPoolInfo[](0), LOCAL_TOKEN_DECIMALS, s_tokenInitCode, s_poolInitCode, FAKE_SALT + ); + + assertNotEq(address(0), tokenAddress, "Token Address should not be 0"); + assertNotEq(address(0), poolAddress, "Pool Address should not be 0"); + + assertEq(predictedTokenAddress, tokenAddress, "Token Address should have been predicted"); + assertEq(predictedPoolAddress, poolAddress, "Pool Address should have been predicted"); + + s_tokenAdminRegistry.acceptAdminRole(tokenAddress); + Ownable2Step(tokenAddress).acceptOwnership(); + Ownable2Step(poolAddress).acceptOwnership(); + + assertEq(poolAddress, s_tokenAdminRegistry.getPool(tokenAddress), "Token Pool should be set"); + assertEq(IOwner(tokenAddress).owner(), OWNER, "Token should be owned by the owner"); + assertEq(IOwner(poolAddress).owner(), OWNER, "Token should be owned by the owner"); + } + + function test_createTokenPool_WithNoExistingRemoteContracts_predict_Success() public { + vm.startPrank(OWNER); + bytes32 dynamicSalt = keccak256(abi.encodePacked(FAKE_SALT, OWNER)); + + // We have to create a new factory, registry module, and token admin registry to simulate the other chain + TokenAdminRegistry newTokenAdminRegistry = new TokenAdminRegistry(); + RegistryModuleOwnerCustom newRegistryModule = new RegistryModuleOwnerCustom(address(newTokenAdminRegistry)); + + // We want to deploy a new factory and Owner Module. + TokenPoolFactory newTokenPoolFactory = + new TokenPoolFactory(newTokenAdminRegistry, newRegistryModule, s_rmnProxy, address(s_destRouter)); + + newTokenAdminRegistry.addRegistryModule(address(newRegistryModule)); + + TokenPoolFactory.RemoteChainConfig memory remoteChainConfig = TokenPoolFactory.RemoteChainConfig( + address(newTokenPoolFactory), address(s_destRouter), address(s_rmnProxy), LOCAL_TOKEN_DECIMALS + ); + + // Create an array of remote pools where nothing exists yet, but we want to predict the address for + // the new pool and token on DEST_CHAIN_SELECTOR + TokenPoolFactory.RemoteTokenPoolInfo[] memory remoteTokenPools = new TokenPoolFactory.RemoteTokenPoolInfo[](1); + + // The only field that matters is DEST_CHAIN_SELECTOR because we dont want any existing token pool or token + // on the remote chain + remoteTokenPools[0] = TokenPoolFactory.RemoteTokenPoolInfo( + DEST_CHAIN_SELECTOR, // remoteChainSelector + "", // remotePoolAddress + type(BurnMintTokenPool).creationCode, // remotePoolInitCode + remoteChainConfig, // remoteChainConfig + TokenPoolFactory.PoolType.BURN_MINT, // poolType + "", // remoteTokenAddress + s_tokenInitCode, // remoteTokenInitCode + RateLimiter.Config(false, 0, 0) + ); + + // Predict the address of the token and pool on the DESTINATION chain + address predictedTokenAddress = dynamicSalt.computeAddress(keccak256(s_tokenInitCode), address(newTokenPoolFactory)); + + // Since the remote chain information was provided, we should be able to get the information from the newly + // deployed token pool using the available getter functions + (address tokenAddress, address poolAddress) = s_tokenPoolFactory.deployTokenAndTokenPool( + remoteTokenPools, // No existing remote pools + LOCAL_TOKEN_DECIMALS, // 18 decimal token + s_tokenInitCode, // Token Init Code + s_poolInitCode, // Pool Init Code + FAKE_SALT // Salt + ); + + // Ensure that the remote Token was set to the one we predicted + assertEq( + abi.encode(predictedTokenAddress), + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + "Token Address should have been predicted" + ); + + { + // Create the constructor params for the predicted pool + // The predictedTokenAddress is NOT abi-encoded since the raw evm-address + // is used in the constructor params + bytes memory predictedPoolCreationParams = + abi.encode(predictedTokenAddress, LOCAL_TOKEN_DECIMALS, new address[](0), s_rmnProxy, address(s_destRouter)); + + // Take the init code and concat the destination params to it, the initCode shouldn't change + bytes memory predictedPoolInitCode = abi.encodePacked(s_poolInitCode, predictedPoolCreationParams); + + // Predict the address of the pool on the DESTINATION chain + address predictedPoolAddress = + dynamicSalt.computeAddress(keccak256(predictedPoolInitCode), address(newTokenPoolFactory)); + + // Assert that the address set for the remote pool is the same as the predicted address + assertEq( + abi.encode(predictedPoolAddress), + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + "Pool Address should have been predicted" + ); + } + + // On the new token pool factory, representing a destination chain, + // deploy a new token and a new pool + (address newTokenAddress, address newPoolAddress) = newTokenPoolFactory.deployTokenAndTokenPool( + new TokenPoolFactory.RemoteTokenPoolInfo[](0), LOCAL_TOKEN_DECIMALS, s_tokenInitCode, s_poolInitCode, FAKE_SALT + ); + + assertEq( + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + abi.encode(newPoolAddress), + "New Pool Address should have been deployed correctly" + ); + + assertEq( + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + abi.encode(newTokenAddress), + "New Token Address should have been deployed correctly" + ); + + // Check that the token pool has the correct permissions + vm.startPrank(poolAddress); + IBurnMintERC20(tokenAddress).mint(poolAddress, 1e18); + + assertEq(1e18, IBurnMintERC20(tokenAddress).balanceOf(poolAddress), "Balance should be 1e18"); + + IBurnMintERC20(tokenAddress).burn(1e18); + assertEq(0, IBurnMintERC20(tokenAddress).balanceOf(poolAddress), "Balance should be 0"); + + vm.stopPrank(); + + assertEq(s_tokenAdminRegistry.getPool(tokenAddress), poolAddress, "Token Pool should be set"); + + // Check the token admin registry for config + TokenAdminRegistry.TokenConfig memory tokenConfig = s_tokenAdminRegistry.getTokenConfig(tokenAddress); + assertEq(tokenConfig.administrator, address(s_tokenPoolFactory), "Administrator should be set"); + assertEq(tokenConfig.pendingAdministrator, OWNER, "Pending Administrator should be 0"); + assertEq(tokenConfig.tokenPool, poolAddress, "Pool Address should be set"); + + // Accept Ownership of the token, pool, and adminRegistry + vm.startPrank(OWNER); + s_tokenAdminRegistry.acceptAdminRole(tokenAddress); + assertEq(s_tokenAdminRegistry.getTokenConfig(tokenAddress).administrator, OWNER, "Administrator should be set"); + assertEq( + s_tokenAdminRegistry.getTokenConfig(tokenAddress).pendingAdministrator, address(0), "Administrator should be set" + ); + + Ownable2Step(tokenAddress).acceptOwnership(); + Ownable2Step(poolAddress).acceptOwnership(); + + assertEq(IOwner(tokenAddress).owner(), OWNER, "Token should be controlled by the OWNER"); + assertEq(IOwner(poolAddress).owner(), OWNER, "Pool should be controlled by the OWNER"); + } + + function test_createTokenPool_ExistingRemoteToken_AndPredictPool_Success() public { + vm.startPrank(OWNER); + bytes32 dynamicSalt = keccak256(abi.encodePacked(FAKE_SALT, OWNER)); + + FactoryBurnMintERC20 newRemoteToken = + new FactoryBurnMintERC20("TestToken", "TT", 18, type(uint256).max, PREMINT_AMOUNT, OWNER); + + // We have to create a new factory, registry module, and token admin registry to simulate the other chain + TokenAdminRegistry newTokenAdminRegistry = new TokenAdminRegistry(); + RegistryModuleOwnerCustom newRegistryModule = new RegistryModuleOwnerCustom(address(newTokenAdminRegistry)); + + // We want to deploy a new factory and Owner Module. + TokenPoolFactory newTokenPoolFactory = + new TokenPoolFactory(newTokenAdminRegistry, newRegistryModule, s_rmnProxy, address(s_destRouter)); + + newTokenAdminRegistry.addRegistryModule(address(newRegistryModule)); + + TokenPoolFactory.RemoteChainConfig memory remoteChainConfig = TokenPoolFactory.RemoteChainConfig( + address(newTokenPoolFactory), address(s_destRouter), address(s_rmnProxy), LOCAL_TOKEN_DECIMALS + ); + + // Create an array of remote pools where nothing exists yet, but we want to predict the address for + // the new pool and token on DEST_CHAIN_SELECTOR + TokenPoolFactory.RemoteTokenPoolInfo[] memory remoteTokenPools = new TokenPoolFactory.RemoteTokenPoolInfo[](1); + + // The only field that matters is DEST_CHAIN_SELECTOR because we dont want any existing token pool or token + // on the remote chain + remoteTokenPools[0] = TokenPoolFactory.RemoteTokenPoolInfo( + DEST_CHAIN_SELECTOR, // remoteChainSelector + "", // remotePoolAddress + type(BurnMintTokenPool).creationCode, // remotePoolInitCode + remoteChainConfig, // remoteChainConfig + TokenPoolFactory.PoolType.BURN_MINT, // poolType + abi.encode(address(newRemoteToken)), // remoteTokenAddress + s_tokenInitCode, // remoteTokenInitCode + RateLimiter.Config(false, 0, 0) // rateLimiterConfig + ); + + // Since the remote chain information was provided, we should be able to get the information from the newly + // deployed token pool using the available getter functions + (address tokenAddress, address poolAddress) = s_tokenPoolFactory.deployTokenAndTokenPool( + remoteTokenPools, LOCAL_TOKEN_DECIMALS, s_tokenInitCode, s_poolInitCode, FAKE_SALT + ); + + assertEq(address(TokenPool(poolAddress).getToken()), tokenAddress, "Token Address should have been set locally"); + + // Ensure that the remote Token was set to the one we predicted + assertEq( + abi.encode(address(newRemoteToken)), + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + "Token Address should have been predicted" + ); + + // Create the constructor params for the predicted pool + // The predictedTokenAddress is NOT abi-encoded since the raw evm-address + // is used in the constructor params + bytes memory predictedPoolCreationParams = + abi.encode(address(newRemoteToken), LOCAL_TOKEN_DECIMALS, new address[](0), s_rmnProxy, address(s_destRouter)); + + // Take the init code and concat the destination params to it, the initCode shouldn't change + bytes memory predictedPoolInitCode = abi.encodePacked(s_poolInitCode, predictedPoolCreationParams); + + // Predict the address of the pool on the DESTINATION chain + address predictedPoolAddress = + dynamicSalt.computeAddress(keccak256(predictedPoolInitCode), address(newTokenPoolFactory)); + + // Assert that the address set for the remote pool is the same as the predicted address + assertEq( + abi.encode(predictedPoolAddress), + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + "Pool Address should have been predicted" + ); + + // On the new token pool factory, representing a destination chain, + // deploy a new token and a new pool + address newPoolAddress = newTokenPoolFactory.deployTokenPoolWithExistingToken( + address(newRemoteToken), + LOCAL_TOKEN_DECIMALS, + new TokenPoolFactory.RemoteTokenPoolInfo[](0), + s_poolInitCode, + FAKE_SALT, + TokenPoolFactory.PoolType.BURN_MINT + ); + + assertEq( + abi.encode(newRemoteToken), + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + "Remote Token Address should have been set correctly" + ); + + assertEq( + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + abi.encode(newPoolAddress), + "New Pool Address should have been deployed correctly" + ); + } + + function test_createTokenPool_WithRemoteTokenAndRemotePool_Success() public { + vm.startPrank(OWNER); + + bytes memory RANDOM_TOKEN_ADDRESS = abi.encode(makeAddr("RANDOM_TOKEN")); + bytes memory RANDOM_POOL_ADDRESS = abi.encode(makeAddr("RANDOM_POOL")); + + // Create an array of remote pools with some fake addresses + TokenPoolFactory.RemoteTokenPoolInfo[] memory remoteTokenPools = new TokenPoolFactory.RemoteTokenPoolInfo[](1); + + remoteTokenPools[0] = TokenPoolFactory.RemoteTokenPoolInfo( + DEST_CHAIN_SELECTOR, // remoteChainSelector + RANDOM_POOL_ADDRESS, // remotePoolAddress + type(BurnMintTokenPool).creationCode, // remotePoolInitCode + TokenPoolFactory.RemoteChainConfig(address(0), address(0), address(0), 0), // remoteChainConfig + TokenPoolFactory.PoolType.BURN_MINT, // poolType + RANDOM_TOKEN_ADDRESS, // remoteTokenAddress + "", // remoteTokenInitCode + RateLimiter.Config(false, 0, 0) // rateLimiterConfig + ); + + (address tokenAddress, address poolAddress) = s_tokenPoolFactory.deployTokenAndTokenPool( + remoteTokenPools, LOCAL_TOKEN_DECIMALS, s_tokenInitCode, s_poolInitCode, FAKE_SALT + ); + + assertNotEq(address(0), tokenAddress, "Token Address should not be 0"); + assertNotEq(address(0), poolAddress, "Pool Address should not be 0"); + + s_tokenAdminRegistry.acceptAdminRole(tokenAddress); + Ownable2Step(tokenAddress).acceptOwnership(); + Ownable2Step(poolAddress).acceptOwnership(); + + assertEq( + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + RANDOM_TOKEN_ADDRESS, + "Remote Token Address should have been set" + ); + + assertEq( + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + RANDOM_POOL_ADDRESS, + "Remote Pool Address should have been set" + ); + + assertEq(poolAddress, s_tokenAdminRegistry.getPool(tokenAddress), "Token Pool should be set"); + + assertEq(IOwner(tokenAddress).owner(), OWNER, "Token should be owned by the owner"); + + assertEq(IOwner(poolAddress).owner(), OWNER, "Token should be owned by the owner"); + } + + function test_createTokenPoolLockRelease_ExistingToken_predict_Success() public { + vm.startPrank(OWNER); + + // We have to create a new factory, registry module, and token admin registry to simulate the other chain + TokenAdminRegistry newTokenAdminRegistry = new TokenAdminRegistry(); + RegistryModuleOwnerCustom newRegistryModule = new RegistryModuleOwnerCustom(address(newTokenAdminRegistry)); + + // We want to deploy a new factory and Owner Module. + TokenPoolFactory newTokenPoolFactory = + new TokenPoolFactory(newTokenAdminRegistry, newRegistryModule, s_rmnProxy, address(s_destRouter)); + + newTokenAdminRegistry.addRegistryModule(address(newRegistryModule)); + + TokenPoolFactory.RemoteChainConfig memory remoteChainConfig = TokenPoolFactory.RemoteChainConfig( + address(newTokenPoolFactory), address(s_destRouter), address(s_rmnProxy), LOCAL_TOKEN_DECIMALS + ); + + FactoryBurnMintERC20 newLocalToken = + new FactoryBurnMintERC20("TestToken", "TEST", 18, type(uint256).max, PREMINT_AMOUNT, OWNER); + + FactoryBurnMintERC20 newRemoteToken = + new FactoryBurnMintERC20("TestToken", "TEST", 18, type(uint256).max, PREMINT_AMOUNT, OWNER); + + // Create an array of remote pools where nothing exists yet, but we want to predict the address for + // the new pool and token on DEST_CHAIN_SELECTOR + TokenPoolFactory.RemoteTokenPoolInfo[] memory remoteTokenPools = new TokenPoolFactory.RemoteTokenPoolInfo[](1); + + // The only field that matters is DEST_CHAIN_SELECTOR because we dont want any existing token pool or token + // on the remote chain + remoteTokenPools[0] = TokenPoolFactory.RemoteTokenPoolInfo( + DEST_CHAIN_SELECTOR, // remoteChainSelector + "", // remotePoolAddress + type(LockReleaseTokenPool).creationCode, // remotePoolInitCode + remoteChainConfig, // remoteChainConfig + TokenPoolFactory.PoolType.LOCK_RELEASE, // poolType + abi.encode(address(newRemoteToken)), // remoteTokenAddress + s_tokenInitCode, // remoteTokenInitCode + RateLimiter.Config(false, 0, 0) + ); + + // Since the remote chain information was provided, we should be able to get the information from the newly + // deployed token pool using the available getter functions + address poolAddress = s_tokenPoolFactory.deployTokenPoolWithExistingToken( + address(newLocalToken), + LOCAL_TOKEN_DECIMALS, + remoteTokenPools, + type(LockReleaseTokenPool).creationCode, + FAKE_SALT, + TokenPoolFactory.PoolType.LOCK_RELEASE + ); + + // Check that the pool was correctly deployed on the local chain first + + // Accept the ownership which was transferred + Ownable2Step(poolAddress).acceptOwnership(); + + // Ensure that the remote Token was set to the one we predicted + assertEq( + address(LockReleaseTokenPool(poolAddress).getToken()), + address(newLocalToken), + "Token Address should have been set" + ); + + LockReleaseTokenPool(poolAddress).setRebalancer(OWNER); + assertEq(OWNER, LockReleaseTokenPool(poolAddress).getRebalancer(), "Rebalancer should be set"); + + // Deploy the Lock-Release Token Pool on the destination chain with the existing remote token + (address newPoolAddress) = newTokenPoolFactory.deployTokenPoolWithExistingToken( + address(newRemoteToken), + LOCAL_TOKEN_DECIMALS, + new TokenPoolFactory.RemoteTokenPoolInfo[](0), // No existing remote pools + type(LockReleaseTokenPool).creationCode, // Pool Init Code + FAKE_SALT, // Salt + TokenPoolFactory.PoolType.LOCK_RELEASE + ); + + assertEq( + LockReleaseTokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + abi.encode(newPoolAddress), + "New Pool Address should have been deployed correctly" + ); + + assertEq( + LockReleaseTokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + abi.encode(address(newRemoteToken)), + "New Token Address should have been deployed correctly" + ); + + assertEq( + address(LockReleaseTokenPool(newPoolAddress).getToken()), + address(newRemoteToken), + "New Remote Token should be set correctly" + ); + } + + function test_createTokenPool_BurnFromMintTokenPool_Success() public { + vm.startPrank(OWNER); + + bytes memory RANDOM_TOKEN_ADDRESS = abi.encode(makeAddr("RANDOM_TOKEN")); + bytes memory RANDOM_POOL_ADDRESS = abi.encode(makeAddr("RANDOM_POOL")); + + // Create an array of remote pools with some fake addresses + TokenPoolFactory.RemoteTokenPoolInfo[] memory remoteTokenPools = new TokenPoolFactory.RemoteTokenPoolInfo[](1); + + remoteTokenPools[0] = TokenPoolFactory.RemoteTokenPoolInfo( + DEST_CHAIN_SELECTOR, // remoteChainSelector + RANDOM_POOL_ADDRESS, // remotePoolAddress + type(BurnFromMintTokenPool).creationCode, // remotePoolInitCode + TokenPoolFactory.RemoteChainConfig(address(0), address(0), address(0), 0), // remoteChainConfig + TokenPoolFactory.PoolType.BURN_MINT, // poolType + RANDOM_TOKEN_ADDRESS, // remoteTokenAddress + "", // remoteTokenInitCode + RateLimiter.Config(false, 0, 0) // rateLimiterConfig + ); + + (address tokenAddress, address poolAddress) = s_tokenPoolFactory.deployTokenAndTokenPool( + remoteTokenPools, LOCAL_TOKEN_DECIMALS, s_tokenInitCode, s_poolInitCode, FAKE_SALT + ); + + assertNotEq(address(0), tokenAddress, "Token Address should not be 0"); + assertNotEq(address(0), poolAddress, "Pool Address should not be 0"); + + s_tokenAdminRegistry.acceptAdminRole(tokenAddress); + Ownable2Step(tokenAddress).acceptOwnership(); + Ownable2Step(poolAddress).acceptOwnership(); + + assertEq( + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + RANDOM_TOKEN_ADDRESS, + "Remote Token Address should have been set" + ); + + assertEq( + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + RANDOM_POOL_ADDRESS, + "Remote Pool Address should have been set" + ); + + assertEq(poolAddress, s_tokenAdminRegistry.getPool(tokenAddress), "Token Pool should be set"); + + assertEq(IOwner(tokenAddress).owner(), OWNER, "Token should be owned by the owner"); + + assertEq(IOwner(poolAddress).owner(), OWNER, "Token should be owned by the owner"); + } + + function test_createTokenPool_RemoteTokenHasDifferentDecimals_Success() public { + vm.startPrank(OWNER); + bytes32 dynamicSalt = keccak256(abi.encodePacked(FAKE_SALT, OWNER)); + + // Deploy the "remote" token which has a different decimal value than the local token + FactoryBurnMintERC20 newRemoteToken = + new FactoryBurnMintERC20("TestToken", "TT", 6, type(uint256).max, PREMINT_AMOUNT, OWNER); + + // We have to create a new factory, registry module, and token admin registry to simulate the other chain + TokenAdminRegistry newTokenAdminRegistry = new TokenAdminRegistry(); + RegistryModuleOwnerCustom newRegistryModule = new RegistryModuleOwnerCustom(address(newTokenAdminRegistry)); + + // We want to deploy a new factory and Owner Module. + TokenPoolFactory newTokenPoolFactory = + new TokenPoolFactory(newTokenAdminRegistry, newRegistryModule, s_rmnProxy, address(s_destRouter)); + + newTokenAdminRegistry.addRegistryModule(address(newRegistryModule)); + + TokenPoolFactory.RemoteChainConfig memory remoteChainConfig = TokenPoolFactory.RemoteChainConfig( + address(newTokenPoolFactory), address(s_destRouter), address(s_rmnProxy), REMOTE_TOKEN_DECIMALS + ); + + // Create an array of remote pools where nothing exists yet, but we want to predict the address for + // the new pool and token on DEST_CHAIN_SELECTOR + TokenPoolFactory.RemoteTokenPoolInfo[] memory remoteTokenPools = new TokenPoolFactory.RemoteTokenPoolInfo[](1); + + // The only field that matters is DEST_CHAIN_SELECTOR because we dont want any existing token pool or token + // on the remote chain + remoteTokenPools[0] = TokenPoolFactory.RemoteTokenPoolInfo( + DEST_CHAIN_SELECTOR, // remoteChainSelector + "", // remotePoolAddress + type(BurnMintTokenPool).creationCode, // remotePoolInitCode + remoteChainConfig, // remoteChainConfig + TokenPoolFactory.PoolType.BURN_MINT, // poolType + abi.encode(address(newRemoteToken)), // remoteTokenAddress + s_tokenInitCode, // remoteTokenInitCode + RateLimiter.Config(false, 0, 0) // rateLimiterConfig + ); + + // Since the remote chain information was provided, we should be able to get the information from the newly + // deployed token pool using the available getter functions + (address tokenAddress, address poolAddress) = s_tokenPoolFactory.deployTokenAndTokenPool( + remoteTokenPools, LOCAL_TOKEN_DECIMALS, s_tokenInitCode, s_poolInitCode, FAKE_SALT + ); + + assertEq(address(TokenPool(poolAddress).getToken()), tokenAddress, "Token Address should have been set locally"); + + // Ensure that the remote Token was set to the one we predicted + assertEq( + abi.encode(address(newRemoteToken)), + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + "Token Address should have been predicted" + ); + + // Create the constructor params for the predicted pool + // The predictedTokenAddress is NOT abi-encoded since the raw evm-address + // is used in the constructor params + bytes memory predictedPoolCreationParams = + abi.encode(address(newRemoteToken), REMOTE_TOKEN_DECIMALS, new address[](0), s_rmnProxy, address(s_destRouter)); + + // Take the init code and concat the destination params to it, the initCode shouldn't change + bytes memory predictedPoolInitCode = abi.encodePacked(s_poolInitCode, predictedPoolCreationParams); + + // Predict the address of the pool on the DESTINATION chain + address predictedPoolAddress = + dynamicSalt.computeAddress(keccak256(predictedPoolInitCode), address(newTokenPoolFactory)); + + // Assert that the address set for the remote pool is the same as the predicted address + assertEq( + abi.encode(predictedPoolAddress), + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + "Pool Address should have been predicted" + ); + + // On the new token pool factory, representing a destination chain, + // deploy a new token and a new pool + address newPoolAddress = newTokenPoolFactory.deployTokenPoolWithExistingToken( + address(newRemoteToken), + REMOTE_TOKEN_DECIMALS, + new TokenPoolFactory.RemoteTokenPoolInfo[](0), + s_poolInitCode, + FAKE_SALT, + TokenPoolFactory.PoolType.BURN_MINT + ); + + assertEq( + abi.encode(newRemoteToken), + TokenPool(poolAddress).getRemoteToken(DEST_CHAIN_SELECTOR), + "Remote Token Address should have been set correctly" + ); + + assertEq( + TokenPool(poolAddress).getRemotePools(DEST_CHAIN_SELECTOR)[0], + abi.encode(newPoolAddress), + "New Pool Address should have been deployed correctly" + ); + + assertEq(TokenPool(poolAddress).getTokenDecimals(), LOCAL_TOKEN_DECIMALS, "Local token pool should use 18 decimals"); + + // Assert the local token has 18 decimals + assertEq(IERC20Metadata(tokenAddress).decimals(), LOCAL_TOKEN_DECIMALS, "Token Decimals should be 18"); + + // Check configs on the remote pool and remote token decimals + assertEq(TokenPool(newPoolAddress).getTokenDecimals(), REMOTE_TOKEN_DECIMALS, "Token Decimals should be 6"); + assertEq(address(TokenPool(newPoolAddress).getToken()), address(newRemoteToken), "Token Address should be set"); + assertEq(IERC20Metadata(newRemoteToken).decimals(), REMOTE_TOKEN_DECIMALS, "Token Decimals should be 6"); + } +} diff --git a/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactorySetup.t.sol b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactorySetup.t.sol new file mode 100644 index 00000000000..9f78ceb9439 --- /dev/null +++ b/contracts/src/v0.8/ccip/test/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactorySetup.t.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {Create2} from "../../../../vendor/openzeppelin-solidity/v5.0.2/contracts/utils/Create2.sol"; +import {BurnMintTokenPool} from "../../../pools/BurnMintTokenPool.sol"; +import {RegistryModuleOwnerCustom} from "../../../tokenAdminRegistry/RegistryModuleOwnerCustom.sol"; +import {FactoryBurnMintERC20} from "../../../tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol"; +import {TokenPoolFactory} from "../../../tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.sol"; +import {TokenAdminRegistrySetup} from "../TokenAdminRegistry/TokenAdminRegistrySetup.t.sol"; + +contract TokenPoolFactorySetup is TokenAdminRegistrySetup { + using Create2 for bytes32; + + TokenPoolFactory internal s_tokenPoolFactory; + RegistryModuleOwnerCustom internal s_registryModuleOwnerCustom; + + bytes internal s_poolInitCode; + bytes internal s_poolInitArgs; + + bytes32 internal constant FAKE_SALT = keccak256(abi.encode("FAKE_SALT")); + + address internal s_rmnProxy = address(0x1234); + + bytes internal s_tokenCreationParams; + bytes internal s_tokenInitCode; + + uint256 public constant PREMINT_AMOUNT = 100 ether; + + function setUp() public virtual override { + TokenAdminRegistrySetup.setUp(); + + s_registryModuleOwnerCustom = new RegistryModuleOwnerCustom(address(s_tokenAdminRegistry)); + s_tokenAdminRegistry.addRegistryModule(address(s_registryModuleOwnerCustom)); + + s_tokenPoolFactory = + new TokenPoolFactory(s_tokenAdminRegistry, s_registryModuleOwnerCustom, s_rmnProxy, address(s_sourceRouter)); + + // Create Init Code for BurnMintERC20 TestToken with 18 decimals and supply cap of max uint256 value + s_tokenCreationParams = abi.encode("TestToken", "TT", 18, type(uint256).max, PREMINT_AMOUNT, OWNER); + + s_tokenInitCode = abi.encodePacked(type(FactoryBurnMintERC20).creationCode, s_tokenCreationParams); + + s_poolInitCode = type(BurnMintTokenPool).creationCode; + + // Create Init Args for BurnMintTokenPool with no allowlist minus the token address + address[] memory allowlist = new address[](1); + allowlist[0] = OWNER; + s_poolInitArgs = abi.encode(allowlist, address(0x1234), s_sourceRouter); + } +} diff --git a/contracts/src/v0.8/ccip/tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol b/contracts/src/v0.8/ccip/tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol new file mode 100644 index 00000000000..6fdba0f9b1f --- /dev/null +++ b/contracts/src/v0.8/ccip/tokenAdminRegistry/TokenPoolFactory/FactoryBurnMintERC20.sol @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IGetCCIPAdmin} from "../../../ccip/interfaces/IGetCCIPAdmin.sol"; +import {IOwnable} from "../../../shared/interfaces/IOwnable.sol"; +import {IBurnMintERC20} from "../../../shared/token/ERC20/IBurnMintERC20.sol"; + +import {Ownable2StepMsgSender} from "../../../shared/access/Ownable2StepMsgSender.sol"; + +import {ERC20} from "../../../vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "../../../vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; +import {ERC20Burnable} from + "../../../vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {IERC165} from "../../../vendor/openzeppelin-solidity/v4.8.3/contracts/utils/introspection/IERC165.sol"; +import {EnumerableSet} from "../../../vendor/openzeppelin-solidity/v4.8.3/contracts/utils/structs/EnumerableSet.sol"; + +/// @notice A basic ERC20 compatible token contract with burn and minting roles. +/// @dev The constructor has been modified to support the deployment pattern used by a factory contract. +/// @dev The total supply can be limited during deployment. +contract FactoryBurnMintERC20 is IBurnMintERC20, IGetCCIPAdmin, IERC165, ERC20Burnable, Ownable2StepMsgSender { + using EnumerableSet for EnumerableSet.AddressSet; + + error SenderNotMinter(address sender); + error SenderNotBurner(address sender); + error MaxSupplyExceeded(uint256 supplyAfterMint); + + event MintAccessGranted(address minter); + event BurnAccessGranted(address burner); + event MintAccessRevoked(address minter); + event BurnAccessRevoked(address burner); + event CCIPAdminTransferred(address indexed previousAdmin, address indexed newAdmin); + + /// @dev The number of decimals for the token + uint8 internal immutable i_decimals; + + /// @dev The maximum supply of the token, 0 if unlimited + uint256 internal immutable i_maxSupply; + + /// @dev the CCIPAdmin can be used to register with the CCIP token admin registry, but has no other special powers, + /// and can only be transferred by the owner. + address internal s_ccipAdmin; + + /// @dev the allowed minter addresses + EnumerableSet.AddressSet internal s_minters; + /// @dev the allowed burner addresses + EnumerableSet.AddressSet internal s_burners; + + /// @dev the underscores in parameter names are used to suppress compiler warnings about shadowing ERC20 functions + constructor( + string memory name, + string memory symbol, + uint8 decimals_, + uint256 maxSupply_, + uint256 preMint, + address newOwner + ) ERC20(name, symbol) { + i_decimals = decimals_; + i_maxSupply = maxSupply_; + + s_ccipAdmin = newOwner; + + // Mint the initial supply to the new Owner, saving gas by not calling if the mint amount is zero + if (preMint != 0) _mint(newOwner, preMint); + + // Grant the deployer the minter and burner roles. This contract is expected to be deployed by a factory + // contract that will transfer ownership to the correct address after deployment, so granting minting and burning + // privileges here saves gas by not requiring two transactions. + grantMintRole(newOwner); + grantBurnRole(newOwner); + } + + /// @inheritdoc IERC165 + function supportsInterface( + bytes4 interfaceId + ) public pure virtual override returns (bool) { + return interfaceId == type(IERC20).interfaceId || interfaceId == type(IBurnMintERC20).interfaceId + || interfaceId == type(IERC165).interfaceId || interfaceId == type(IOwnable).interfaceId + || interfaceId == type(IGetCCIPAdmin).interfaceId; + } + + // ================================================================ + // │ ERC20 │ + // ================================================================ + + /// @dev Returns the number of decimals used in its user representation. + function decimals() public view virtual override returns (uint8) { + return i_decimals; + } + + /// @dev Returns the max supply of the token, 0 if unlimited. + function maxSupply() public view virtual returns (uint256) { + return i_maxSupply; + } + + /// @dev Uses OZ ERC20 _transfer to disallow sending to address(0). + /// @dev Disallows sending to address(this) + function _transfer(address from, address to, uint256 amount) internal virtual override validAddress(to) { + super._transfer(from, to, amount); + } + + /// @dev Uses OZ ERC20 _approve to disallow approving for address(0). + /// @dev Disallows approving for address(this) + function _approve(address owner, address spender, uint256 amount) internal virtual override validAddress(spender) { + super._approve(owner, spender, amount); + } + + /// @dev Exists to be backwards compatible with the older naming convention. + /// @param spender the account being approved to spend on the users' behalf. + /// @param subtractedValue the amount being removed from the approval. + /// @return success Bool to return if the approval was successfully decreased. + function decreaseApproval(address spender, uint256 subtractedValue) external returns (bool success) { + return decreaseAllowance(spender, subtractedValue); + } + + /// @dev Exists to be backwards compatible with the older naming convention. + /// @param spender the account being approved to spend on the users' behalf. + /// @param addedValue the amount being added to the approval. + function increaseApproval(address spender, uint256 addedValue) external { + increaseAllowance(spender, addedValue); + } + + // ================================================================ + // │ Burning & minting │ + // ================================================================ + + /// @inheritdoc ERC20Burnable + /// @dev Uses OZ ERC20 _burn to disallow burning from address(0). + /// @dev Decreases the total supply. + function burn( + uint256 amount + ) public override(IBurnMintERC20, ERC20Burnable) onlyBurner { + super.burn(amount); + } + + /// @inheritdoc IBurnMintERC20 + /// @dev Alias for BurnFrom for compatibility with the older naming convention. + /// @dev Uses burnFrom for all validation & logic. + function burn(address account, uint256 amount) public virtual override { + burnFrom(account, amount); + } + + /// @inheritdoc ERC20Burnable + /// @dev Uses OZ ERC20 _burn to disallow burning from address(0). + /// @dev Decreases the total supply. + function burnFrom(address account, uint256 amount) public override(IBurnMintERC20, ERC20Burnable) onlyBurner { + super.burnFrom(account, amount); + } + + /// @inheritdoc IBurnMintERC20 + /// @dev Uses OZ ERC20 _mint to disallow minting to address(0). + /// @dev Disallows minting to address(this) + /// @dev Increases the total supply. + function mint(address account, uint256 amount) external override onlyMinter validAddress(account) { + if (i_maxSupply != 0 && totalSupply() + amount > i_maxSupply) revert MaxSupplyExceeded(totalSupply() + amount); + + _mint(account, amount); + } + + // ================================================================ + // │ Roles │ + // ================================================================ + + /// @notice grants both mint and burn roles to `burnAndMinter`. + /// @dev calls public functions so this function does not require + /// access controls. This is handled in the inner functions. + function grantMintAndBurnRoles( + address burnAndMinter + ) external { + grantMintRole(burnAndMinter); + grantBurnRole(burnAndMinter); + } + + /// @notice Grants mint role to the given address. + /// @dev only the owner can call this function. + function grantMintRole( + address minter + ) public onlyOwner { + if (s_minters.add(minter)) { + emit MintAccessGranted(minter); + } + } + + /// @notice Grants burn role to the given address. + /// @dev only the owner can call this function. + /// @param burner the address to grant the burner role to + function grantBurnRole( + address burner + ) public onlyOwner { + if (s_burners.add(burner)) { + emit BurnAccessGranted(burner); + } + } + + /// @notice Revokes mint role for the given address. + /// @dev only the owner can call this function. + /// @param minter the address to revoke the mint role from. + function revokeMintRole( + address minter + ) external onlyOwner { + if (s_minters.remove(minter)) { + emit MintAccessRevoked(minter); + } + } + + /// @notice Revokes burn role from the given address. + /// @dev only the owner can call this function + /// @param burner the address to revoke the burner role from + function revokeBurnRole( + address burner + ) external onlyOwner { + if (s_burners.remove(burner)) { + emit BurnAccessRevoked(burner); + } + } + + /// @notice Returns all permissioned minters + function getMinters() external view returns (address[] memory) { + return s_minters.values(); + } + + /// @notice Returns all permissioned burners + function getBurners() external view returns (address[] memory) { + return s_burners.values(); + } + + /// @notice Returns the current CCIPAdmin + function getCCIPAdmin() external view returns (address) { + return s_ccipAdmin; + } + + /// @notice Transfers the CCIPAdmin role to a new address + /// @dev only the owner can call this function, NOT the current ccipAdmin, and 1-step ownership transfer is used. + /// @param newAdmin The address to transfer the CCIPAdmin role to. Setting to address(0) is a valid way to revoke + /// the role + function setCCIPAdmin( + address newAdmin + ) public onlyOwner { + address currentAdmin = s_ccipAdmin; + + s_ccipAdmin = newAdmin; + + emit CCIPAdminTransferred(currentAdmin, newAdmin); + } + + // ================================================================ + // │ Access │ + // ================================================================ + + /// @notice Checks whether a given address is a minter for this token. + /// @return true if the address is allowed to mint. + function isMinter( + address minter + ) public view returns (bool) { + return s_minters.contains(minter); + } + + /// @notice Checks whether a given address is a burner for this token. + /// @return true if the address is allowed to burn. + function isBurner( + address burner + ) public view returns (bool) { + return s_burners.contains(burner); + } + + /// @notice Checks whether the msg.sender is a permissioned minter for this token + /// @dev Reverts with a SenderNotMinter if the check fails + modifier onlyMinter() { + if (!isMinter(msg.sender)) revert SenderNotMinter(msg.sender); + _; + } + + /// @notice Checks whether the msg.sender is a permissioned burner for this token + /// @dev Reverts with a SenderNotBurner if the check fails + modifier onlyBurner() { + if (!isBurner(msg.sender)) revert SenderNotBurner(msg.sender); + _; + } + + /// @notice Check if recipient is valid (not this contract address). + /// @param recipient the account we transfer/approve to. + /// @dev Reverts with an empty revert to be compatible with the existing link token when + /// the recipient is this contract address. + modifier validAddress( + address recipient + ) virtual { + // solhint-disable-next-line reason-string, gas-custom-errors + if (recipient == address(this)) revert(); + _; + } +} diff --git a/contracts/src/v0.8/ccip/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.sol b/contracts/src/v0.8/ccip/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.sol new file mode 100644 index 00000000000..94beff704a4 --- /dev/null +++ b/contracts/src/v0.8/ccip/tokenAdminRegistry/TokenPoolFactory/TokenPoolFactory.sol @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {IOwnable} from "../../../shared/interfaces/IOwnable.sol"; +import {ITypeAndVersion} from "../../../shared/interfaces/ITypeAndVersion.sol"; +import {ITokenAdminRegistry} from "../../interfaces/ITokenAdminRegistry.sol"; + +import {RateLimiter} from "../../libraries/RateLimiter.sol"; +import {TokenPool} from "../../pools/TokenPool.sol"; +import {RegistryModuleOwnerCustom} from "../RegistryModuleOwnerCustom.sol"; +import {FactoryBurnMintERC20} from "./FactoryBurnMintERC20.sol"; + +import {Create2} from "../../../vendor/openzeppelin-solidity/v5.0.2/contracts/utils/Create2.sol"; + +/// @notice A contract for deploying new tokens and token pools, and configuring them with the token admin registry +/// @dev At the end of the transaction, the ownership transfer process will begin, but the user must accept the +/// ownership transfer in a separate transaction. +/// @dev The address prediction mechanism is only capable of deploying and predicting addresses for EVM based chains. +/// adding compatibility for other chains will require additional offchain computation. +contract TokenPoolFactory is ITypeAndVersion { + using Create2 for bytes32; + + event RemoteChainConfigUpdated(uint64 indexed remoteChainSelector, RemoteChainConfig remoteChainConfig); + + error InvalidZeroAddress(); + + /// @notice The type of pool to deploy. Types may be expanded in future versions + enum PoolType { + BURN_MINT, + LOCK_RELEASE + } + + /// @dev This struct will only ever exist in memory and as calldata, and therefore does not need to be efficiently packed for storage. The struct is used to pass information to the create2 address generation function. + struct RemoteTokenPoolInfo { + uint64 remoteChainSelector; // The CCIP specific selector for the remote chain + bytes remotePoolAddress; // The address of the remote pool to either deploy or use as is. If empty, address + // will be predicted + bytes remotePoolInitCode; // Remote pool creation code if it needs to be deployed, without constructor params + // appended to the end. + RemoteChainConfig remoteChainConfig; // The addresses of the remote RMNProxy, Router, factory, and token + // decimals which are needed for determining the remote address + PoolType poolType; // The type of pool to deploy, either Burn/Mint or Lock/Release + bytes remoteTokenAddress; // EVM address for remote token. If empty, the address will be predicted + bytes remoteTokenInitCode; // The init code to be deployed on the remote chain and includes constructor params + RateLimiter.Config rateLimiterConfig; // Token Pool rate limit. Values will be applied on incoming an outgoing messages + } + + // solhint-disable-next-line gas-struct-packing + struct RemoteChainConfig { + address remotePoolFactory; // The factory contract on the remote chain which will make the deployment + address remoteRouter; // The router on the remote chain + address remoteRMNProxy; // The RMNProxy contract on the remote chain + uint8 remoteTokenDecimals; // The number of decimals for the token on the remote chain + } + + string public constant typeAndVersion = "TokenPoolFactory 1.5.1"; + + ITokenAdminRegistry private immutable i_tokenAdminRegistry; + RegistryModuleOwnerCustom private immutable i_registryModuleOwnerCustom; + + address private immutable i_rmnProxy; + address private immutable i_ccipRouter; + + /// @notice Construct the TokenPoolFactory + /// @param tokenAdminRegistry The address of the token admin registry + /// @param tokenAdminModule The address of the token admin module which can register the token via ownership module + /// @param rmnProxy The address of the RMNProxy contract token pools will be deployed with + /// @param ccipRouter The address of the CCIPRouter contract token pools will be deployed with + constructor( + ITokenAdminRegistry tokenAdminRegistry, + RegistryModuleOwnerCustom tokenAdminModule, + address rmnProxy, + address ccipRouter + ) { + if ( + address(tokenAdminRegistry) == address(0) || address(tokenAdminModule) == address(0) || rmnProxy == address(0) + || ccipRouter == address(0) + ) revert InvalidZeroAddress(); + + i_tokenAdminRegistry = ITokenAdminRegistry(tokenAdminRegistry); + i_registryModuleOwnerCustom = RegistryModuleOwnerCustom(tokenAdminModule); + i_rmnProxy = rmnProxy; + i_ccipRouter = ccipRouter; + } + + // ================================================================ + // │ Top-Level Deployment │ + // ================================================================ + + /// @notice Deploys a token and token pool with the given token information and configures it with remote token pools + /// @dev The token and token pool are deployed in the same transaction, and the token pool is configured with the + /// remote token pools. The token pool is then set in the token admin registry. Ownership of the everything is transferred + /// to the msg.sender, but must be accepted in a separate transaction due to 2-step ownership transfer. + /// @param remoteTokenPools An array of remote token pools info to be used in the pool's applyChainUpdates function + /// or to be predicted if the pool has not been deployed yet on the remote chain + /// @param localTokenDecimals The amount of decimals to be used in the new token. Since decimals() is not part of the + /// the ERC20 standard, and thus cannot be certain to exist, the amount must be supplied via user input. + /// @param tokenInitCode The creation code for the token, which includes the constructor parameters already appended + /// @param tokenPoolInitCode The creation code for the token pool, without the constructor parameters appended + /// @param salt The salt to be used in the create2 deployment of the token and token pool to ensure a unique address + /// @return token The address of the token that was deployed + /// @return pool The address of the token pool that was deployed + function deployTokenAndTokenPool( + RemoteTokenPoolInfo[] calldata remoteTokenPools, + uint8 localTokenDecimals, + bytes memory tokenInitCode, + bytes calldata tokenPoolInitCode, + bytes32 salt + ) external returns (address, address) { + // Ensure a unique deployment between senders even if the same input parameter is used to prevent + // DOS/front running attacks + salt = keccak256(abi.encodePacked(salt, msg.sender)); + + // Deploy the token. The constructor parameters are already provided in the tokenInitCode + address token = Create2.deploy(0, salt, tokenInitCode); + + // Deploy the token pool + address pool = + _createTokenPool(token, localTokenDecimals, remoteTokenPools, tokenPoolInitCode, salt, PoolType.BURN_MINT); + + // Grant the mint and burn roles to the pool for the token + FactoryBurnMintERC20(token).grantMintAndBurnRoles(pool); + + // Set the token pool for token in the token admin registry since this contract is the token and pool owner + _setTokenPoolInTokenAdminRegistry(token, pool); + + // Begin the 2 step ownership transfer of the newly deployed token to the msg.sender + IOwnable(token).transferOwnership(msg.sender); + + return (token, pool); + } + + /// @notice Deploys a token pool with an existing ERC20 token + /// @dev Since the token already exists, this contract is not the owner and therefore cannot configure the + /// token pool in the token admin registry in the same transaction. The user must invoke the calls to the + /// tokenAdminRegistry manually + /// @dev since the token already exists, the owner must grant the mint and burn roles to the pool manually + /// @param token The address of the existing token to be used in the token pool + /// @param localTokenDecimals The amount of decimals used in the existing token. Since decimals() is not part of the + /// the ERC20 standard, and thus cannot be certain to exist, the amount must be supplied via user input. + /// @param remoteTokenPools An array of remote token pools info to be used in the pool's applyChainUpdates function + /// @param tokenPoolInitCode The creation code for the token pool + /// @param salt The salt to be used in the create2 deployment of the token pool + /// @return poolAddress The address of the token pool that was deployed + function deployTokenPoolWithExistingToken( + address token, + uint8 localTokenDecimals, + RemoteTokenPoolInfo[] calldata remoteTokenPools, + bytes calldata tokenPoolInitCode, + bytes32 salt, + PoolType poolType + ) external returns (address poolAddress) { + // Ensure a unique deployment between senders even if the same input parameter is used to prevent + // DOS/front running attacks + salt = keccak256(abi.encodePacked(salt, msg.sender)); + + // create the token pool and return the address + return _createTokenPool(token, localTokenDecimals, remoteTokenPools, tokenPoolInitCode, salt, poolType); + } + + // ================================================================ + // │ Pool Deployment/Configuration │ + // ================================================================ + + /// @notice Deploys a token pool with the given token information and remote token pools + /// @param token The token to be used in the token pool + /// @param remoteTokenPools An array of remote token pools info to be used in the pool's applyChainUpdates function + /// @param tokenPoolInitCode The creation code for the token pool + /// @param salt The salt to be used in the create2 deployment of the token pool + /// @return poolAddress The address of the token pool that was deployed + function _createTokenPool( + address token, + uint8 localTokenDecimals, + RemoteTokenPoolInfo[] calldata remoteTokenPools, + bytes calldata tokenPoolInitCode, + bytes32 salt, + PoolType poolType + ) private returns (address) { + // Create an array of chain updates to apply to the token pool + TokenPool.ChainUpdate[] memory chainUpdates = new TokenPool.ChainUpdate[](remoteTokenPools.length); + + RemoteTokenPoolInfo memory remoteTokenPool; + for (uint256 i = 0; i < remoteTokenPools.length; ++i) { + remoteTokenPool = remoteTokenPools[i]; + + // If the user provides an empty byte string, indicated no token has already been deployed, + // then the address of the token needs to be predicted. Otherwise the address provided will be used. + if (remoteTokenPool.remoteTokenAddress.length == 0) { + // The user must provide the initCode for the remote token, so its address can be predicted correctly. It's + // provided in the remoteTokenInitCode field for the remoteTokenPool + remoteTokenPool.remoteTokenAddress = abi.encode( + salt.computeAddress( + keccak256(remoteTokenPool.remoteTokenInitCode), remoteTokenPool.remoteChainConfig.remotePoolFactory + ) + ); + } + + // If the user provides an empty byte string parameter, indicating the pool has not been deployed yet, + // the address of the pool should be predicted. Otherwise use the provided address. + if (remoteTokenPool.remotePoolAddress.length == 0) { + // Address is predicted based on the init code hash and the deployer, so the hash must first be computed + // using the initCode and a concatenated set of constructor parameters. + bytes32 remotePoolInitcodeHash = _generatePoolInitcodeHash( + remoteTokenPool.remotePoolInitCode, + remoteTokenPool.remoteChainConfig, + abi.decode(remoteTokenPool.remoteTokenAddress, (address)), + remoteTokenPool.poolType + ); + + // Abi encode the computed remote address so it can be used as bytes in the chain update + remoteTokenPool.remotePoolAddress = + abi.encode(salt.computeAddress(remotePoolInitcodeHash, remoteTokenPool.remoteChainConfig.remotePoolFactory)); + } + + bytes[] memory remotePoolAddresses = new bytes[](1); + remotePoolAddresses[0] = remoteTokenPool.remotePoolAddress; + + chainUpdates[i] = TokenPool.ChainUpdate({ + remoteChainSelector: remoteTokenPool.remoteChainSelector, + remotePoolAddresses: remotePoolAddresses, + remoteTokenAddress: remoteTokenPool.remoteTokenAddress, + outboundRateLimiterConfig: remoteTokenPool.rateLimiterConfig, + inboundRateLimiterConfig: remoteTokenPool.rateLimiterConfig + }); + } + + // Construct the initArgs for the token pool using the immutable contracts for CCIP on the local chain + bytes memory tokenPoolInitArgs; + if (poolType == PoolType.BURN_MINT) { + tokenPoolInitArgs = abi.encode(token, localTokenDecimals, new address[](0), i_rmnProxy, i_ccipRouter); + } else if (poolType == PoolType.LOCK_RELEASE) { + // Lock/Release pools have an additional boolean constructor parameter that must be accounted for, acceptLiquidity, + // which is set to true by default in this case. Users wishing to set it to false must deploy the pool manually. + tokenPoolInitArgs = abi.encode(token, localTokenDecimals, new address[](0), i_rmnProxy, true, i_ccipRouter); + } + + // Construct the deployment code from the initCode and the initArgs and then deploy + address poolAddress = Create2.deploy(0, salt, abi.encodePacked(tokenPoolInitCode, tokenPoolInitArgs)); + + // Apply the chain updates to the token pool + TokenPool(poolAddress).applyChainUpdates(new uint64[](0), chainUpdates); + + // Begin the 2 step ownership transfer of the token pool to the msg.sender. + IOwnable(poolAddress).transferOwnership(address(msg.sender)); // 2 step ownership transfer + + return poolAddress; + } + + /// @notice Generates the hash of the init code the pool will be deployed with + /// @dev The init code hash is used with Create2 to predict the address of the pool on the remote chain + /// @dev ABI-encoding limitations prevent arbitrary constructor parameters from being used, so pool type must be + /// restricted to those with known types in the constructor. This function should be updated if new pool types are needed. + /// @param initCode The init code of the pool + /// @param remoteChainConfig The remote chain config for the pool + /// @param remoteTokenAddress The address of the remote token + /// @param poolType The type of pool to deploy + /// @return bytes32 hash of the init code to be used in the deterministic address calculation + function _generatePoolInitcodeHash( + bytes memory initCode, + RemoteChainConfig memory remoteChainConfig, + address remoteTokenAddress, + PoolType poolType + ) private pure returns (bytes32) { + if (poolType == PoolType.BURN_MINT) { + return keccak256( + abi.encodePacked( + initCode, + // constructor(address token, uint8 localTokenDecimals, address[] allowlist, address rmnProxy, address router) + abi.encode( + remoteTokenAddress, + remoteChainConfig.remoteTokenDecimals, + new address[](0), + remoteChainConfig.remoteRMNProxy, + remoteChainConfig.remoteRouter + ) + ) + ); + } else { + // if poolType is PoolType.LOCK_RELEASE, but may be expanded in future versions + return keccak256( + abi.encodePacked( + initCode, + // constructor(address token, uint8 localTokenDecimals, address[] allowList, address rmnProxy, bool acceptLiquidity, address router) + abi.encode( + remoteTokenAddress, + remoteChainConfig.remoteTokenDecimals, + new address[](0), + remoteChainConfig.remoteRMNProxy, + true, + remoteChainConfig.remoteRouter + ) + ) + ); + } + } + + /// @notice Sets the token pool address in the token admin registry for a newly deployed token pool. + /// @dev this function should only be called when the token is deployed by this contract as well, otherwise + /// the token pool will not be able to be set in the token admin registry, and this function will revert. + /// @param token The address of the token to set the pool for + /// @param pool The address of the pool to set in the token admin registry + function _setTokenPoolInTokenAdminRegistry(address token, address pool) private { + i_registryModuleOwnerCustom.registerAdminViaOwner(token); + i_tokenAdminRegistry.acceptAdminRole(token); + i_tokenAdminRegistry.setPool(token, pool); + + // Begin the 2 admin transfer process which must be accepted in a separate tx. + i_tokenAdminRegistry.transferAdminRole(token, msg.sender); + } +}