diff --git a/packages/cli/docs/bridge.md b/packages/cli/docs/bridge.md new file mode 100644 index 0000000000..aee906483a --- /dev/null +++ b/packages/cli/docs/bridge.md @@ -0,0 +1,171 @@ +# Bridge Commands + +Bridge CELO tokens between Ethereum (L1) and Celo (L2) using the OP Stack bridge. + +## Overview + +The bridge commands allow you to move CELO between Ethereum (Layer 1) and Celo (Layer 2): + +- **Deposit** (L1 → L2): Move CELO from Ethereum to Celo. Takes ~15 minutes. +- **Withdrawal** (L2 → L1): Move CELO from Celo to Ethereum. Takes ~7 days due to the challenge period. + +## Prerequisites + +- A wallet with CELO on the source chain +- RPC URLs for both L1 (Ethereum) and L2 (Celo) +- A private key or Ledger hardware wallet for signing transactions + +## Deposit: Ethereum (L1) → Celo (L2) + +Deposits are simple — one command, ~15 minute wait. + +### Command + +```bash +celocli bridge:deposit \ + --from 0xYourAddress \ + --to 0xRecipientOnL2 \ + --value 1000000000000000000 \ + --network mainnet \ + --l1RpcUrl https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + -k 0xYourPrivateKey +``` + +### Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--from` | Yes | Your address on L1 (sender) | +| `--to` | No | Recipient address on L2 (defaults to `--from`) | +| `--value` | Yes | Amount in wei (1 CELO = 1000000000000000000) | +| `--network` | Yes | `mainnet` or `sepolia` | +| `--l1RpcUrl` | Yes | Ethereum RPC URL | +| `--gaslimit` | No | L2 gas limit (default: 100000) | +| `-k` / `--privateKey` | Yes* | Private key for signing | +| `--useLedger` | Yes* | Use Ledger hardware wallet | + +*One of `--privateKey` or `--useLedger` is required. + +### What happens + +1. The CLI retrieves the CELO token address on L1 +2. Approves the bridge contract to spend your CELO +3. Submits the deposit transaction +4. Your CELO appears on L2 in ~15 minutes + +## Withdrawal: Celo (L2) → Ethereum (L1) + +Withdrawals are a multi-step process due to the OP Stack's 7-day security challenge period. + +### Step 1: Initiate the Withdrawal + +```bash +celocli bridge:withdraw-init \ + --from 0xYourL2Address \ + --to 0xYourL1Address \ + --value 1000000000000000000 \ + --network mainnet \ + -n mainnet \ + -k 0xYourPrivateKey +``` + +**Save the transaction hash from the output!** You'll need it for all subsequent steps. + +### Step 2: Submit the Proof (~1 hour after Step 1) + +Wait about 1 hour for the L2 output to be published on L1, then submit the proof: + +```bash +celocli bridge:withdraw-prove \ + --txHash 0xYourL2TxHash \ + --from 0xYourAddress \ + --network mainnet \ + --l1RpcUrl https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + -n mainnet \ + -k 0xYourPrivateKey +``` + +This command will wait for the proof to become available if it isn't ready yet. + +### Step 3: Wait for the Challenge Period (7 days) + +After proving, you must wait 7 days for the security challenge period to pass. You can check the status at any time: + +```bash +celocli bridge:withdraw-status \ + --txHash 0xYourL2TxHash \ + --network mainnet \ + --l1RpcUrl https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + -n mainnet +``` + +The status command will tell you exactly where your withdrawal stands and what to do next. + +### Step 4: Finalize and Claim (after 7 days) + +Once the challenge period has passed: + +```bash +celocli bridge:withdraw-finalize \ + --txHash 0xYourL2TxHash \ + --from 0xYourAddress \ + --network mainnet \ + --l1RpcUrl https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + -n mainnet \ + -k 0xYourPrivateKey +``` + +Your CELO will be transferred to your L1 address. + +## Checking Withdrawal Status + +You can check the status of any withdrawal at any point: + +```bash +celocli bridge:withdraw-status \ + --txHash 0xYourL2TxHash \ + --network mainnet \ + --l1RpcUrl https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + -n mainnet +``` + +### Possible Statuses + +| Status | Meaning | Next Action | +|--------|---------|-------------| +| **Waiting to Prove** | Withdrawal initiated, proof not yet available | Wait ~1 hour | +| **Ready to Prove** | Proof available | Run `bridge:withdraw-prove` | +| **Waiting to Finalize** | Proof submitted, challenge period in progress | Wait (up to 7 days) | +| **Ready to Finalize** | Challenge period passed | Run `bridge:withdraw-finalize` | +| **Finalized** | Complete | Nothing — funds are on L1 | + +## Network Support + +| Network | L1 | L2 | +|---------|----|----| +| `mainnet` | Ethereum Mainnet | Celo Mainnet | +| `sepolia` | Ethereum Sepolia | Celo Sepolia | + +## Using with Ledger + +All bridge commands support Ledger hardware wallets. Replace `-k 0xYourPrivateKey` with `--useLedger`: + +```bash +celocli bridge:deposit \ + --from 0xYourAddress \ + --value 1000000000000000000 \ + --network mainnet \ + --l1RpcUrl https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + --useLedger +``` + +## Common Issues + +**"Bridge commands require --privateKey or --useLedger"** +You must provide a signing method. Add `-k 0xYourKey` or `--useLedger`. + +**"Cannot finalize: The 7-day challenge period has not passed yet"** +The challenge period hasn't elapsed. Run `bridge:withdraw-status` to check progress. + +**"Cannot finalize: The withdrawal has not been proven yet"** +You need to submit the proof first with `bridge:withdraw-prove`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 086e160356..6685e4e615 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -125,6 +125,9 @@ "account": { "description": "Manage your account, keys, and metadata" }, + "bridge": { + "description": "Bridge CELO between Ethereum (L1) and Celo (L2)" + }, "config": { "description": "Configure CLI options which persist across commands" }, diff --git a/packages/cli/src/commands/bridge/deposit.test.ts b/packages/cli/src/commands/bridge/deposit.test.ts new file mode 100644 index 0000000000..556244d547 --- /dev/null +++ b/packages/cli/src/commands/bridge/deposit.test.ts @@ -0,0 +1,141 @@ +import BridgeDeposit from './deposit' + +// Inline testLocally to avoid importing cliUtils which pulls in @celo/dev-utils +async function testLocally(command: any, argv: string[]) { + if (argv.includes('--node')) { + return command.run(argv) + } + const extendedArgv = command.flags?.node ? [...argv, '--node', 'local'] : argv + return command.run(extendedArgv) +} + +process.env.NO_SYNCCHECK = 'true' + +jest.setTimeout(15000) + +describe('bridge:deposit', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'error').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'info').mockImplementation(() => { + // noop + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('requires --from flag', async () => { + await expect( + testLocally(BridgeDeposit, [ + '--value', + '1000000000000000000', + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('requires --value flag', async () => { + await expect( + testLocally(BridgeDeposit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('requires --network flag', async () => { + await expect( + testLocally(BridgeDeposit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--value', + '1000000000000000000', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('requires --l1RpcUrl flag', async () => { + await expect( + testLocally(BridgeDeposit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--value', + '1000000000000000000', + '--network', + 'sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('rejects invalid network value', async () => { + await expect( + testLocally(BridgeDeposit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--value', + '1000000000000000000', + '--network', + 'goerli', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('rejects invalid address format', async () => { + await expect( + testLocally(BridgeDeposit, [ + '--from', + 'not-an-address', + '--value', + '1000000000000000000', + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow('is not a valid address') + }) + + it('requires signing method (privateKey or useLedger)', async () => { + await expect( + testLocally(BridgeDeposit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--value', + '1000000000000000000', + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + ]) + ).rejects.toThrow() + }) +}) diff --git a/packages/cli/src/commands/bridge/deposit.ts b/packages/cli/src/commands/bridge/deposit.ts new file mode 100644 index 0000000000..bba90555a2 --- /dev/null +++ b/packages/cli/src/commands/bridge/deposit.ts @@ -0,0 +1,168 @@ +import { Flags, ux } from '@oclif/core' +import { createPublicClient, createWalletClient, http, isAddressEqual } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { BaseCommand } from '../../base' +import { printValueMap } from '../../utils/cli' +import { CustomFlags } from '../../utils/command' +import { + BRIDGE_CONFIG, + SYSTEM_CONFIG_ABI, + ERC20_APPROVE_ABI, + OPTIMISM_PORTAL_DEPOSIT_ABI, + validateNetwork, + verifyL1ChainId, + type BridgeNetwork, +} from '../../utils/bridge' + +export default class BridgeDeposit extends BaseCommand { + static description = + 'Deposit CELO from Ethereum (L1) to Celo (L2). This bridges your CELO tokens to the Celo L2 network.' + + static examples = [ + 'bridge:deposit --from 0xYOUR_ADDRESS --to 0xRECIPIENT --value 1000000000000000000 --network mainnet --l1RpcUrl https://eth-mainnet.example.com -k 0xPRIVATE_KEY', + 'bridge:deposit --from 0xYOUR_ADDRESS --value 1000000000000000000 --network sepolia --l1RpcUrl https://eth-sepolia.example.com -k 0xPRIVATE_KEY', + ] + + static flags = { + ...BaseCommand.flags, + from: CustomFlags.address({ + required: true, + description: 'Address of the sender on L1', + }), + to: CustomFlags.address({ + description: 'Address of the recipient on L2 (defaults to sender address)', + }), + value: CustomFlags.bigint({ + required: true, + description: 'Amount of CELO to deposit (in wei)', + }), + network: Flags.string({ + required: true, + options: ['mainnet', 'sepolia'], + description: 'Network to bridge on (mainnet or sepolia)', + }), + l1RpcUrl: Flags.string({ + required: true, + description: 'RPC URL for the Ethereum L1 network', + }), + gasLimit: Flags.integer({ + description: 'Gas limit for the L2 transaction', + default: 100000, + }), + } + + requireSynced = false + + async init() { + // noop - skip ContractKit initialization, we use L1 directly + } + + async run() { + const res = await this.parse(BridgeDeposit) + const { from, value, network: networkFlag, l1RpcUrl, gasLimit } = res.flags + const to = res.flags.to || from + const network = validateNetwork(networkFlag) + const config = BRIDGE_CONFIG[network] + + // We need wallet client for L1 - derive from BaseCommand's signing config + const wallet = await this.getL1WalletClient(res, l1RpcUrl, network) + const l1Client = createPublicClient({ + chain: config.l1Chain, + transport: http(l1RpcUrl), + }) + + await verifyL1ChainId(l1Client, network) + + // Step 1: Retrieve gas paying token (CELO address on L1) + ux.action.start('Step 1/3: Retrieving CELO token address on L1') + const [celoL1Address] = await l1Client.readContract({ + address: config.systemConfig, + abi: SYSTEM_CONFIG_ABI, + functionName: 'gasPayingToken', + }) + ux.action.stop() + printValueMap({ 'CELO token on L1': celoL1Address }) + + // Step 2: Approve OptimismPortal to spend CELO + ux.action.start('Step 2/3: Approving CELO spending on L1') + // Type assertion needed: wallet client is created dynamically so TS can't infer chain/account + const approveHash = await wallet.writeContract({ + address: celoL1Address, + abi: ERC20_APPROVE_ABI, + functionName: 'approve', + args: [config.optimismPortal, value], + chain: config.l1Chain, + account: wallet.account!, + } as any) + const approveReceipt = await l1Client.waitForTransactionReceipt({ hash: approveHash }) + ux.action.stop() + printValueMap({ 'Approval txHash': approveReceipt.transactionHash }) + + if (approveReceipt.status !== 'success') { + throw new Error( + 'Approval transaction failed. Please check the transaction on a block explorer.' + ) + } + + // Step 3: Deposit CELO to L2 + ux.action.start('Step 3/3: Depositing CELO to L2') + const depositHash = await wallet.writeContract({ + address: config.optimismPortal, + abi: OPTIMISM_PORTAL_DEPOSIT_ABI, + functionName: 'depositERC20Transaction', + // Type assertion needed: wallet client is created dynamically so TS can't infer chain/account + args: [to, value, value, BigInt(gasLimit), false, '0x'], + chain: config.l1Chain, + account: wallet.account!, + } as any) + const depositReceipt = await l1Client.waitForTransactionReceipt({ hash: depositHash }) + ux.action.stop() + printValueMap({ + 'Deposit txHash': depositReceipt.transactionHash, + status: depositReceipt.status === 'success' ? 'Success' : 'Failed', + }) + + if (depositReceipt.status === 'success') { + console.log('\nDeposit initiated! Your CELO should appear on L2 in approximately 15 minutes.') + } else { + throw new Error( + 'Deposit transaction failed. Please check the transaction on a block explorer.' + ) + } + } + + // Create an L1 wallet client using the same signing mechanism as BaseCommand + private async getL1WalletClient(res: any, l1RpcUrl: string, network: BridgeNetwork) { + const config = BRIDGE_CONFIG[network] + + if (res.flags.useLedger) { + const ledgerOptions = await this.ledgerOptions() + const { ledgerToWalletClient } = await import('@celo/viem-account-ledger') + return ledgerToWalletClient({ + ...ledgerOptions, + account: res.flags.from, + walletClientOptions: { + transport: http(l1RpcUrl), + chain: config.l1Chain, + }, + }) + } else if (res.flags.privateKey) { + const { ensureLeading0x } = await import('@celo/base') + const account = privateKeyToAccount(ensureLeading0x(res.flags.privateKey)) + if (res.flags.from && !isAddressEqual(res.flags.from, account.address)) { + throw new Error( + `The --from address ${res.flags.from} does not match the address derived from the provided private key ${account.address}.` + ) + } + return createWalletClient({ + account, + chain: config.l1Chain, + transport: http(l1RpcUrl), + }) + } + + throw new Error( + 'Bridge commands require --privateKey or --useLedger for signing L1 transactions' + ) + } +} diff --git a/packages/cli/src/commands/bridge/withdraw-finalize.ts b/packages/cli/src/commands/bridge/withdraw-finalize.ts new file mode 100644 index 0000000000..90506e77a9 --- /dev/null +++ b/packages/cli/src/commands/bridge/withdraw-finalize.ts @@ -0,0 +1,163 @@ +import { ensureLeading0x } from '@celo/base' +import { Flags, ux } from '@oclif/core' +import { createPublicClient, createWalletClient, http, isAddressEqual } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { getWithdrawals, publicActionsL2, walletActionsL1 } from 'viem/op-stack' +import { BaseCommand } from '../../base' +import { printValueMap } from '../../utils/cli' +import { CustomFlags } from '../../utils/command' +import { + BRIDGE_CONFIG, + validateNetwork, + getL2OpChain, + createL1PublicClient, + verifyL1ChainId, + verifyL2ChainId, + type BridgeNetwork, +} from '../../utils/bridge' + +export default class BridgeWithdrawFinalize extends BaseCommand { + static description = + 'Finalize a withdrawal and claim your CELO on Ethereum (L1). This is the final step of the withdrawal process.\n\nCan only be run after the 7-day challenge period has passed. Use bridge:withdraw-status to check if your withdrawal is ready.' + + static examples = [ + 'bridge:withdraw-finalize --txHash 0xYOUR_L2_TX_HASH --from 0xYOUR_ADDRESS --network mainnet --l1RpcUrl https://eth-mainnet.example.com -n mainnet -k 0xPRIVATE_KEY', + ] + + static flags = { + ...BaseCommand.flags, + txHash: CustomFlags.hexString({ + required: true, + description: 'Transaction hash of the withdrawal initiation on L2', + }), + from: CustomFlags.address({ + required: true, + description: 'Address that will finalize the withdrawal on L1 (pays L1 gas)', + }), + network: Flags.string({ + required: true, + options: ['mainnet', 'sepolia'], + description: 'Network (mainnet or sepolia)', + }), + l1RpcUrl: Flags.string({ + required: true, + description: 'RPC URL for the Ethereum L1 network', + }), + } + + requireSynced = false + + async init() { + // noop — skip ContractKit + } + + async run() { + const res = await this.parse(BridgeWithdrawFinalize) + const { txHash, network: networkFlag, l1RpcUrl } = res.flags + const network = validateNetwork(networkFlag) + const l2Chain = getL2OpChain(network) + + // Create L2 client with OP Stack extensions + const l2NodeUrl = await this.getNodeUrl() + const l2Client = createPublicClient({ + chain: l2Chain, + transport: http(l2NodeUrl), + }).extend(publicActionsL2()) + + await verifyL2ChainId(l2Client, network) + + // Create L1 clients + const l1Client = createL1PublicClient(l1RpcUrl, network) + + await verifyL1ChainId(l1Client, network) + + const l1Wallet = await this.getL1WalletClient(res, l1RpcUrl, network) + + // Step 1: Get the withdrawal receipt + ux.action.start('Step 1/3: Fetching withdrawal transaction receipt') + const receipt = await l2Client.getTransactionReceipt({ hash: txHash as `0x${string}` }) + ux.action.stop() + + // Step 2: Check status + ux.action.start('Step 2/3: Verifying withdrawal is ready to finalize') + const status = await l1Client.getWithdrawalStatus({ + receipt, + targetChain: l2Chain as any, + }) + ux.action.stop() + + if (status !== 'ready-to-finalize') { + const statusMessages: Record = { + 'waiting-to-prove': + 'The withdrawal has not been proven yet. Run bridge:withdraw-prove first.', + 'ready-to-prove': 'The withdrawal needs to be proven first. Run bridge:withdraw-prove.', + 'waiting-to-finalize': + 'The 7-day challenge period has not passed yet. Please wait and try again later.', + finalized: 'This withdrawal has already been finalized.', + } + const msg = statusMessages[status] || `Unexpected status: ${status}` + throw new Error(`Cannot finalize: ${msg}`) + } + + // Step 3: Finalize the withdrawal on L1 + ux.action.start('Step 3/3: Finalizing withdrawal on L1') + const [withdrawal] = getWithdrawals(receipt) + const finalizeHash = await l1Wallet.finalizeWithdrawal({ + targetChain: l2Chain as any, + withdrawal, + }) + + const finalizeReceipt = await createPublicClient({ + chain: BRIDGE_CONFIG[network].l1Chain, + transport: http(l1RpcUrl), + }).waitForTransactionReceipt({ hash: finalizeHash }) + ux.action.stop() + + printValueMap({ + 'Finalize txHash': finalizeReceipt.transactionHash, + status: finalizeReceipt.status === 'success' ? 'Success' : 'Failed', + }) + + if (finalizeReceipt.status === 'success') { + console.log('\nWithdrawal finalized! Your CELO has been sent to your L1 address.') + } else { + throw new Error( + 'Finalize transaction failed. Please check the transaction on a block explorer.' + ) + } + } + + private async getL1WalletClient(res: any, l1RpcUrl: string, network: BridgeNetwork) { + const config = BRIDGE_CONFIG[network] + + if (res.flags.useLedger) { + const ledgerOptions = await this.ledgerOptions() + const { ledgerToWalletClient } = await import('@celo/viem-account-ledger') + const wallet = await ledgerToWalletClient({ + ...ledgerOptions, + account: res.flags.from, + walletClientOptions: { + transport: http(l1RpcUrl), + chain: config.l1Chain, + }, + }) + return wallet.extend(walletActionsL1()) + } else if (res.flags.privateKey) { + const account = privateKeyToAccount(ensureLeading0x(res.flags.privateKey)) + if (res.flags.from && !isAddressEqual(res.flags.from, account.address)) { + throw new Error( + `The --from address ${res.flags.from} does not match the address derived from the provided private key ${account.address}.` + ) + } + return createWalletClient({ + account, + chain: config.l1Chain, + transport: http(l1RpcUrl), + }).extend(walletActionsL1()) + } + + throw new Error( + 'Bridge commands require --privateKey or --useLedger for signing L1 transactions' + ) + } +} diff --git a/packages/cli/src/commands/bridge/withdraw-init.ts b/packages/cli/src/commands/bridge/withdraw-init.ts new file mode 100644 index 0000000000..b50424ea15 --- /dev/null +++ b/packages/cli/src/commands/bridge/withdraw-init.ts @@ -0,0 +1,98 @@ +import { Flags } from '@oclif/core' +import { BaseCommand } from '../../base' +import { printValueMap } from '../../utils/cli' +import { CustomFlags } from '../../utils/command' +import { newCheckBuilder } from '../../utils/checks' +import { + BRIDGE_CONFIG, + L2_L1_MESSAGE_PASSER_ABI, + validateNetwork, + verifyL2ChainId, +} from '../../utils/bridge' + +export default class BridgeWithdrawInit extends BaseCommand { + static description = + 'Initiate a withdrawal of CELO from Celo (L2) to Ethereum (L1). This is step 1 of the withdrawal process.\n\nAfter initiating, you will need to:\n1. Wait ~1 hour for the proof to become available\n2. Run bridge:withdraw-prove to submit the proof\n3. Wait 7 days for the challenge period\n4. Run bridge:withdraw-finalize to claim your funds on L1' + + static examples = [ + 'bridge:withdraw-init --from 0xYOUR_L2_ADDRESS --to 0xL1_RECIPIENT --value 1000000000000000000 --network mainnet -n mainnet -k 0xPRIVATE_KEY', + 'bridge:withdraw-init --from 0xYOUR_L2_ADDRESS --value 1000000000000000000 --network sepolia -n celo-sepolia -k 0xPRIVATE_KEY', + ] + + static flags = { + ...BaseCommand.flags, + from: CustomFlags.address({ + required: true, + description: 'Address of the sender on L2', + }), + to: CustomFlags.address({ + description: 'Address of the recipient on L1 (defaults to sender address)', + }), + value: CustomFlags.bigint({ + required: true, + description: 'Amount of CELO to withdraw (in wei)', + }), + network: Flags.string({ + required: true, + options: ['mainnet', 'sepolia'], + description: 'Network to bridge on (mainnet or sepolia)', + }), + } + + async init() { + // noop — skip ContractKit, we use viem directly + } + + async run() { + const client = await this.getPublicClient() + const wallet = await this.getWalletClient() + const res = await this.parse(BridgeWithdrawInit) + + const { from, value, network: networkFlag } = res.flags + const to = res.flags.to || from + const network = validateNetwork(networkFlag) + const config = BRIDGE_CONFIG[network] + + await verifyL2ChainId(client, network) + + await newCheckBuilder(this) + .isNotSanctioned(from) + .isNotSanctioned(to) + .isValidWalletSigner(from) + .hasEnoughCelo(from, value) + .runChecks() + + console.log('\nInitiating withdrawal from L2 to L1...') + console.log('This sends your CELO to the L2→L1 message bridge.\n') + + const hash = wallet.writeContract({ + address: config.l2L1MessagePasser, + abi: L2_L1_MESSAGE_PASSER_ABI, + functionName: 'initiateWithdrawal', + args: [to, BigInt(0), '0x'], + value: value, + chain: client.chain, + account: wallet.account!, + } as any) as Promise<`0x${string}`> + + const receipt = await client.waitForTransactionReceipt({ hash: await hash }) + printValueMap({ txHash: receipt.transactionHash }) + + if (receipt.status === 'success') { + console.log('\nWithdrawal initiated! Save the transaction hash above.') + console.log('Next steps:') + console.log(' 1. Wait ~1 hour for the proof to become available') + console.log( + ' 2. Run: celocli bridge:withdraw-prove --txHash ' + receipt.transactionHash + ' ...' + ) + console.log(' 3. Wait 7 days for the challenge period to pass') + console.log( + ' 4. Run: celocli bridge:withdraw-finalize --txHash ' + receipt.transactionHash + ' ...' + ) + } else { + throw new Error( + 'Withdrawal initiation failed. Please check the transaction on a block explorer.' + ) + } + } +} diff --git a/packages/cli/src/commands/bridge/withdraw-prove.ts b/packages/cli/src/commands/bridge/withdraw-prove.ts new file mode 100644 index 0000000000..236b8f549b --- /dev/null +++ b/packages/cli/src/commands/bridge/withdraw-prove.ts @@ -0,0 +1,182 @@ +import { ensureLeading0x } from '@celo/base' +import { Flags, ux } from '@oclif/core' +import { createPublicClient, createWalletClient, http, isAddressEqual } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { publicActionsL2, walletActionsL1 } from 'viem/op-stack' +import { BaseCommand } from '../../base' +import { printValueMap } from '../../utils/cli' +import { CustomFlags } from '../../utils/command' +import { + BRIDGE_CONFIG, + validateNetwork, + getL2OpChain, + createL1PublicClient, + verifyL1ChainId, + verifyL2ChainId, + type BridgeNetwork, +} from '../../utils/bridge' + +export default class BridgeWithdrawProve extends BaseCommand { + static description = + 'Build a withdrawal proof and submit it to Ethereum (L1). This is step 2 of the withdrawal process.\n\nThis command will wait until the proof is available (~1 hour after withdrawal initiation), then automatically build and submit it.' + + static examples = [ + 'bridge:withdraw-prove --txHash 0xYOUR_L2_TX_HASH --from 0xYOUR_ADDRESS --network mainnet --l1RpcUrl https://eth-mainnet.example.com -n mainnet -k 0xPRIVATE_KEY', + ] + + static flags = { + ...BaseCommand.flags, + txHash: CustomFlags.hexString({ + required: true, + description: 'Transaction hash of the withdrawal initiation on L2', + }), + from: CustomFlags.address({ + required: true, + description: 'Address that will submit the proof on L1 (pays L1 gas)', + }), + network: Flags.string({ + required: true, + options: ['mainnet', 'sepolia'], + description: 'Network to bridge on (mainnet or sepolia)', + }), + l1RpcUrl: Flags.string({ + required: true, + description: 'RPC URL for the Ethereum L1 network', + }), + } + + requireSynced = false + + async init() { + // noop — skip ContractKit + } + + async run() { + const res = await this.parse(BridgeWithdrawProve) + const { txHash, network: networkFlag, l1RpcUrl } = res.flags + const network = validateNetwork(networkFlag) + const l2Chain = getL2OpChain(network) + + // Create L2 client with OP Stack extensions + const l2NodeUrl = await this.getNodeUrl() + const l2Client = createPublicClient({ + chain: l2Chain, + transport: http(l2NodeUrl), + }).extend(publicActionsL2()) + + await verifyL2ChainId(l2Client, network) + + // Create L1 public client with OP Stack extensions + const l1Client = createL1PublicClient(l1RpcUrl, network) + + await verifyL1ChainId(l1Client, network) + + // Create L1 wallet client for submitting the prove tx + const l1Wallet = await this.getL1WalletClient(res, l1RpcUrl, network) + + // Step 1: Get the withdrawal receipt + ux.action.start('Step 1/4: Fetching withdrawal transaction receipt') + const receipt = await l2Client.getTransactionReceipt({ hash: txHash as `0x${string}` }) + ux.action.stop() + printValueMap({ + blockNumber: receipt.blockNumber.toString(), + status: receipt.status, + }) + + // Step 2: Check if already proven + ux.action.start('Step 2/5: Checking withdrawal status') + const status = await l1Client.getWithdrawalStatus({ + receipt, + targetChain: l2Chain as any, + }) + ux.action.stop() + + if (status !== 'ready-to-prove' && status !== 'waiting-to-prove') { + const messages: Record = { + 'waiting-to-finalize': + 'This withdrawal has already been proven. Wait for the 7-day challenge period to pass, then run bridge:withdraw-finalize.', + 'ready-to-finalize': + 'This withdrawal has already been proven and is ready to finalize. Run bridge:withdraw-finalize.', + finalized: 'This withdrawal has already been finalized.', + } + throw new Error(messages[status] || `Unexpected withdrawal status: ${status}`) + } + + // Step 3: Wait for proof readiness + ux.action.start('Step 3/5: Waiting for proof to become available (this may take up to 1 hour)') + const { output, withdrawal } = await l1Client.waitToProve({ + receipt, + targetChain: l2Chain as any, + }) + ux.action.stop() + console.log('Proof is ready!') + + // Step 4: Build the proof + ux.action.start('Step 4/5: Building withdrawal proof') + const proveArgs = await l2Client.buildProveWithdrawal({ + output, + withdrawal, + }) + ux.action.stop() + console.log('Proof built successfully.') + + // Step 5: Submit the prove transaction on L1 + ux.action.start('Step 5/5: Submitting proof to L1') + const proveHash = await l1Wallet.proveWithdrawal(proveArgs) + const proveReceipt = await createPublicClient({ + chain: BRIDGE_CONFIG[network].l1Chain, + transport: http(l1RpcUrl), + }).waitForTransactionReceipt({ hash: proveHash }) + ux.action.stop() + + printValueMap({ + 'Prove txHash': proveReceipt.transactionHash, + status: proveReceipt.status === 'success' ? 'Success' : 'Failed', + }) + + if (proveReceipt.status === 'success') { + console.log('\nWithdrawal proof submitted! Next steps:') + console.log(' 1. Wait 7 days for the challenge period to pass') + console.log(' 2. Run: celocli bridge:withdraw-status --txHash ' + txHash + ' ...') + console.log( + ' 3. When ready, run: celocli bridge:withdraw-finalize --txHash ' + txHash + ' ...' + ) + } else { + throw new Error('Prove transaction failed. Please check the transaction on a block explorer.') + } + } + + private async getL1WalletClient(res: any, l1RpcUrl: string, network: BridgeNetwork) { + const config = BRIDGE_CONFIG[network] + + if (res.flags.useLedger) { + const ledgerOptions = await this.ledgerOptions() + const { ledgerToWalletClient } = await import('@celo/viem-account-ledger') + const wallet = await ledgerToWalletClient({ + ...ledgerOptions, + account: res.flags.from, + walletClientOptions: { + transport: http(l1RpcUrl), + chain: config.l1Chain, + }, + }) + return wallet.extend(walletActionsL1()) + } else if (res.flags.privateKey) { + const account = privateKeyToAccount(ensureLeading0x(res.flags.privateKey)) + if (res.flags.from && !isAddressEqual(res.flags.from, account.address)) { + throw new Error( + `The --from address ${res.flags.from} does not match the address derived from the provided private key ${account.address}.` + ) + } + return createWalletClient({ + account, + chain: config.l1Chain, + transport: http(l1RpcUrl), + }).extend(walletActionsL1()) + } + + throw new Error( + 'Bridge commands require --privateKey or --useLedger for signing L1 transactions' + ) + } +} diff --git a/packages/cli/src/commands/bridge/withdraw-status.ts b/packages/cli/src/commands/bridge/withdraw-status.ts new file mode 100644 index 0000000000..2dcba6478a --- /dev/null +++ b/packages/cli/src/commands/bridge/withdraw-status.ts @@ -0,0 +1,136 @@ +import { Flags, ux } from '@oclif/core' +import { createPublicClient, http } from 'viem' +import { publicActionsL2 } from 'viem/op-stack' +import { BaseCommand } from '../../base' +import { printValueMap } from '../../utils/cli' +import { CustomFlags } from '../../utils/command' +import { + WITHDRAWAL_STATUS_LABELS, + validateNetwork, + getL2OpChain, + createL1PublicClient, + verifyL1ChainId, + verifyL2ChainId, +} from '../../utils/bridge' + +export default class BridgeWithdrawStatus extends BaseCommand { + static description = + 'Check the status of a CELO withdrawal from Celo (L2) to Ethereum (L1).\n\nProvide the L2 transaction hash from the initial bridge:withdraw-init command to see where your withdrawal stands in the process.' + + static examples = [ + 'bridge:withdraw-status --txHash 0xYOUR_L2_TX_HASH --network mainnet --l1RpcUrl https://eth-mainnet.example.com -n mainnet', + 'bridge:withdraw-status --txHash 0xYOUR_L2_TX_HASH --network sepolia --l1RpcUrl https://eth-sepolia.example.com -n celo-sepolia', + ] + + static flags = { + ...BaseCommand.flags, + txHash: CustomFlags.hexString({ + required: true, + description: 'Transaction hash of the withdrawal initiation on L2', + }), + network: Flags.string({ + required: true, + options: ['mainnet', 'sepolia'], + description: 'Network (mainnet or sepolia)', + }), + l1RpcUrl: Flags.string({ + required: true, + description: 'RPC URL for the Ethereum L1 network', + }), + } + + requireSynced = false + + // Read-only command + isOnlyReadingWallet = true + + async init() { + // noop — skip ContractKit + } + + async run() { + const res = await this.parse(BridgeWithdrawStatus) + const { txHash, network: networkFlag, l1RpcUrl } = res.flags + const network = validateNetwork(networkFlag) + const l2Chain = getL2OpChain(network) + + // Create L2 client with OP Stack extensions + const l2NodeUrl = await this.getNodeUrl() + const l2Client = createPublicClient({ + chain: l2Chain, + transport: http(l2NodeUrl), + }).extend(publicActionsL2()) + + await verifyL2ChainId(l2Client, network) + + // Create L1 client with OP Stack extensions + const l1Client = createL1PublicClient(l1RpcUrl, network) + + await verifyL1ChainId(l1Client, network) + + // Get the receipt + ux.action.start('Fetching withdrawal transaction') + const receipt = await l2Client.getTransactionReceipt({ hash: txHash as `0x${string}` }) + ux.action.stop() + + if (receipt.status !== 'success') { + console.log('\nThe withdrawal transaction failed on L2.') + printValueMap({ status: 'Failed', blockNumber: receipt.blockNumber.toString() }) + return + } + + // Get the withdrawal status from L1 + ux.action.start('Checking withdrawal status on L1') + const status = await l1Client.getWithdrawalStatus({ + receipt, + targetChain: l2Chain as any, + }) + ux.action.stop() + + const statusInfo = WITHDRAWAL_STATUS_LABELS[ + status as keyof typeof WITHDRAWAL_STATUS_LABELS + ] || { + label: status, + description: 'Unknown status', + } + + // Display human-readable status + console.log('') + console.log(` Status: ${statusInfo.label}`) + console.log('') + console.log(` ${statusInfo.description}`) + console.log('') + + // Show contextual next steps + printValueMap({ + 'L2 Transaction': txHash, + 'L2 Block': receipt.blockNumber.toString(), + 'Current Status': statusInfo.label, + }) + + console.log('') + switch (status) { + case 'waiting-to-prove': + console.log('What to do next:') + console.log(' Wait for the proof to become available (~1 hour after initiation).') + console.log(' Then run: celocli bridge:withdraw-prove --txHash ' + txHash + ' ...') + break + case 'ready-to-prove': + console.log('What to do next:') + console.log(' Run: celocli bridge:withdraw-prove --txHash ' + txHash + ' ...') + break + case 'waiting-to-finalize': + console.log('What to do next:') + console.log(' Wait for the 7-day challenge period to pass.') + console.log(' You can check again later with this same command.') + break + case 'ready-to-finalize': + console.log('What to do next:') + console.log(' Run: celocli bridge:withdraw-finalize --txHash ' + txHash + ' ...') + break + case 'finalized': + console.log('Your withdrawal is complete! Funds have been sent to L1.') + break + } + } +} diff --git a/packages/cli/src/commands/bridge/withdraw.test.ts b/packages/cli/src/commands/bridge/withdraw.test.ts new file mode 100644 index 0000000000..f016bc235a --- /dev/null +++ b/packages/cli/src/commands/bridge/withdraw.test.ts @@ -0,0 +1,324 @@ +import BridgeWithdrawInit from './withdraw-init' +import BridgeWithdrawProve from './withdraw-prove' +import BridgeWithdrawStatus from './withdraw-status' +import BridgeWithdrawFinalize from './withdraw-finalize' + +// Inline testLocally to avoid importing cliUtils which pulls in @celo/dev-utils +async function testLocally(command: any, argv: string[]) { + if (argv.includes('--node')) { + return command.run(argv) + } + const extendedArgv = command.flags?.node ? [...argv, '--node', 'local'] : argv + return command.run(extendedArgv) +} + +process.env.NO_SYNCCHECK = 'true' + +jest.setTimeout(15000) + +describe('bridge:withdraw-init', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'error').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'info').mockImplementation(() => { + // noop + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('requires --from flag', async () => { + await expect( + testLocally(BridgeWithdrawInit, [ + '--value', + '1000000000000000000', + '--network', + 'sepolia', + '--node', + 'celo-sepolia', + ]) + ).rejects.toThrow() + }) + + it('requires --value flag', async () => { + await expect( + testLocally(BridgeWithdrawInit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--network', + 'sepolia', + '--node', + 'celo-sepolia', + ]) + ).rejects.toThrow() + }) + + it('requires --network flag', async () => { + await expect( + testLocally(BridgeWithdrawInit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--value', + '1000000000000000000', + '--node', + 'celo-sepolia', + ]) + ).rejects.toThrow() + }) + + it('rejects invalid network', async () => { + await expect( + testLocally(BridgeWithdrawInit, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--value', + '1000000000000000000', + '--network', + 'goerli', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('rejects invalid from address', async () => { + await expect( + testLocally(BridgeWithdrawInit, [ + '--from', + 'not-an-address', + '--value', + '1000000000000000000', + '--network', + 'sepolia', + '--node', + 'celo-sepolia', + ]) + ).rejects.toThrow('is not a valid address') + }) +}) + +describe('bridge:withdraw-prove', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'error').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'info').mockImplementation(() => { + // noop + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('requires --txHash flag', async () => { + await expect( + testLocally(BridgeWithdrawProve, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('requires --from flag', async () => { + await expect( + testLocally(BridgeWithdrawProve, [ + '--txHash', + '0x' + 'a'.repeat(64), + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('requires --l1RpcUrl flag', async () => { + await expect( + testLocally(BridgeWithdrawProve, [ + '--txHash', + '0x' + 'a'.repeat(64), + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--network', + 'sepolia', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('rejects invalid txHash format', async () => { + await expect( + testLocally(BridgeWithdrawProve, [ + '--txHash', + 'not-a-hash', + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) +}) + +describe('bridge:withdraw-status', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'error').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'info').mockImplementation(() => { + // noop + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('requires --txHash flag', async () => { + await expect( + testLocally(BridgeWithdrawStatus, [ + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + ]) + ).rejects.toThrow() + }) + + it('requires --l1RpcUrl flag', async () => { + await expect( + testLocally(BridgeWithdrawStatus, [ + '--txHash', + '0x' + 'a'.repeat(64), + '--network', + 'sepolia', + '--node', + 'celo-sepolia', + ]) + ).rejects.toThrow() + }) + + it('rejects invalid network', async () => { + await expect( + testLocally(BridgeWithdrawStatus, [ + '--txHash', + '0x' + 'a'.repeat(64), + '--network', + 'goerli', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + ]) + ).rejects.toThrow() + }) +}) + +describe('bridge:withdraw-finalize', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'error').mockImplementation(() => { + // noop + }) + jest.spyOn(console, 'info').mockImplementation(() => { + // noop + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('requires --txHash flag', async () => { + await expect( + testLocally(BridgeWithdrawFinalize, [ + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('requires --from flag', async () => { + await expect( + testLocally(BridgeWithdrawFinalize, [ + '--txHash', + '0x' + 'a'.repeat(64), + '--network', + 'sepolia', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) + + it('rejects invalid network', async () => { + await expect( + testLocally(BridgeWithdrawFinalize, [ + '--txHash', + '0x' + 'a'.repeat(64), + '--from', + '0x5409ED021D9299bf6814279A6A1411A7e866A631', + '--network', + 'goerli', + '--l1RpcUrl', + 'https://eth-sepolia.example.com', + '--node', + 'celo-sepolia', + '-k', + '0x' + '1'.repeat(64), + ]) + ).rejects.toThrow() + }) +}) diff --git a/packages/cli/src/utils/bridge.test.ts b/packages/cli/src/utils/bridge.test.ts new file mode 100644 index 0000000000..5409cac56f --- /dev/null +++ b/packages/cli/src/utils/bridge.test.ts @@ -0,0 +1,144 @@ +import { + validateNetwork, + BRIDGE_CONFIG, + WITHDRAWAL_STATUS_LABELS, + getL2OpChain, + createL1PublicClient, + verifyL2ChainId, + verifyL1ChainId, +} from './bridge' + +describe('bridge utils', () => { + describe('validateNetwork', () => { + it('accepts mainnet', () => { + expect(validateNetwork('mainnet')).toBe('mainnet') + }) + + it('accepts sepolia', () => { + expect(validateNetwork('sepolia')).toBe('sepolia') + }) + + it('rejects invalid network', () => { + expect(() => validateNetwork('goerli')).toThrow('Invalid network: goerli') + }) + + it('rejects empty string', () => { + expect(() => validateNetwork('')).toThrow('Invalid network') + }) + }) + + describe('BRIDGE_CONFIG', () => { + it('has mainnet config with correct addresses', () => { + const config = BRIDGE_CONFIG.mainnet + expect(config.systemConfig).toBe('0x89E31965D844a309231B1f17759Ccaf1b7c09861') + expect(config.optimismPortal).toBe('0xc5c5D157928BDBD2ACf6d0777626b6C75a9EAEDC') + expect(config.l2L1MessagePasser).toBe('0x4200000000000000000000000000000000000016') + }) + + it('has sepolia config with correct addresses', () => { + const config = BRIDGE_CONFIG.sepolia + expect(config.systemConfig).toBe('0x760a5f022c9940f4a074e0030be682f560d29818') + expect(config.optimismPortal).toBe('0x44ae3d41a335a7d05eb533029917aad35662dcc2') + expect(config.l2L1MessagePasser).toBe('0x4200000000000000000000000000000000000016') + }) + }) + + describe('getL2OpChain', () => { + it('returns Celo mainnet chain for mainnet', () => { + const chain = getL2OpChain('mainnet') + expect(chain.id).toBe(42220) + expect(chain.name).toBe('Celo') + }) + + it('returns Celo Sepolia chain for sepolia', () => { + const chain = getL2OpChain('sepolia') + expect(chain.id).toBe(11142220) + expect(chain.name).toBe('Celo Sepolia Testnet') + }) + }) + + describe('WITHDRAWAL_STATUS_LABELS', () => { + it('has labels for all statuses', () => { + expect(WITHDRAWAL_STATUS_LABELS['waiting-to-prove']).toBeDefined() + expect(WITHDRAWAL_STATUS_LABELS['ready-to-prove']).toBeDefined() + expect(WITHDRAWAL_STATUS_LABELS['waiting-to-finalize']).toBeDefined() + expect(WITHDRAWAL_STATUS_LABELS['ready-to-finalize']).toBeDefined() + expect(WITHDRAWAL_STATUS_LABELS.finalized).toBeDefined() + }) + + it('each status has label and description', () => { + Object.values(WITHDRAWAL_STATUS_LABELS).forEach((status) => { + expect(status.label).toBeTruthy() + expect(status.description).toBeTruthy() + }) + }) + }) + + describe('verifyL2ChainId', () => { + it('passes when chain ID matches mainnet', async () => { + const mockClient = { getChainId: async () => 42220 } + await expect(verifyL2ChainId(mockClient, 'mainnet')).resolves.toBeUndefined() + }) + + it('passes when chain ID matches sepolia', async () => { + const mockClient = { getChainId: async () => 11142220 } + await expect(verifyL2ChainId(mockClient, 'sepolia')).resolves.toBeUndefined() + }) + + it('throws when chain ID mismatches', async () => { + const mockClient = { getChainId: async () => 42220 } + await expect(verifyL2ChainId(mockClient, 'sepolia')).rejects.toThrow( + 'L2 node chain ID mismatch' + ) + }) + + it('includes expected and actual chain IDs in error', async () => { + const mockClient = { getChainId: async () => 99999 } + await expect(verifyL2ChainId(mockClient, 'mainnet')).rejects.toThrow( + 'expects chain 42220 but --node is connected to chain 99999' + ) + }) + }) + + describe('verifyL1ChainId', () => { + it('passes when chain ID matches mainnet (Ethereum)', async () => { + const mockClient = { getChainId: async () => 1 } + await expect(verifyL1ChainId(mockClient, 'mainnet')).resolves.toBeUndefined() + }) + + it('passes when chain ID matches sepolia', async () => { + const mockClient = { getChainId: async () => 11155111 } + await expect(verifyL1ChainId(mockClient, 'sepolia')).resolves.toBeUndefined() + }) + + it('throws when chain ID mismatches', async () => { + const mockClient = { getChainId: async () => 11155111 } + await expect(verifyL1ChainId(mockClient, 'mainnet')).rejects.toThrow( + 'L1 RPC chain ID mismatch' + ) + }) + + it('includes expected and actual chain IDs in error', async () => { + const mockClient = { getChainId: async () => 5 } + await expect(verifyL1ChainId(mockClient, 'sepolia')).rejects.toThrow( + 'expects chain 11155111 but --l1RpcUrl is connected to chain 5' + ) + }) + }) + + describe('createL1PublicClient', () => { + it('creates a client with OP Stack L1 actions for mainnet', () => { + const client = createL1PublicClient('https://eth.example.com', 'mainnet') + expect(client).toBeDefined() + expect(typeof client.getWithdrawalStatus).toBe('function') + expect(typeof client.waitToProve).toBe('function') + }) + + it('creates a client with OP Stack L1 actions for sepolia', () => { + const client = createL1PublicClient('https://eth-sepolia.example.com', 'sepolia') + expect(client).toBeDefined() + expect(typeof client.getWithdrawalStatus).toBe('function') + expect(typeof client.waitToProve).toBe('function') + }) + }) +}) diff --git a/packages/cli/src/utils/bridge.ts b/packages/cli/src/utils/bridge.ts new file mode 100644 index 0000000000..3062a3bfd0 --- /dev/null +++ b/packages/cli/src/utils/bridge.ts @@ -0,0 +1,240 @@ +import { StrongAddress } from '@celo/base' +import { createPublicClient, http, type Chain } from 'viem' +import { mainnet, sepolia } from 'viem/chains' +import { chainConfig, publicActionsL1 } from 'viem/op-stack' +import { defineChain } from 'viem/utils' + +export type BridgeNetwork = 'mainnet' | 'sepolia' + +export interface BridgeConfig { + l1Chain: Chain + systemConfig: StrongAddress + optimismPortal: StrongAddress + l2L1MessagePasser: StrongAddress +} + +// Contract addresses per network — sourced from op-tooling deposit.sh / withdrawal scripts +export const BRIDGE_CONFIG: Record = { + mainnet: { + l1Chain: mainnet, + systemConfig: '0x89E31965D844a309231B1f17759Ccaf1b7c09861', + optimismPortal: '0xc5c5D157928BDBD2ACf6d0777626b6C75a9EAEDC', + l2L1MessagePasser: '0x4200000000000000000000000000000000000016', + }, + sepolia: { + l1Chain: sepolia, + systemConfig: '0x760a5f022c9940f4a074e0030be682f560d29818', + optimismPortal: '0x44ae3d41a335a7d05eb533029917aad35662dcc2', + l2L1MessagePasser: '0x4200000000000000000000000000000000000016', + }, +} + +// Celo Mainnet OP Stack chain definition for viem +// Standard viem `celo` chain lacks OP Stack contract addresses needed for +// withdrawal proving/finalization. This definition adds them. +const celoL2 = /*#__PURE__*/ defineChain({ + ...chainConfig, + id: 42_220, + name: 'Celo', + nativeCurrency: { decimals: 18, name: 'CELO', symbol: 'CELO' }, + rpcUrls: { default: { http: ['https://forno.celo.org'] } }, + blockExplorers: { + default: { + name: 'Celo Explorer', + url: 'https://celoscan.io', + apiUrl: 'https://api.celoscan.io/api', + }, + }, + contracts: { + ...chainConfig.contracts, + multicall3: { + address: '0xcA11bde05977b3631167028862bE2a173976CA11', + blockCreated: 13112599, + }, + portal: { + 1: { address: '0xc5c5D157928BDBD2ACf6d0777626b6C75a9EAEDC' }, + }, + disputeGameFactory: { + 1: { address: '0xFbAC162162f4009Bb007C6DeBC36B1dAC10aF683' }, + }, + l1StandardBridge: { + 1: { address: '0x9C4955b92F34148dbcfDCD82e9c9eCe5CF2badfe' }, + }, + }, + sourceId: 1, + testnet: false, +}) + +// Celo Sepolia OP Stack chain definition for viem +const celoSepoliaL2 = /*#__PURE__*/ defineChain({ + ...chainConfig, + id: 11_142_220, + name: 'Celo Sepolia Testnet', + nativeCurrency: { decimals: 18, name: 'CELO', symbol: 'S-CELO' }, + rpcUrls: { default: { http: ['https://forno.celo-sepolia.celo-testnet.org'] } }, + blockExplorers: { + default: { + name: 'Celo Sepolia Explorer', + url: 'https://celo-sepolia.blockscout.com', + apiUrl: 'https://celo-sepolia.blockscout.com/api', + }, + }, + contracts: { + ...chainConfig.contracts, + multicall3: { + address: '0xcA11bde05977b3631167028862bE2a173976CA11', + blockCreated: 1, + }, + portal: { + 11155111: { address: '0x44ae3d41a335a7d05eb533029917aad35662dcc2', blockCreated: 8825790 }, + }, + disputeGameFactory: { + 11155111: { address: '0x57c45d82d1a995f1e135b8d7edc0a6bb5211cfaa', blockCreated: 8825790 }, + }, + l1StandardBridge: { + 11155111: { address: '0xec18a3c30131a0db4246e785355fbc16e2eaf408', blockCreated: 8825790 }, + }, + }, + sourceId: 11155111, + testnet: true, +}) + +export function validateNetwork(network: string): BridgeNetwork { + if (network === 'mainnet' || network === 'sepolia') { + return network + } + throw new Error(`Invalid network: ${network}. Must be 'mainnet' or 'sepolia'.`) +} + +export function getL2OpChain(network: BridgeNetwork) { + return network === 'mainnet' ? celoL2 : celoSepoliaL2 +} + +export async function verifyL2ChainId( + client: { getChainId: () => Promise }, + network: BridgeNetwork +) { + const expectedChainId = getL2OpChain(network).id + const actualChainId = await client.getChainId() + if (actualChainId !== expectedChainId) { + throw new Error( + `L2 node chain ID mismatch: --network ${network} expects chain ${expectedChainId} but --node is connected to chain ${actualChainId}. Ensure --node points to the correct L2 network.` + ) + } +} + +export async function verifyL1ChainId( + client: { getChainId: () => Promise }, + network: BridgeNetwork +) { + const expectedChainId = BRIDGE_CONFIG[network].l1Chain.id + const actualChainId = await client.getChainId() + if (actualChainId !== expectedChainId) { + throw new Error( + `L1 RPC chain ID mismatch: --network ${network} expects chain ${expectedChainId} but --l1RpcUrl is connected to chain ${actualChainId}. Ensure --l1RpcUrl points to the correct L1 network.` + ) + } +} + +export function createL1PublicClient(l1RpcUrl: string, network: BridgeNetwork): any { + const config = BRIDGE_CONFIG[network] + return createPublicClient({ + chain: config.l1Chain, + transport: http(l1RpcUrl), + }).extend(publicActionsL1()) +} + +// ABI fragments + +export const SYSTEM_CONFIG_ABI = [ + { + name: 'gasPayingToken', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [ + { name: 'addr_', type: 'address' }, + { name: 'decimals_', type: 'uint8' }, + ], + }, +] as const + +export const ERC20_APPROVE_ABI = [ + { + name: 'approve', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, +] as const + +export const OPTIMISM_PORTAL_DEPOSIT_ABI = [ + { + name: 'depositERC20Transaction', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: '_to', type: 'address' }, + { name: '_mint', type: 'uint256' }, + { name: '_value', type: 'uint256' }, + { name: '_gasLimit', type: 'uint64' }, + { name: '_isCreation', type: 'bool' }, + { name: '_data', type: 'bytes' }, + ], + outputs: [], + }, +] as const + +export const L2_L1_MESSAGE_PASSER_ABI = [ + { + name: 'initiateWithdrawal', + type: 'function', + stateMutability: 'payable', + inputs: [ + { name: '_target', type: 'address' }, + { name: '_gasLimit', type: 'uint256' }, + { name: '_data', type: 'bytes' }, + ], + outputs: [], + }, +] as const + +// Human-readable withdrawal status labels +export type WithdrawalStatus = + | 'waiting-to-prove' + | 'ready-to-prove' + | 'waiting-to-finalize' + | 'ready-to-finalize' + | 'finalized' + +export const WITHDRAWAL_STATUS_LABELS: Record< + WithdrawalStatus, + { label: string; description: string } +> = { + 'waiting-to-prove': { + label: 'Waiting to Prove', + description: + 'Your withdrawal has been initiated on L2. Wait ~1 hour for the proof to become available.', + }, + 'ready-to-prove': { + label: 'Ready to Prove', + description: 'The proof is available. You can now submit it on L1 with bridge:withdraw-prove.', + }, + 'waiting-to-finalize': { + label: 'Waiting to Finalize', + description: 'The proof has been submitted. The 7-day challenge period is in progress.', + }, + 'ready-to-finalize': { + label: 'Ready to Finalize', + description: + 'The challenge period has passed. Claim your funds on L1 with bridge:withdraw-finalize.', + }, + finalized: { + label: 'Finalized', + description: 'The withdrawal is complete. Funds have been sent to your L1 address.', + }, +}