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
LiquidityControllerpredeploy. 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)
- Approvál: tu wallet aprueba al
L1StandardBridge(0xddf0b794...) para gastar X de tu ERC-20. - Deposit: llamás
L1StandardBridge.depositERC20To(l1Token, l2Token, recipient, amount, gasLimit, extraData). - Espera: ~2-3 min para que el sequencer lo derive del L1.
- Resultado: el
recipienten 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 ~10minOptimismPortal.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).