diff --git a/scripts/eth-mainnet-sender/DeployBridge.s.sol b/scripts/eth-mainnet-sender/DeployBridge.s.sol new file mode 100644 index 0000000..c2ed607 --- /dev/null +++ b/scripts/eth-mainnet-sender/DeployBridge.s.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import { Script } from "forge-std/Script.sol"; +import { console } from "forge-std/console.sol"; +import { GravityPortal } from "src/oracle/evm/GravityPortal.sol"; +import { GBridgeSender } from "src/oracle/evm/native_token_bridge/GBridgeSender.sol"; + +/// @title DeployBridge — Ethereum Mainnet Sender Ceremony +/// @notice One-shot deployment of the bridge sender (GravityPortal + GBridgeSender) to Ethereum mainnet. +/// This is the Ethereum-side counterpart to GBridgeReceiver on Gravity chain. +/// @dev Deployer EOA is set as temporary owner; ownership is immediately handed to +/// the multisig via Ownable2Step.transferOwnership(). The multisig must then +/// call acceptOwnership() on BOTH contracts to finalize the handover. +/// +/// Required env vars: +/// PRIVATE_KEY - deployer EOA private key (temporary owner) +/// MULTISIG_ADDRESS - final owner (Safe multisig) for both contracts +/// +/// Optional env vars: +/// FEE_RECIPIENT_ADDRESS - GravityPortal fee recipient (default: MULTISIG_ADDRESS) +/// INITIAL_BASE_FEE - GravityPortal base fee in wei (default: 0.00001 ether) +/// INITIAL_FEE_PER_BYTE - GravityPortal fee per byte in wei (default: 100 gwei) +/// ALLOW_NON_MAINNET - set to "1" to bypass chainid check (fork tests only) +contract DeployBridge is Script { + // ======================================================================== + // MAINNET CONSTANTS + // ======================================================================== + + /// @notice Gravity (G) ERC20 token on Ethereum mainnet (verified: name="Gravity", symbol="G", decimals=18) + address constant G_TOKEN_MAINNET = 0x9C7BEBa8F6eF6643aBd725e45a4E8387eF260649; + + /// @notice Ethereum mainnet chain id + uint256 constant ETHEREUM_MAINNET_CHAINID = 1; + + /// @notice Default initial base fee for GravityPortal (owner can change later) + uint256 constant DEFAULT_BASE_FEE = 0.00001 ether; + + /// @notice Default initial fee per byte for GravityPortal (owner can change later) + uint256 constant DEFAULT_FEE_PER_BYTE = 100 gwei; + + // ======================================================================== + // RUN + // ======================================================================== + + function run() external { + // --- Chain id guard --- + bool allowNonMainnet; + try vm.envString("ALLOW_NON_MAINNET") returns (string memory v) { + allowNonMainnet = keccak256(bytes(v)) == keccak256(bytes("1")); + } catch { + allowNonMainnet = false; + } + if (!allowNonMainnet) { + require(block.chainid == ETHEREUM_MAINNET_CHAINID, "Not Ethereum mainnet (set ALLOW_NON_MAINNET=1 for fork tests)"); + } + + // --- Resolve config --- + uint256 deployerPk = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPk); + address multisig = vm.envAddress("MULTISIG_ADDRESS"); + address feeRecipient = _envAddressOr("FEE_RECIPIENT_ADDRESS", multisig); + uint256 baseFee = _envUintOr("INITIAL_BASE_FEE", DEFAULT_BASE_FEE); + uint256 feePerByte = _envUintOr("INITIAL_FEE_PER_BYTE", DEFAULT_FEE_PER_BYTE); + + // --- Validate --- + require(multisig != address(0), "MULTISIG_ADDRESS not set"); + require(feeRecipient != address(0), "FEE_RECIPIENT_ADDRESS invalid"); + require(deployer != multisig, "Deployer and multisig must differ (multisig cannot broadcast)"); + + // --- Pre-flight log --- + console.log("=== Gravity Bridge Sender - Ethereum Mainnet Deployment ==="); + console.log("Chain id :", block.chainid); + console.log("Deployer (temp owner):", deployer); + console.log("Multisig (final owner):", multisig); + console.log("Fee recipient :", feeRecipient); + console.log("G token :", G_TOKEN_MAINNET); + console.log("Initial baseFee (wei) :", baseFee); + console.log("Initial feePerByte (wei):", feePerByte); + console.log("Deployer balance (wei) :", deployer.balance); + + // --- Broadcast: 4 txs --- + vm.startBroadcast(deployerPk); + + // 1. Deploy GravityPortal with EOA as temporary owner, multisig as feeRecipient. + // feeRecipient is safe to set to multisig now because it is NOT owner-gated to change. + GravityPortal portal = new GravityPortal({ + initialOwner: deployer, + initialBaseFee: baseFee, + initialFeePerByte: feePerByte, + initialFeeRecipient: feeRecipient + }); + console.log("GravityPortal deployed:", address(portal)); + + // 2. Hand off Portal ownership to multisig (pending; multisig must acceptOwnership). + portal.transferOwnership(multisig); + + // 3. Deploy GBridgeSender wired to the freshly deployed Portal and the mainnet G token. + GBridgeSender sender = new GBridgeSender({ + gToken_: G_TOKEN_MAINNET, + gravityPortal_: address(portal), + owner_: deployer + }); + console.log("GBridgeSender deployed:", address(sender)); + + // 4. Hand off Sender ownership to multisig. + sender.transferOwnership(multisig); + + vm.stopBroadcast(); + + // --- Post-deploy invariants --- + require(portal.owner() == deployer, "Portal owner unexpectedly not deployer post-transfer"); + require(portal.pendingOwner() == multisig, "Portal pendingOwner != multisig"); + require(sender.owner() == deployer, "Sender owner unexpectedly not deployer post-transfer"); + require(sender.pendingOwner() == multisig, "Sender pendingOwner != multisig"); + require(sender.gToken() == G_TOKEN_MAINNET, "Sender gToken mismatch"); + require(sender.gravityPortal() == address(portal), "Sender portal mismatch"); + require(portal.feeRecipient() == feeRecipient, "Portal feeRecipient mismatch"); + + // --- Summary --- + console.log("\n=== Deployment Complete ==="); + console.log("GravityPortal :", address(portal)); + console.log("GBridgeSender :", address(sender)); + console.log("Both pendingOwner = multisig. Multisig must now call acceptOwnership() on each."); + + // --- JSON out --- + vm.serializeAddress("deployment", "gravityPortal", address(portal)); + vm.serializeAddress("deployment", "gBridgeSender", address(sender)); + vm.serializeAddress("deployment", "multisig", multisig); + vm.serializeAddress("deployment", "feeRecipient", feeRecipient); + vm.serializeAddress("deployment", "gToken", G_TOKEN_MAINNET); + vm.serializeUint("deployment", "chainId", block.chainid); + string memory json = vm.serializeString("deployment", "network", "ethereum-mainnet"); + console.log("\nDeployment JSON:", json); + } + + // ======================================================================== + // HELPERS + // ======================================================================== + + function _envAddressOr(string memory key, address fallbackValue) internal view returns (address) { + try vm.envAddress(key) returns (address v) { + return v; + } catch { + return fallbackValue; + } + } + + function _envUintOr(string memory key, uint256 fallbackValue) internal view returns (uint256) { + try vm.envUint(key) returns (uint256 v) { + return v; + } catch { + return fallbackValue; + } + } +} diff --git a/scripts/eth-mainnet-sender/README.md b/scripts/eth-mainnet-sender/README.md new file mode 100644 index 0000000..b65812f --- /dev/null +++ b/scripts/eth-mainnet-sender/README.md @@ -0,0 +1,117 @@ +# Gravity Bridge Sender — Ethereum Mainnet Deployment + +Deploys `GravityPortal` + `GBridgeSender` to Ethereum mainnet in a single `forge script` broadcast, handing ownership of both contracts to a Safe multisig via `Ownable2Step.transferOwnership()`. The multisig must then call `acceptOwnership()` on each contract to finalize the handover. + +This is the Ethereum-side counterpart to `GBridgeReceiver` on Gravity chain. The full operator runbook (multisig decisions, dry-run, handoff to Gravity mainnet launch §11.2) lives in **`mono-grav/docs/mainnet/ETH-BRIDGE-SENDER-DEPLOYMENT.md`**. This file is a minimal script-side reference. + +## Constants baked into the script + +| Name | Value | Source | +|------|-------|--------| +| G token (mainnet) | `0x9C7BEBa8F6eF6643aBd725e45a4E8387eF260649` | verified on-chain: `name="Gravity"`, `symbol="G"`, `decimals=18`, supports ERC20Permit | +| Chain id | `1` | hard guard, bypass with `ALLOW_NON_MAINNET=1` (fork only) | + +## Required env vars + +```bash +PRIVATE_KEY=0x... # deployer EOA — temporary owner; only holds ownership for the duration of the script +MULTISIG_ADDRESS=0x... # final owner (Safe); MUST differ from the deployer EOA +``` + +## Optional env vars + +```bash +FEE_RECIPIENT_ADDRESS=0x... # default: MULTISIG_ADDRESS +INITIAL_BASE_FEE=10000000000000 # wei; default 0.00001 ether (revisit before real deploy) +INITIAL_FEE_PER_BYTE=100000000000 # wei; default 100 gwei (revisit before real deploy) +ALLOW_NON_MAINNET=1 # ONLY for fork tests; omit on real deploy +``` + +## Flow performed by the script + +1. `new GravityPortal(deployer, baseFee, feePerByte, feeRecipient=MULTISIG)` +2. `portal.transferOwnership(MULTISIG)` — pendingOwner = multisig +3. `new GBridgeSender(gToken=G_TOKEN_MAINNET, portal=address(portal), deployer)` +4. `sender.transferOwnership(MULTISIG)` — pendingOwner = multisig +5. Post-broadcast asserts: `pendingOwner == multisig` on both, portal/gToken wiring is correct. + +Deployer EOA is the active owner only between tx 1→2 and tx 3→4 — practically zero window. +`feeRecipient` is set to multisig in the constructor, so any `withdrawFees()` the deployer could theoretically call during that window still routes ETH to the multisig. + +## Local fork dry-run (no broadcast, no mainnet cost) + +```bash +export PRIVATE_KEY=0x # anvil test key works on fork +export MULTISIG_ADDRESS=0x000000000000000000000000000000000000dEaD +export ALLOW_NON_MAINNET=1 + +forge script scripts/eth-mainnet-sender/DeployBridge.s.sol \ + --fork-url https://ethereum-rpc.publicnode.com \ + -vvvv +``` + +Expect: both contracts deploy, all post-deploy asserts pass, deployment JSON printed. No transactions are broadcast without `--broadcast`. + +## Real mainnet deploy + +1. Fund the deployer EOA with ~0.2 ETH. +2. Confirm `MULTISIG_ADDRESS`, `FEE_RECIPIENT_ADDRESS`, `INITIAL_BASE_FEE`, `INITIAL_FEE_PER_BYTE` are the finalized values. +3. Commit hash of the repo in this deploy is recorded in the deployment artifact. + +```bash +export PRIVATE_KEY=0x... # hardware wallet / Foundry keystore recommended over raw env +export MULTISIG_ADDRESS=0x... +export ETHERSCAN_API_KEY=... + +forge script scripts/eth-mainnet-sender/DeployBridge.s.sol \ + --rpc-url $MAINNET_RPC_URL \ + --broadcast \ + --verify \ + --etherscan-api-key $ETHERSCAN_API_KEY \ + -vvvv +``` + +Alternative using Foundry keystore / hardware wallet: + +```bash +forge script scripts/eth-mainnet-sender/DeployBridge.s.sol \ + --rpc-url $MAINNET_RPC_URL \ + --broadcast \ + --ledger --sender 0x \ + --verify --etherscan-api-key $ETHERSCAN_API_KEY +``` + +(If using `--ledger`, remove `PRIVATE_KEY` from env and adjust the script to read sender from `msg.sender` instead of `vm.addr(vm.envUint("PRIVATE_KEY"))`. Ping before switching this mode.) + +## Post-deploy: multisig acceptOwnership + +On the Safe, create two Contract Interaction transactions: + +| Target | Method | Args | +|--------|--------|------| +| `GravityPortal` address | `acceptOwnership()` | none | +| `GBridgeSender` address | `acceptOwnership()` | none | + +Verify afterwards: + +```bash +cast call "owner()(address)" --rpc-url $MAINNET_RPC_URL # == MULTISIG +cast call "owner()(address)" --rpc-url $MAINNET_RPC_URL # == MULTISIG +cast call "pendingOwner()(address)" --rpc-url $MAINNET_RPC_URL # == 0x0 +cast call "pendingOwner()(address)" --rpc-url $MAINNET_RPC_URL # == 0x0 +``` + +**Do not announce the contract addresses or open the bridge frontend until the multisig has accepted ownership on both contracts.** + +## Smoke test after acceptOwnership + +1. `cast call "calculateBridgeFee(uint256,address)" 1000000000000000000 ` — sanity check fee +2. Approve 1 G to Sender, then call `bridgeToGravity(1e18, )` with `msg.value = fee`. +3. Confirm `TokensLocked` event on Ethereum and native G mint on Gravity chain. + +## Emergency + +No upgrade path. If a critical bug is found: +- Multisig can `emergencyWithdraw` / `recoverERC20` on GBridgeSender (drains locked G back to a safe address). +- Consensus engine side can stop processing `MessageSent` events from the deployed Portal. +- Fix requires re-deploy + frontend + consensus engine redirect.