From 298f3d8fedb5bc23c4c29bded5a60cbce6b520b6 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Dec 2025 12:00:09 -0500 Subject: [PATCH 1/8] feat: basic third-party bridge app --- .gitmodules | 3 + foundry.lock | 15 ++-- lib/simple-erc20 | 1 + src/apps/bridge/BridgeL2.sol | 50 +++++++++++ src/apps/lido/LidoL2.sol | 54 ++++++++++++ src/chains/Pecorino.sol | 4 +- src/l2/SelfOwned.sol | 12 +++ src/l2/Signet.sol | 30 ++++++- src/l2/examples/GetOut.sol | 2 +- src/vendor/BurnMintERC20.sol | 158 ++++++++++++++++++++++++++++++++++ src/vendor/IBurnMintERC20.sol | 31 +++++++ src/vendor/IGetCCIPAdmin.sol | 8 ++ test/Base.sol | 13 +++ test/SelfOwned.sol | 26 ++++++ test/Tests.sol | 12 --- 15 files changed, 394 insertions(+), 25 deletions(-) create mode 160000 lib/simple-erc20 create mode 100644 src/apps/bridge/BridgeL2.sol create mode 100644 src/apps/lido/LidoL2.sol create mode 100644 src/l2/SelfOwned.sol create mode 100644 src/vendor/BurnMintERC20.sol create mode 100644 src/vendor/IBurnMintERC20.sol create mode 100644 src/vendor/IGetCCIPAdmin.sol create mode 100644 test/Base.sol create mode 100644 test/SelfOwned.sol delete mode 100644 test/Tests.sol 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/BridgeL2.sol b/src/apps/bridge/BridgeL2.sol new file mode 100644 index 0000000..5679469 --- /dev/null +++ b/src/apps/bridge/BridgeL2.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {RollupOrders} from "zenith/src/orders/RollupOrders.sol"; +import {SimpleERC20} from "simple-erc20/SimpleERC20.sol"; + +import {SignetL2} from "../../l2/Signet.sol"; + +abstract contract BridgeL2 is SignetL2, SimpleERC20 { + address immutable HOST_ASSET; + address immutable HOST_BANK; + + constructor( + address _hostAsset, + address _hostBank, + address _initialOwner, + string memory _name, + string memory _symbol, + uint8 _decimals + ) SimpleERC20(_initialOwner, _name, _symbol, _decimals) { + HOST_ASSET = _hostAsset; + HOST_BANK = _hostBank; + } + + function _bridgeIn(address recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal virtual { + RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); + outputs[0] = makeHostOutput(HOST_ASSET, amount, HOST_BANK); + + ORDERS.initiate(block.timestamp, inputs, outputs); + + _mint(recipient, amount); + } + + function bridgeIn(address recipient, uint256 amount) public virtual { + _bridgeIn(recipient, amount, new RollupOrders.Input[](0)); + } + + function _bridgeOut(address recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal virtual { + RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); + outputs[0] = makeHostOutput(HOST_ASSET, amount, recipient); + + ORDERS.initiate(block.timestamp, inputs, outputs); + + _burn(msg.sender, amount); + } + + function bridgeOut(address recipient, uint256 amount) public virtual { + _bridgeOut(recipient, amount, new RollupOrders.Input[](0)); + } +} diff --git a/src/apps/lido/LidoL2.sol b/src/apps/lido/LidoL2.sol new file mode 100644 index 0000000..cfd7f0d --- /dev/null +++ b/src/apps/lido/LidoL2.sol @@ -0,0 +1,54 @@ +// 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 {SignetL2} from "../../l2/Signet.sol"; +import {BurnMintERC20} from "../../vendor/BurnMintERC20.sol"; + + +contract LidoL2 is SignetL2(), BurnMintERC20 { + using SafeERC20 for IERC20; + + address public immutable HOST_WSTETH; + + constructor(address _hostWsteth) + BurnMintERC20("Signet Lido Staked Ether", "stETH", 18, 0, 0) + { + HOST_WSTETH = _hostWsteth; + WETH.forceApprove(address(ORDERS), type(uint256).max); + } + + function _bridgeIn(address recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal { + RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); + outputs[0] = makeHostOutput(HOST_WSTETH, amount, address(HOST_PASSAGE)); + + ORDERS.initiate(block.timestamp, inputs, outputs); + + _mint(recipient, amount); + } + + function bridgeIn(address recipient, uint256 amount) external { + _bridgeIn(recipient, amount, new RollupOrders.Input[](0)); + } + + function bridgeOut(address recipient, uint256 amount) public { + _burn(msg.sender, amount); + + RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); + outputs[0] = makeHostOutput(HOST_WSTETH, amount, recipient); + + ORDERS.initiate(block.timestamp, new RollupOrders.Input[](0), outputs); + } + + function enter(address funder, uint256 amountIn, address recipient, uint256 amountOut) external { + WETH.safeTransferFrom(funder, address(this), amountIn); + + RollupOrders.Input[] memory inputs = new RollupOrders.Input[](1); + inputs[0] = makeWethInput(amountIn); + + _bridgeIn(recipient, amountOut, inputs); + } +} 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..a0a9fe3 --- /dev/null +++ b/src/l2/SelfOwned.sol @@ -0,0 +1,12 @@ +// 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..dbe2ae4 100644 --- a/src/l2/Signet.sol +++ b/src/l2/Signet.sol @@ -6,6 +6,7 @@ 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 @@ -19,6 +20,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 +47,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 +65,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 +80,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..806507a --- /dev/null +++ b/src/vendor/BurnMintERC20.sol @@ -0,0 +1,158 @@ +// 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..4086256 --- /dev/null +++ b/src/vendor/IBurnMintERC20.sol @@ -0,0 +1,31 @@ +// 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..d83a1f3 --- /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..72724f8 --- /dev/null +++ b/test/SelfOwned.sol @@ -0,0 +1,26 @@ +// 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 {AddressAliasHelper} from "../src/vendor/AddressAliasHelper.sol"; + +contract SelfOwnedToken is SignetL2, SimpleERC20 { + constructor() SimpleERC20(aliasedSelf(), "My Token", "MTK", 18) { + assert(HOST_WETH != address(0)); + } +} + +contract TestSelfOwned is PecorinoTest { + SelfOwnedToken token; + + constructor() { + token = new SelfOwnedToken(); + } + + function test_ownerIsSelfOnL1() public view { + assertEq(token.owner(), AddressAliasHelper.applyL1ToL2Alias(address(token))); + } +} 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; - } -} - From eb9d584b0a21a550d3b3d0dbad6b2ccd0113c847 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Dec 2025 13:08:50 -0500 Subject: [PATCH 2/8] lint: fmt --- src/apps/lido/LidoL2.sol | 7 +- src/l2/SelfOwned.sol | 2 +- src/vendor/BurnMintERC20.sol | 258 ++++++++++++++++------------------ src/vendor/IBurnMintERC20.sol | 40 +++--- src/vendor/IGetCCIPAdmin.sol | 6 +- 5 files changed, 149 insertions(+), 164 deletions(-) diff --git a/src/apps/lido/LidoL2.sol b/src/apps/lido/LidoL2.sol index cfd7f0d..8085858 100644 --- a/src/apps/lido/LidoL2.sol +++ b/src/apps/lido/LidoL2.sol @@ -8,15 +8,12 @@ import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {SignetL2} from "../../l2/Signet.sol"; import {BurnMintERC20} from "../../vendor/BurnMintERC20.sol"; - -contract LidoL2 is SignetL2(), BurnMintERC20 { +contract LidoL2 is SignetL2, BurnMintERC20 { using SafeERC20 for IERC20; address public immutable HOST_WSTETH; - constructor(address _hostWsteth) - BurnMintERC20("Signet Lido Staked Ether", "stETH", 18, 0, 0) - { + constructor(address _hostWsteth) BurnMintERC20("Signet Lido Staked Ether", "stETH", 18, 0, 0) { HOST_WSTETH = _hostWsteth; WETH.forceApprove(address(ORDERS), type(uint256).max); } diff --git a/src/l2/SelfOwned.sol b/src/l2/SelfOwned.sol index a0a9fe3..38f0925 100644 --- a/src/l2/SelfOwned.sol +++ b/src/l2/SelfOwned.sol @@ -5,7 +5,7 @@ import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; import {SignetL2} from "./Signet.sol"; -abstract contract SelfOwned is SignetL2(), Ownable { +abstract contract SelfOwned is SignetL2, Ownable { constructor() { Ownable(aliasedSelf()); } diff --git a/src/vendor/BurnMintERC20.sol b/src/vendor/BurnMintERC20.sol index 806507a..4dec0e6 100644 --- a/src/vendor/BurnMintERC20.sol +++ b/src/vendor/BurnMintERC20.sol @@ -21,138 +21,128 @@ 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); - } + 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 index 4086256..a26751e 100644 --- a/src/vendor/IBurnMintERC20.sol +++ b/src/vendor/IBurnMintERC20.sol @@ -4,28 +4,26 @@ 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 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 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 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; + /// @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 index d83a1f3..de01786 100644 --- a/src/vendor/IGetCCIPAdmin.sol +++ b/src/vendor/IGetCCIPAdmin.sol @@ -2,7 +2,7 @@ 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); + /// @notice Returns the admin of the token. + /// @dev This method is named to never conflict with existing methods. + function getCCIPAdmin() external view returns (address); } From bbe5648777963e1f274d31311f79b063232c204c Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Dec 2025 14:55:35 -0500 Subject: [PATCH 3/8] refactor: move down --- src/apps/{bridge/BridgeL2.sol => Bridge.sol} | 2 +- src/apps/{lido/LidoL2.sol => Lido.sol} | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) rename src/apps/{bridge/BridgeL2.sol => Bridge.sol} (97%) rename src/apps/{lido/LidoL2.sol => Lido.sol} (84%) diff --git a/src/apps/bridge/BridgeL2.sol b/src/apps/Bridge.sol similarity index 97% rename from src/apps/bridge/BridgeL2.sol rename to src/apps/Bridge.sol index 5679469..8285066 100644 --- a/src/apps/bridge/BridgeL2.sol +++ b/src/apps/Bridge.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.13; import {RollupOrders} from "zenith/src/orders/RollupOrders.sol"; import {SimpleERC20} from "simple-erc20/SimpleERC20.sol"; -import {SignetL2} from "../../l2/Signet.sol"; +import {SignetL2} from "../l2/Signet.sol"; abstract contract BridgeL2 is SignetL2, SimpleERC20 { address immutable HOST_ASSET; diff --git a/src/apps/lido/LidoL2.sol b/src/apps/Lido.sol similarity index 84% rename from src/apps/lido/LidoL2.sol rename to src/apps/Lido.sol index 8085858..50f8e55 100644 --- a/src/apps/lido/LidoL2.sol +++ b/src/apps/Lido.sol @@ -5,9 +5,14 @@ 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 {SignetL2} from "../../l2/Signet.sol"; -import {BurnMintERC20} from "../../vendor/BurnMintERC20.sol"; - +import {SignetL2} from "../l2/Signet.sol"; +import {BurnMintERC20} from "../vendor/BurnMintERC20.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. contract LidoL2 is SignetL2, BurnMintERC20 { using SafeERC20 for IERC20; From bde89cdafaf24511745bc21b3a7ba28ded24cb05 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Dec 2025 10:24:48 -0500 Subject: [PATCH 4/8] chore: add some natspec --- src/apps/Lido.sol | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/apps/Lido.sol b/src/apps/Lido.sol index 50f8e55..bb5e151 100644 --- a/src/apps/Lido.sol +++ b/src/apps/Lido.sol @@ -13,9 +13,20 @@ import {BurnMintERC20} from "../vendor/BurnMintERC20.sol"; /// 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 SignetL2, BurnMintERC20 { using SafeERC20 for IERC20; +/// @notice The WstETH token on the host. address public immutable HOST_WSTETH; constructor(address _hostWsteth) BurnMintERC20("Signet Lido Staked Ether", "stETH", 18, 0, 0) { @@ -23,6 +34,8 @@ contract LidoL2 is SignetL2, BurnMintERC20 { WETH.forceApprove(address(ORDERS), type(uint256).max); } + /// @notice Create an order to bridge in wstETH from L1, and mint stETH on + /// L2. function _bridgeIn(address recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal { RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); outputs[0] = makeHostOutput(HOST_WSTETH, amount, address(HOST_PASSAGE)); @@ -32,10 +45,16 @@ contract LidoL2 is SignetL2, BurnMintERC20 { _mint(recipient, amount); } + /// @notice Bridge in wstETH from L1, and mint stETH on L2. function bridgeIn(address recipient, uint256 amount) external { _bridgeIn(recipient, amount, new RollupOrders.Input[](0)); } + /// @notice Burn stETH on L2, and create an order to bridge out wstETH to + /// L1. If the order is not filled, the stETH 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 { _burn(msg.sender, amount); @@ -45,6 +64,8 @@ contract LidoL2 is SignetL2, BurnMintERC20 { ORDERS.initiate(block.timestamp, new RollupOrders.Input[](0), outputs); } + /// @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(address funder, uint256 amountIn, address recipient, uint256 amountOut) external { WETH.safeTransferFrom(funder, address(this), amountIn); From 97e0668d5ac26500f03ff5a2a02b78fff4ade2cc Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Dec 2025 10:25:41 -0500 Subject: [PATCH 5/8] lint: fmt --- src/apps/Lido.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/Lido.sol b/src/apps/Lido.sol index bb5e151..b0ec3ef 100644 --- a/src/apps/Lido.sol +++ b/src/apps/Lido.sol @@ -26,7 +26,7 @@ import {BurnMintERC20} from "../vendor/BurnMintERC20.sol"; contract LidoL2 is SignetL2, BurnMintERC20 { using SafeERC20 for IERC20; -/// @notice The WstETH token on the host. + /// @notice The WstETH token on the host. address public immutable HOST_WSTETH; constructor(address _hostWsteth) BurnMintERC20("Signet Lido Staked Ether", "stETH", 18, 0, 0) { From 684e8eb75cd9e2102f5a0ab11a8e839ef536cbdc Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Dec 2025 13:17:22 -0500 Subject: [PATCH 6/8] chore: add natspec --- src/apps/Bridge.sol | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/apps/Bridge.sol b/src/apps/Bridge.sol index 8285066..af9712a 100644 --- a/src/apps/Bridge.sol +++ b/src/apps/Bridge.sol @@ -7,7 +7,10 @@ import {SimpleERC20} from "simple-erc20/SimpleERC20.sol"; import {SignetL2} from "../l2/Signet.sol"; abstract contract BridgeL2 is SignetL2, SimpleERC20 { + /// @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( @@ -22,6 +25,7 @@ abstract contract BridgeL2 is SignetL2, SimpleERC20 { 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 { RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); outputs[0] = makeHostOutput(HOST_ASSET, amount, HOST_BANK); @@ -31,10 +35,16 @@ abstract contract BridgeL2 is SignetL2, SimpleERC20 { _mint(recipient, amount); } + /// @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 recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal virtual { RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); outputs[0] = makeHostOutput(HOST_ASSET, amount, recipient); @@ -44,6 +54,11 @@ abstract contract BridgeL2 is SignetL2, SimpleERC20 { _burn(msg.sender, amount); } + /// @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 recipient, uint256 amount) public virtual { _bridgeOut(recipient, amount, new RollupOrders.Input[](0)); } From 9af82ed05dfff63912629a68b63fbab8efbeaa0e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Dec 2025 15:33:20 -0500 Subject: [PATCH 7/8] refactor: use BurnMintErc20 everywhere --- src/apps/Bridge.sol | 15 +++++---------- src/apps/Lido.sol | 37 +++--------------------------------- src/apps/SignetCoreAsset.sol | 18 ++++++++++++++++++ src/l2/Signet.sol | 3 +++ 4 files changed, 29 insertions(+), 44 deletions(-) create mode 100644 src/apps/SignetCoreAsset.sol diff --git a/src/apps/Bridge.sol b/src/apps/Bridge.sol index af9712a..3ab48f8 100644 --- a/src/apps/Bridge.sol +++ b/src/apps/Bridge.sol @@ -2,25 +2,20 @@ pragma solidity ^0.8.13; import {RollupOrders} from "zenith/src/orders/RollupOrders.sol"; -import {SimpleERC20} from "simple-erc20/SimpleERC20.sol"; +import {BurnMintERC20} from "../vendor/BurnMintERC20.sol"; import {SignetL2} from "../l2/Signet.sol"; -abstract contract BridgeL2 is SignetL2, SimpleERC20 { +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, - address _initialOwner, - string memory _name, - string memory _symbol, - uint8 _decimals - ) SimpleERC20(_initialOwner, _name, _symbol, _decimals) { + 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; } diff --git a/src/apps/Lido.sol b/src/apps/Lido.sol index b0ec3ef..aba413c 100644 --- a/src/apps/Lido.sol +++ b/src/apps/Lido.sol @@ -5,8 +5,7 @@ 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 {SignetL2} from "../l2/Signet.sol"; -import {BurnMintERC20} from "../vendor/BurnMintERC20.sol"; +import {BridgeL2} from "./Bridge.sol"; /// @notice An example contract, implementing LIDO staking from Signet L2, with /// support for CCIP teleporting. @@ -23,47 +22,17 @@ import {BurnMintERC20} from "../vendor/BurnMintERC20.sol"; /// WETH to wstETH on L1 and delivers it to `HOST_PASSAGE`, and mints stETH /// on L2 to `recipient`. /// -contract LidoL2 is SignetL2, BurnMintERC20 { +contract LidoL2 is BridgeL2 { using SafeERC20 for IERC20; /// @notice The WstETH token on the host. address public immutable HOST_WSTETH; - constructor(address _hostWsteth) BurnMintERC20("Signet Lido Staked Ether", "stETH", 18, 0, 0) { + constructor(address _hostWsteth) BridgeL2(_hostWsteth, HOST_PASSAGE, "Lido Staked Ether", "stETH", 18) { HOST_WSTETH = _hostWsteth; WETH.forceApprove(address(ORDERS), type(uint256).max); } - /// @notice Create an order to bridge in wstETH from L1, and mint stETH on - /// L2. - function _bridgeIn(address recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal { - RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); - outputs[0] = makeHostOutput(HOST_WSTETH, amount, address(HOST_PASSAGE)); - - ORDERS.initiate(block.timestamp, inputs, outputs); - - _mint(recipient, amount); - } - - /// @notice Bridge in wstETH from L1, and mint stETH on L2. - function bridgeIn(address recipient, uint256 amount) external { - _bridgeIn(recipient, amount, new RollupOrders.Input[](0)); - } - - /// @notice Burn stETH on L2, and create an order to bridge out wstETH to - /// L1. If the order is not filled, the stETH 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 { - _burn(msg.sender, amount); - - RollupOrders.Output[] memory outputs = new RollupOrders.Output[](1); - outputs[0] = makeHostOutput(HOST_WSTETH, amount, recipient); - - ORDERS.initiate(block.timestamp, new RollupOrders.Input[](0), outputs); - } - /// @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(address funder, uint256 amountIn, address recipient, uint256 amountOut) external { 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/l2/Signet.sol b/src/l2/Signet.sol index dbe2ae4..4912c82 100644 --- a/src/l2/Signet.sol +++ b/src/l2/Signet.sol @@ -12,6 +12,9 @@ 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; From b182eef6d196cbe1d4b5c10318462f13e65d705e Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Dec 2025 10:07:58 -0500 Subject: [PATCH 8/8] fix: ownable constructor invocation --- src/apps/Bridge.sol | 31 ++++++++++++++++++++++++------- src/apps/Lido.sol | 4 ++-- src/l2/SelfOwned.sol | 4 +--- test/SelfOwned.sol | 15 ++++++++++++++- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/apps/Bridge.sol b/src/apps/Bridge.sol index 3ab48f8..284a6b2 100644 --- a/src/apps/Bridge.sol +++ b/src/apps/Bridge.sol @@ -22,12 +22,12 @@ abstract contract BridgeL2 is SignetL2, BurnMintERC20 { /// @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); - - _mint(recipient, amount); } /// @notice Bridges assets into the rollup for a given recipient. @@ -40,21 +40,38 @@ abstract contract BridgeL2 is SignetL2, BurnMintERC20 { /// /// This transaction should be paired with some off-chain logic that fills /// orders from the L1 bank. - function _bridgeOut(address recipient, uint256 amount, RollupOrders.Input[] memory inputs) internal virtual { + 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); - - _burn(msg.sender, amount); } - /// @notice Burn asset on L2, and create an order to bridge out asset to + /// @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(recipient, amount, new RollupOrders.Input[](0)); + _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 index aba413c..24821b7 100644 --- a/src/apps/Lido.sol +++ b/src/apps/Lido.sol @@ -35,8 +35,8 @@ contract LidoL2 is BridgeL2 { /// @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(address funder, uint256 amountIn, address recipient, uint256 amountOut) external { - WETH.safeTransferFrom(funder, address(this), amountIn); + 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); diff --git a/src/l2/SelfOwned.sol b/src/l2/SelfOwned.sol index 38f0925..aaa61f6 100644 --- a/src/l2/SelfOwned.sol +++ b/src/l2/SelfOwned.sol @@ -6,7 +6,5 @@ import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; import {SignetL2} from "./Signet.sol"; abstract contract SelfOwned is SignetL2, Ownable { - constructor() { - Ownable(aliasedSelf()); - } + constructor() Ownable(aliasedSelf()) {} } diff --git a/test/SelfOwned.sol b/test/SelfOwned.sol index 72724f8..36fe046 100644 --- a/test/SelfOwned.sol +++ b/test/SelfOwned.sol @@ -4,9 +4,15 @@ 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)); @@ -16,11 +22,18 @@ contract SelfOwnedToken is SignetL2, SimpleERC20 { contract TestSelfOwned is PecorinoTest { SelfOwnedToken token; + SelfOwnedNothing nothing; + constructor() { token = new SelfOwnedToken(); + nothing = new SelfOwnedNothing(); } - function test_ownerIsSelfOnL1() public view { + function test_tokenOwnerIsSelfOnL1() public view { assertEq(token.owner(), AddressAliasHelper.applyL1ToL2Alias(address(token))); } + + function test_nothingOwnerIsSelfOnL1() public view { + assertEq(nothing.owner(), AddressAliasHelper.applyL1ToL2Alias(address(nothing))); + } }