diff --git a/contracts/nomina/.gas-snapshot b/contracts/nomina/.gas-snapshot index 6ce8a7a2f..8c3a0aa5c 100644 --- a/contracts/nomina/.gas-snapshot +++ b/contracts/nomina/.gas-snapshot @@ -1,3 +1,6 @@ +NominaMintLock_Test:test_acceptMintAuthority() (gas: 34792) +NominaMintLock_Test:test_acceptMintAuthority_reverts_notPending() (gas: 13525) +NominaMintLock_Test:test_mintLocked() (gas: 36214) Nomina_Test:testAcceptMintAuthority() (gas: 32024) Nomina_Test:testAcceptMintAuthorityReverts() (gas: 13134) Nomina_Test:testBurn() (gas: 105554) diff --git a/contracts/nomina/script/DeployNominaMintLock.sol b/contracts/nomina/script/DeployNominaMintLock.sol new file mode 100644 index 000000000..4c33532e0 --- /dev/null +++ b/contracts/nomina/script/DeployNominaMintLock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.24; + +import { Script } from "forge-std/Script.sol"; +import { Nomina } from "src/token/Nomina.sol"; +import { NominaMintLock } from "src/token/NominaMintLock.sol"; + +contract DeployNominaMintLock is Script { + function run() public returns (NominaMintLock) { + Nomina nomina = Nomina(0x6e6F6d696e61decd6605bD4a57836c5DB6923340); + + vm.broadcast(); + NominaMintLock lock = new NominaMintLock(nomina); + + return lock; + } +} diff --git a/contracts/nomina/src/token/NominaMintLock.sol b/contracts/nomina/src/token/NominaMintLock.sol new file mode 100644 index 000000000..ac6c4dfe2 --- /dev/null +++ b/contracts/nomina/src/token/NominaMintLock.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.24; + +import { Nomina } from "./Nomina.sol"; + +/** + * @title NominaMintLock + * @notice Permanently locks the ability to mint NOM tokens. + * @dev Once this contract accepts mint authority from the Nomina token, the mint authority is + * irrevocably held by this contract. Since this contract has no ability to set a minter or + * transfer the mint authority, no new NOM tokens can ever be minted. + */ +contract NominaMintLock { + /** + * @notice The Nomina token contract. + */ + Nomina public immutable NOMINA; + + constructor(Nomina _nomina) { + NOMINA = _nomina; + } + + /** + * @notice Accepts the mint authority from the Nomina token, permanently locking minting. + * @dev After this call, no new NOM tokens can ever be minted, as this contract has no + * mechanism to set a minter or transfer the mint authority. + */ + function acceptMintAuthority() external { + NOMINA.acceptMintAuthority(); + } +} diff --git a/contracts/nomina/test/token/NominaMintLock.t.sol b/contracts/nomina/test/token/NominaMintLock.t.sol new file mode 100644 index 000000000..58b926515 --- /dev/null +++ b/contracts/nomina/test/token/NominaMintLock.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { Nomina } from "src/token/Nomina.sol"; +import { NominaMintLock } from "src/token/NominaMintLock.sol"; +import { MockOmni } from "test/utils/MockOmni.sol"; + +contract NominaMintLock_Test is Test { + Nomina public nomina; + NominaMintLock public lock; + MockOmni public omni; + + address public mintAuthority = makeAddr("mintAuthority"); + address public minter = makeAddr("minter"); + address public user = makeAddr("user"); + + function setUp() public { + omni = new MockOmni(1_000_000 ether, user); + nomina = new Nomina(address(omni), mintAuthority); + lock = new NominaMintLock(nomina); + + vm.prank(mintAuthority); + nomina.setMinter(minter); + } + + function test_acceptMintAuthority() public { + // Queue the lock contract as pending mint authority + vm.prank(mintAuthority); + nomina.setMintAuthority(address(lock)); + + // Accept mint authority via the lock contract + lock.acceptMintAuthority(); + + // Verify the lock contract is now the mint authority + assertEq(nomina.mintAuthority(), address(lock), "mint authority mismatch"); + assertEq(nomina.pendingMintAuthority(), address(0), "pending mint authority not cleared"); + } + + function test_acceptMintAuthority_reverts_notPending() public { + // Revert when lock is not the pending mint authority + vm.expectRevert(Nomina.Unauthorized.selector); + lock.acceptMintAuthority(); + } + + function test_mintLocked() public { + // Transfer mint authority to the lock contract + vm.prank(mintAuthority); + nomina.setMintAuthority(address(lock)); + lock.acceptMintAuthority(); + + // The lock contract cannot set a minter, so minting is permanently locked. + // Existing minter still works until mint authority sets a new one, + // but the lock contract has no way to call setMinter or setMintAuthority. + + // Verify the lock contract has no setMinter function by checking + // that the mint authority (lock) cannot set a new minter. + // Since NominaMintLock has no setMinter or setMintAuthority functions, + // the mint authority is permanently locked. + (bool success,) = address(lock).call(abi.encodeWithSignature("setMinter(address)", user)); + assertFalse(success, "lock should not have setMinter"); + + (success,) = address(lock).call(abi.encodeWithSignature("setMintAuthority(address)", user)); + assertFalse(success, "lock should not have setMintAuthority"); + } +} diff --git a/e2e/cmd/cmd.go b/e2e/cmd/cmd.go index b93de73f5..ceea89d35 100644 --- a/e2e/cmd/cmd.go +++ b/e2e/cmd/cmd.go @@ -17,6 +17,8 @@ import ( libcmd "github.com/omni-network/omni/lib/cmd" "github.com/omni-network/omni/lib/contracts/solvernet" "github.com/omni-network/omni/lib/errors" + "github.com/omni-network/omni/lib/ethclient" + "github.com/omni-network/omni/lib/ethclient/ethbackend" "github.com/omni-network/omni/lib/log" "github.com/omni-network/omni/lib/netconf" "github.com/omni-network/omni/lib/tokens" @@ -52,7 +54,7 @@ func New() *cobra.Command { } // Some commands do not require a full definition. - if matchAny(cmd.Use, "hyperliquid-use-big-blocks", "drain-relayer-monitor") { + if matchAny(cmd.Use, "hyperliquid-use-big-blocks", "drain-relayer-monitor", "lock-nom-mint") { return nil } @@ -106,6 +108,7 @@ func New() *cobra.Command { fundAccounts(&def), newFundOpsFromSolverCmd(&def), newConvertOmniCmd(&def), + newLockNomMintCmd(&defCfg), newDrainRelayerMonitorCmd(&defCfg), ) @@ -526,6 +529,45 @@ func newConvertOmniCmd(def *app.Definition) *cobra.Command { return cmd } +func newLockNomMintCmd(defCfg *app.DefinitionConfig) *cobra.Command { + cmd := &cobra.Command{ + Use: "lock-nom-mint", + Short: "Sets the NOM mint authority to the NominaMintLock contract", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + chain, err := types.PublicChainByName("ethereum") + if err != nil { + return errors.Wrap(err, "public chain") + } + + rpc := types.PublicRPCByName(chain.Name) + if override, ok := defCfg.RPCOverrides[chain.Name]; ok { + rpc = override + } + + ethCl, err := ethclient.DialContext(ctx, chain.Name, rpc) + if err != nil { + return errors.Wrap(err, "dial ethereum") + } + + fireCl, err := app.NewFireblocksClient(*defCfg, netconf.Mainnet, cmd.Name()) + if err != nil { + return errors.Wrap(err, "new fireblocks client") + } + + backend, err := ethbackend.NewFireBackend(ctx, chain.Name, chain.ChainID, chain.BlockPeriod, ethCl, fireCl) + if err != nil { + return errors.Wrap(err, "new fire backend") + } + + return nomina.LockNomMint(ctx, backend) + }, + } + + return cmd +} + func newDrainRelayerMonitorCmd(defCfg *app.DefinitionConfig) *cobra.Command { var dryRun bool diff --git a/e2e/nomina/mintlock.go b/e2e/nomina/mintlock.go new file mode 100644 index 000000000..62c0ef3d2 --- /dev/null +++ b/e2e/nomina/mintlock.go @@ -0,0 +1,50 @@ +package nomina + +import ( + "context" + + "github.com/omni-network/omni/contracts/bindings" + "github.com/omni-network/omni/e2e/app/eoa" + "github.com/omni-network/omni/lib/contracts" + "github.com/omni-network/omni/lib/errors" + "github.com/omni-network/omni/lib/ethclient/ethbackend" + "github.com/omni-network/omni/lib/log" + "github.com/omni-network/omni/lib/netconf" + + "github.com/ethereum/go-ethereum/common" +) + +// MintLock is the deployed address of the NominaMintLock contract. +var MintLock = common.HexToAddress("0xF9046e60f10000c97316D76Ba0DbAB399C3D8752") + +// LockNomMint calls Nomina.setMintAuthority to queue the NominaMintLock contract as the +// pending mint authority, permanently locking the ability to mint NOM once accepted. +func LockNomMint(ctx context.Context, backend *ethbackend.Backend) error { + nomAddr := contracts.NomAddr(netconf.Mainnet) + + nomina, err := bindings.NewNomina(nomAddr, backend) + if err != nil { + return errors.Wrap(err, "new nomina") + } + + mintAuthority := eoa.MustAddress(netconf.Mainnet, eoa.RoleNomAuthority) + + txOpts, err := backend.BindOpts(ctx, mintAuthority) + if err != nil { + return errors.Wrap(err, "bind opts") + } + + tx, err := nomina.SetMintAuthority(txOpts, MintLock) + if err != nil { + return errors.Wrap(err, "set mint authority") + } + + _, err = backend.WaitMined(ctx, tx) + if err != nil { + return errors.Wrap(err, "wait mined") + } + + log.Info(ctx, "NOM mint lock queued", "nom", nomAddr, "mint_lock", MintLock) + + return nil +}