Bridge L1↔L2

Bridge L1 ↔ L2

Ruby Chain usa OP Stack standard bridge contracts para mover assets entre Sepolia (L1) y Ruby Chain (L2).

Modelo CGT importante de entender

Ruby Chain usa Custom Gas Token mode. Eso quiere decir:

  • El L2 native gas (RUBY) es un sistema cerrado controlado por el LiquidityController predeploy. No se bridgea desde L1 vía el portal estándar.
  • Para obtener RUBY native en L2, andá al faucet (opens in a new tab). En mainnet habrá un mecanismo de mint controlado por Elektrum.
  • El bridge L1↔L2 sirve para ERC-20s (no para RUBY native). Movés un ERC-20 de Sepolia a su representación wrapped en L2, y vice-versa.

Si en el futuro queremos bridgear RUBY native, hay que extender el LiquidityController para aceptar mensajes cross-chain. No está todavía.

Deposit ERC-20 (Sepolia → Ruby Chain)

  1. Approvál: tu wallet aprueba al L1StandardBridge (0xddf0b794...) para gastar X de tu ERC-20.
  2. Deposit: llamás L1StandardBridge.depositERC20To(l1Token, l2Token, recipient, amount, gasLimit, extraData).
  3. Espera: ~2-3 min para que el sequencer lo derive del L1.
  4. Resultado: el recipient en L2 recibe el wrapped ERC-20 al address correspondiente.

El L2 token address — auto-generado

Si el ERC-20 nunca se bridgeó antes, debe deployarse primero vía OptimismMintableERC20Factory (predeploy en L2). El address es deterministic:

import { ethers } from "ethers";
const l2Factory = new ethers.Contract(
  "0x4200000000000000000000000000000000000012",
  ["function calculateERC20BridgeAddress(address _l1Token, string _name, string _symbol, uint8 _decimals) view returns (address)"],
  provider
);
const l2TokenAddress = await l2Factory.calculateERC20BridgeAddress(
  l1Token, "Wrapped X", "wX", 18
);

Si no existe todavía: llamá l2Factory.createOptimismMintableERC20WithDecimals(l1Token, name, symbol, decimals) antes del deposit.

Ejemplo end-to-end

const L1_BRIDGE = "0xddf0b794c028c8fcc354336e851cbc1a68ad346e";
const TOKEN_L1 = "0x..."; // tu ERC-20 en Sepolia
const TOKEN_L2 = "0x..."; // computado vía factory
const RECIPIENT = "0x..."; // tu wallet en L2
 
// 1. Approve
const erc20 = new ethers.Contract(TOKEN_L1, ERC20_ABI, signer);
await (await erc20.approve(L1_BRIDGE, AMOUNT)).wait();
 
// 2. Deposit
const bridge = new ethers.Contract(L1_BRIDGE, BRIDGE_ABI, signer);
const tx = await bridge.depositERC20To(TOKEN_L1, TOKEN_L2, RECIPIENT, AMOUNT, 200_000, "0x");
console.log("L1 tx:", tx.hash);
 
// 3. Esperar 2-3 min y verificar L2 balance
const l2Provider = new ethers.JsonRpcProvider("https://rpc.ruby.testnet.finetry.win");
const l2Token = new ethers.Contract(TOKEN_L2, ERC20_ABI, l2Provider);
console.log(await l2Token.balanceOf(RECIPIENT));

Withdrawal ERC-20 (Ruby Chain → Sepolia)

3 pasos secuenciales:

1. Init withdrawal en L2

const L2_BRIDGE = "0x4200000000000000000000000000000000000010";
const tx = await l2Bridge.withdraw(L2_TOKEN, AMOUNT, MIN_GAS, "0x");

2. Esperar challenge period (7 días en testnet)

Los state roots se postean a L1 cada ~10min. Después de ~10min, podés probar la withdrawal. Después de 7 días, finalizar y recibir los tokens en L1.

3. Prove + Finalize en L1

Usar Optimism SDK (opens in a new tab) o llamar manualmente:

  • OptimismPortal.proveWithdrawalTransaction(...) — después de ~10min
  • OptimismPortal.finalizeWithdrawalTransaction(...) — después de 7 días

En mainnet vamos a integrar OP Succinct ZK proofs y bajar withdrawals a 1-6 horas.

Errores comunes

"L2 token doesn't exist"

El wrapped token en L2 no fue creado todavía. Llamá primero OptimismMintableERC20Factory.createOptimismMintableERC20WithDecimals(...).

"Insufficient gas limit"

El _gasLimit que pasás es para la ejecución en L2 del finalize, no para la tx L1. Mínimo recomendado: 200_000.

"Withdrawal not yet finalizable"

El challenge period (7d) no terminó. Volvé después.

Bridge UI (próximamente)

Sprint 3 en progreso: estamos construyendo https://bridge.ruby.testnet.finetry.win que abstrae todos estos pasos detrás de una UI. Mientras tanto, usá los scripts manualmente o el Optimism SDK (opens in a new tab).