Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions scripts/eth-mainnet-sender/DeployBridge.s.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
117 changes: 117 additions & 0 deletions scripts/eth-mainnet-sender/README.md
Original file line number Diff line number Diff line change
@@ -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<any-funded-test-key> # 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<ledger-address> \
--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 <portal> "owner()(address)" --rpc-url $MAINNET_RPC_URL # == MULTISIG
cast call <sender> "owner()(address)" --rpc-url $MAINNET_RPC_URL # == MULTISIG
cast call <portal> "pendingOwner()(address)" --rpc-url $MAINNET_RPC_URL # == 0x0
cast call <sender> "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 <sender> "calculateBridgeFee(uint256,address)" 1000000000000000000 <recipient>` — sanity check fee
2. Approve 1 G to Sender, then call `bridgeToGravity(1e18, <recipientOnGravity>)` 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.
Loading