diff --git a/.gitmodules b/.gitmodules index 38bf78c..db417c5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "lib/zenith"] path = lib/zenith url = https://github.com/init4tech/zenith +[submodule "lib/simple-erc20"] + path = lib/simple-erc20 + url = https://github.com/init4tech/simple-erc20 diff --git a/foundry.lock b/foundry.lock index 6fee446..89bdcf8 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,10 +1,4 @@ { - "lib/zenith": { - "tag": { - "name": "v0.1.55", - "rev": "914146a3904541192f2cb0906c0990cc6f90b1e3" - } - }, "lib/forge-std": { "tag": { "name": "v1.10.0", @@ -16,5 +10,14 @@ "name": "v5.4.0", "rev": "c64a1edb67b6e3f4a15cca8909c9482ad33a02b0" } + }, + "lib/simple-erc20": { + "rev": "f5c2597ec179ea13e219ccbca415dc59d5a33398" + }, + "lib/zenith": { + "tag": { + "name": "v0.1.55", + "rev": "914146a3904541192f2cb0906c0990cc6f90b1e3" + } } } \ No newline at end of file diff --git a/lib/simple-erc20 b/lib/simple-erc20 new file mode 160000 index 0000000..f5c2597 --- /dev/null +++ b/lib/simple-erc20 @@ -0,0 +1 @@ +Subproject commit f5c2597ec179ea13e219ccbca415dc59d5a33398 diff --git a/src/apps/Bridge.sol b/src/apps/Bridge.sol new file mode 100644 index 0000000..284a6b2 --- /dev/null +++ b/src/apps/Bridge.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {RollupOrders} from "zenith/src/orders/RollupOrders.sol"; +import {BurnMintERC20} from "../vendor/BurnMintERC20.sol"; + +import {SignetL2} from "../l2/Signet.sol"; + +abstract contract BridgeL2 is SignetL2, BurnMintERC20 { + /// @notice The address of the asset on the host chain. + address immutable HOST_ASSET; + /// @notice The address of the bank on the host chain. The bank holds the + /// asset while tokens are bridged into the rollup. + address immutable HOST_BANK; + + constructor(address _hostAsset, address _hostBank, string memory _name, string memory _symbol, uint8 _decimals) + BurnMintERC20(_name, _symbol, _decimals, 0, 0) + { + HOST_ASSET = _hostAsset; + HOST_BANK = _hostBank; + } + + /// @notice Bridges assets into the rollup for a given recipient. + function _bridgeIn(address recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal virtual { + _mint(recipient, amount); + + RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); + outputs[0] = makeHostOutput(HOST_ASSET, amount, HOST_BANK); + + ORDERS.initiate(block.timestamp, inputs, outputs); + } + + /// @notice Bridges assets into the rollup for a given recipient. + function bridgeIn(address recipient, uint256 amount) public virtual { + _bridgeIn(recipient, amount, new RollupOrders.Input[](0)); + } + + /// @notice Burn asset on L2, and create an order to bridge out asset to + /// L1. If the order is not filled, the asset will not be burned. + /// + /// This transaction should be paired with some off-chain logic that fills + /// orders from the L1 bank. + function _bridgeOut(address sender, address recipient, uint256 amount, RollupOrders.Input[] memory inputs) + internal + virtual + { + if (_msgSender() != sender) { + _spendAllowance(sender, _msgSender(), amount); + } + + _burn(msg.sender, amount); + + RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); + outputs[0] = makeHostOutput(HOST_ASSET, amount, recipient); + + ORDERS.initiate(block.timestamp, inputs, outputs); + } + + /// @notice Burn asset on L2, and create an order to bridge out assets to + /// L1. If the order is not filled, the asset will not be burned. + /// + /// This transaction should be paired with some off-chain logic that fills + /// orders from the L1 bank. + function bridgeOut(address recipient, uint256 amount) public virtual { + _bridgeOut(msg.sender, recipient, amount, new RollupOrders.Input[](0)); + } + + /// @notice Burn asset on L2 from `sender`, and create an order to bridge + /// out assets to L1. If the order is not filled, the asset will not be + /// burned. Used when the caller is not the sender. + /// + /// This transaction should be paired with some off-chain logic that fills + /// orders from the L1 bank. + function bridgeOutFrom(address sender, address recipient, uint256 amount) public virtual { + _bridgeOut(sender, recipient, amount, new RollupOrders.Input[](0)); + } +} diff --git a/src/apps/Lido.sol b/src/apps/Lido.sol new file mode 100644 index 0000000..24821b7 --- /dev/null +++ b/src/apps/Lido.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {RollupOrders} from "zenith/src/orders/RollupOrders.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import {BridgeL2} from "./Bridge.sol"; + +/// @notice An example contract, implementing LIDO staking from Signet L2, with +/// support for CCIP teleporting. +/// Allows bridging two ways: +/// - Signet native bridging with Orders. +/// - CCIP Teleporting via support for the CCT standard. +/// +/// Bridging with Signet: +/// - bridgeIn: Creates an order that delilvers wstETH to `HOST_PASSAGE` on L1. +/// If the order is filled, mints stETH on L2 to `recipient`. +/// - bridgeOut: Burns stETH on L2 from `msg.sender`, and creates an order +/// that delivers wstETH to `recipient` on L1. +/// - enter: Transfers WETH from `funder`, creates an order that converts +/// WETH to wstETH on L1 and delivers it to `HOST_PASSAGE`, and mints stETH +/// on L2 to `recipient`. +/// +contract LidoL2 is BridgeL2 { + using SafeERC20 for IERC20; + + /// @notice The WstETH token on the host. + address public immutable HOST_WSTETH; + + constructor(address _hostWsteth) BridgeL2(_hostWsteth, HOST_PASSAGE, "Lido Staked Ether", "stETH", 18) { + HOST_WSTETH = _hostWsteth; + WETH.forceApprove(address(ORDERS), type(uint256).max); + } + + /// @notice Transfer WETH from `funder`, create an order to convert it to + /// wstETH on L1 and bridge it to L2, and mint stETH to `recipient`. + function enter(uint256 amountIn, address recipient, uint256 amountOut) external { + WETH.safeTransferFrom(msg.sender, address(this), amountIn); + + RollupOrders.Input[] memory inputs = new RollupOrders.Input[](1); + inputs[0] = makeWethInput(amountIn); + + _bridgeIn(recipient, amountOut, inputs); + } +} diff --git a/src/apps/SignetCoreAsset.sol b/src/apps/SignetCoreAsset.sol new file mode 100644 index 0000000..25e77c0 --- /dev/null +++ b/src/apps/SignetCoreAsset.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {BridgeL2} from "./Bridge.sol"; + +contract SignetCoreAsset is BridgeL2 { + constructor( + address _hostAsset, + address _hostPassageAdmin, + string memory _name, + string memory _symbol, + uint8 _decimals + ) BridgeL2(_hostAsset, HOST_PASSAGE, _name, _symbol, _decimals) { + _revokeRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(DEFAULT_ADMIN_ROLE, _hostPassageAdmin); + _grantRole(MINTER_ROLE, TOKEN_MINTER); + } +} diff --git a/src/chains/Pecorino.sol b/src/chains/Pecorino.sol index a760618..146f3b5 100644 --- a/src/chains/Pecorino.sol +++ b/src/chains/Pecorino.sol @@ -25,10 +25,10 @@ library PecorinoConstants { HostOrders constant HOST_ORDERS = HostOrders(0x0A4f505364De0Aa46c66b15aBae44eBa12ab0380); /// @notice The Rollup Passage contract for the Pecorino testnet. - RollupPassage constant PECORINO_ROLLUP_PASSAGE = RollupPassage(payable(0x0000000000007369676E65742D70617373616765)); + RollupPassage constant ROLLUP_PASSAGE = RollupPassage(payable(0x0000000000007369676E65742D70617373616765)); /// @notice The Rollup Orders contract for the Pecorino testnet. - RollupOrders constant PECORINO_ROLLUP_ORDERS = RollupOrders(0x000000000000007369676E65742D6f7264657273); + RollupOrders constant ROLLUP_ORDERS = RollupOrders(0x000000000000007369676E65742D6f7264657273); /// USDC token for the Pecorino testnet host chain. address constant HOST_USDC = 0x65Fb255585458De1F9A246b476aa8d5C5516F6fd; diff --git a/src/l2/SelfOwned.sol b/src/l2/SelfOwned.sol new file mode 100644 index 0000000..aaa61f6 --- /dev/null +++ b/src/l2/SelfOwned.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +import {SignetL2} from "./Signet.sol"; + +abstract contract SelfOwned is SignetL2, Ownable { + constructor() Ownable(aliasedSelf()) {} +} diff --git a/src/l2/Signet.sol b/src/l2/Signet.sol index 4b37300..4912c82 100644 --- a/src/l2/Signet.sol +++ b/src/l2/Signet.sol @@ -6,11 +6,15 @@ import {RollupPassage} from "zenith/src/passage/RollupPassage.sol"; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {PecorinoConstants} from "../chains/Pecorino.sol"; +import {AddressAliasHelper} from "../vendor/AddressAliasHelper.sol"; contract SignetL2 { /// @notice Sentinal value for the native asset in order inputs/outputs address constant NATIVE_ASSET = address(0); + /// @notice System address that produces System minted tokens. + address constant TOKEN_MINTER = 0x00000000000000000000746f6b656E61646d696E; + /// @notice The chain ID of the host network. uint32 internal immutable HOST_CHAIN_ID; @@ -19,6 +23,9 @@ contract SignetL2 { /// @notice The Rollup Orders contract. RollupOrders internal immutable ORDERS; + /// @notice The address of the Rollup Passage on the host network. + address immutable HOST_PASSAGE; + /// @notice The WETH token address. IERC20 internal immutable WETH; /// @notice The WBTC token address. @@ -43,8 +50,10 @@ contract SignetL2 { if (block.chainid == PecorinoConstants.ROLLUP_CHAIN_ID) { HOST_CHAIN_ID = PecorinoConstants.HOST_CHAIN_ID; - PASSAGE = PecorinoConstants.PECORINO_ROLLUP_PASSAGE; - ORDERS = PecorinoConstants.PECORINO_ROLLUP_ORDERS; + HOST_PASSAGE = address(PecorinoConstants.HOST_PASSAGE); + + PASSAGE = PecorinoConstants.ROLLUP_PASSAGE; + ORDERS = PecorinoConstants.ROLLUP_ORDERS; WETH = PecorinoConstants.WETH; WBTC = PecorinoConstants.WBTC; @@ -59,6 +68,12 @@ contract SignetL2 { } } + /// @notice Gets the aliased address of this contracat, representing itself + /// on L1. Use with caustion. + function aliasedSelf() internal view returns (address) { + return AddressAliasHelper.applyL1ToL2Alias(address(this)); + } + /// @notice Creates an Input struct for the RollupOrders. /// @param token The address of the token. /// @param amount The amount of the token. @@ -68,14 +83,24 @@ contract SignetL2 { input.amount = amount; } - /// @notice Creates an Input struct for the native asset (ETH). + /// @notice Creates an Input struct for the native asset (USD). /// @param amount The amount of the native asset (in wei). /// @return input The created Input struct for the native asset. - function makeEthInput(uint256 amount) internal pure returns (RollupOrders.Input memory input) { + function makeUsdInput(uint256 amount) internal pure returns (RollupOrders.Input memory input) { input.token = address(0); input.amount = amount; } + function makeWethInput(uint256 amount) internal view returns (RollupOrders.Input memory input) { + input.token = address(WETH); + input.amount = amount; + } + + function makeWbtcInput(uint256 amount) internal view returns (RollupOrders.Input memory input) { + input.token = address(WBTC); + input.amount = amount; + } + /// @notice Creates an Output struct for the RollupOrders. /// @param token The address of the token. /// @param amount The amount of the token. diff --git a/src/l2/examples/GetOut.sol b/src/l2/examples/GetOut.sol index 3544cd7..ad50696 100644 --- a/src/l2/examples/GetOut.sol +++ b/src/l2/examples/GetOut.sol @@ -30,7 +30,7 @@ contract GetOut is SignetL2 { uint256 desired = msg.value * 995 / 1000; // 0.5% fee RollupOrders.Input[] memory inputs = new RollupOrders.Input[](1); - inputs[0] = makeEthInput(msg.value); + inputs[0] = makeUsdInput(msg.value); RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); outputs[0] = hostUsdcOutput(desired, msg.sender); diff --git a/src/vendor/BurnMintERC20.sol b/src/vendor/BurnMintERC20.sol new file mode 100644 index 0000000..4dec0e6 --- /dev/null +++ b/src/vendor/BurnMintERC20.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT + +// vendored from +// - https://github.com/smartcontractkit/chainlink-evm/blob/contracts-solidity/1.5.0/contracts/src/v0.8/shared/token/ERC20/BurnMintERC20.sol +// modifications made: +// - update imports to vendored interfaces +// - update to openzeppelin contracts v5 +// - remove override functions for _transfer and _approve +pragma solidity ^0.8.4; + +import {IGetCCIPAdmin} from "./IGetCCIPAdmin.sol"; +import {IBurnMintERC20} from "./IBurnMintERC20.sol"; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/// @notice A basic ERC20 compatible token contract with burn and minting roles. +/// @dev The total supply can be limited during deployment. +contract BurnMintERC20 is IBurnMintERC20, IGetCCIPAdmin, IERC165, ERC20Burnable, AccessControl { + error MaxSupplyExceeded(uint256 supplyAfterMint); + error InvalidRecipient(address recipient); + + 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; + + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); + + /// @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) + ERC20(name, symbol) + { + i_decimals = decimals_; + i_maxSupply = maxSupply_; + + s_ccipAdmin = msg.sender; + + // Mint the initial supply to the new Owner, saving gas by not calling if the mint amount is zero + if (preMint != 0) _mint(msg.sender, preMint); + + // Set up the owner as the initial minter and burner + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public pure virtual override(AccessControl, IERC165) returns (bool) { + return interfaceId == type(IERC20).interfaceId || interfaceId == type(IBurnMintERC20).interfaceId + || interfaceId == type(IERC165).interfaceId || interfaceId == type(IAccessControl).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; + } + + // ================================================================ + // │ 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 virtual override(IBurnMintERC20, ERC20Burnable) onlyRole(BURNER_ROLE) { + 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 + virtual + override(IBurnMintERC20, ERC20Burnable) + onlyRole(BURNER_ROLE) + { + 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 virtual override onlyRole(MINTER_ROLE) { + if (account == address(this)) revert InvalidRecipient(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 virtual { + grantRole(MINTER_ROLE, burnAndMinter); + grantRole(BURNER_ROLE, burnAndMinter); + } + + /// @notice Returns the current CCIPAdmin + function getCCIPAdmin() external view virtual 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) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + address currentAdmin = s_ccipAdmin; + + s_ccipAdmin = newAdmin; + + emit CCIPAdminTransferred(currentAdmin, newAdmin); + } +} diff --git a/src/vendor/IBurnMintERC20.sol b/src/vendor/IBurnMintERC20.sol new file mode 100644 index 0000000..a26751e --- /dev/null +++ b/src/vendor/IBurnMintERC20.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IBurnMintERC20 is IERC20 { + /// @notice Mints new tokens for a given address. + /// @param account The address to mint the new tokens to. + /// @param amount The number of tokens to be minted. + /// @dev this function increases the total supply. + function mint(address account, uint256 amount) external; + + /// @notice Burns tokens from the sender. + /// @param amount The number of tokens to be burned. + /// @dev this function decreases the total supply. + function burn(uint256 amount) external; + + /// @notice Burns tokens from a given address.. + /// @param account The address to burn tokens from. + /// @param amount The number of tokens to be burned. + /// @dev this function decreases the total supply. + function burn(address account, uint256 amount) external; + + /// @notice Burns tokens from a given address.. + /// @param account The address to burn tokens from. + /// @param amount The number of tokens to be burned. + /// @dev this function decreases the total supply. + function burnFrom(address account, uint256 amount) external; +} diff --git a/src/vendor/IGetCCIPAdmin.sol b/src/vendor/IGetCCIPAdmin.sol new file mode 100644 index 0000000..de01786 --- /dev/null +++ b/src/vendor/IGetCCIPAdmin.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IGetCCIPAdmin { + /// @notice Returns the admin of the token. + /// @dev This method is named to never conflict with existing methods. + function getCCIPAdmin() external view returns (address); +} diff --git a/test/Base.sol b/test/Base.sol new file mode 100644 index 0000000..a99f53d --- /dev/null +++ b/test/Base.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; + +import {PecorinoConstants} from "../src/chains/Pecorino.sol"; + +contract PecorinoTest is Test { + constructor() { + vm.chainId(PecorinoConstants.ROLLUP_CHAIN_ID); + } +} + diff --git a/test/SelfOwned.sol b/test/SelfOwned.sol new file mode 100644 index 0000000..36fe046 --- /dev/null +++ b/test/SelfOwned.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {SimpleERC20} from "simple-erc20/SimpleERC20.sol"; + +import {PecorinoTest} from "./Base.sol"; + +import {SignetL2} from "../src/l2/Signet.sol"; +import {SelfOwned} from "../src/l2/SelfOwned.sol"; +import {AddressAliasHelper} from "../src/vendor/AddressAliasHelper.sol"; + +contract SelfOwnedNothing is SelfOwned { + constructor() {} +} + +contract SelfOwnedToken is SignetL2, SimpleERC20 { + constructor() SimpleERC20(aliasedSelf(), "My Token", "MTK", 18) { + assert(HOST_WETH != address(0)); + } +} + +contract TestSelfOwned is PecorinoTest { + SelfOwnedToken token; + + SelfOwnedNothing nothing; + + constructor() { + token = new SelfOwnedToken(); + nothing = new SelfOwnedNothing(); + } + + function test_tokenOwnerIsSelfOnL1() public view { + assertEq(token.owner(), AddressAliasHelper.applyL1ToL2Alias(address(token))); + } + + function test_nothingOwnerIsSelfOnL1() public view { + assertEq(nothing.owner(), AddressAliasHelper.applyL1ToL2Alias(address(nothing))); + } +} diff --git a/test/Tests.sol b/test/Tests.sol deleted file mode 100644 index 9d2bf2b..0000000 --- a/test/Tests.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test} from "forge-std/Test.sol"; - -contract TestNop is Test { - /// @notice Prevents foundry from complaining about no tests in CI. - function test_nop() external pure returns (bool) { - return true; - } -} -