ETH 브릿징하기

이더리움에서 GIWA로, GIWA에서 이더리움으로 ETH를 브릿징해요.

요구사항

아래 항목들이 설치되어 있어야해요.

개발 환경 세팅

이 튜토리얼에서는 viem을 사용해요. Viem은 Node.js 라이브러리이기 때문에 Node.js 프로젝트로 생성합니다.

1

프로젝트 폴더 생성

mkdir giwa-bridging-eth
cd giwa-bridging-eth
2

프로젝트 초기화

pnpm init
3

Dependencies 설치

pnpm add -D tsx @types/node
pnpm add viem

지갑 준비

ETH 브릿징을 위해 지갑이 필요해요.

1

세폴리아 ETH 준비

Deposit (Ethereum -> GIWA) 을 위해 이더리움 세폴리아 네트워크에서 ETH가 필요해요. 해당 faucet에서 세폴리아 ETH를 받을 수 있습니다.

아직 지갑이 없나요? cast 커맨드로 지갑을 생성하세요.

2

Private Key 환경변수 세팅

이 튜토리얼에서는 여러 번의 트랜잭션 서명이 필요해요. 이를 위해 지갑 private key 환경변수를 세팅해야합니다.

export TEST_PRIVATE_KEY=0x...

Chain Client 설정

ETH 브릿징을 위해 chain client를 설정합니다.

src/config.ts
import {defineChain, createPublicClient, http, createWalletClient} from "viem";
import {privateKeyToAccount} from "viem/accounts";
import {publicActionsL1, publicActionsL2, walletActionsL1, walletActionsL2} from "viem/op-stack";
import {sepolia} from "viem/chains";

// GIWA 세폴리아 체인 설정
export const giwaSepolia = defineChain({
  id: 91342,
  name: 'Giwa Sepolia',
  nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 },
  rpcUrls: {
    default: {
      http: ['https://sepolia-rpc.giwa.io'],
    },
  },
  contracts: {
    multicall3: {
      address: '0xcA11bde05977b3631167028862bE2a173976CA11',
    },
    l2OutputOracle: {},
    disputeGameFactory: {
      [sepolia.id]: {
        address: '0x37347caB2afaa49B776372279143D71ad1f354F6',
      },
    },
    portal: {
      [sepolia.id]: {
        address: '0x956962C34687A954e611A83619ABaA37Ce6bC78A',
      },
    },
    l1StandardBridge: {
      [sepolia.id]: {
        address: '0x77b2ffc0F57598cAe1DB76cb398059cF5d10A7E7',
      },
    },
  },
  testnet: true,
});

// 지갑 준비
export const PRIVATE_KEY = process.env.TEST_PRIVATE_KEY as `0x${string}`;
export const account = privateKeyToAccount(PRIVATE_KEY);

// 이더리움 세폴리아 체인 데이터를 읽기 위한 client
export const publicClientL1 = createPublicClient({
  chain: sepolia,
  transport: http(),
}).extend(publicActionsL1())

// 이더리움 세폴리아 체인에 트랜잭션을 전송하기 위한 client
export const walletClientL1 = createWalletClient({
  account,
  chain: sepolia,
  transport: http(),
}).extend(walletActionsL1());

// GIWA 세폴리아 체인 데이터를 읽기 위한 client
export const publicClientL2 = createPublicClient({
  chain: giwaSepolia,
  transport: http(),
}).extend(publicActionsL2());

// GIWA 세폴리아 체인에 트랜잭션을 전송하기 위한 client
export const walletClientL2 = createWalletClient({
  account,
  chain: giwaSepolia,
  transport: http(),
}).extend(walletActionsL2());

Deposit ETH (이더리움 -> GIWA)

이제 이더리움에서 GIWA로 ETH를 브릿징 해볼까요?

아래 코드를 실행하면 이더리움에 있던 여러분의 ETH가 실제로는 OptimismPortal 컨트랙트로 전송되고, 전송한 수량만큼 여러분의 GIWA 지갑으로 전송되는 것을 확인할 수 있어요. 이러한 방식을 Lock-and-Mint 라고 해요.

1

구성

  1. 레이어 1에서 deposit 트랜잭션 전송

  2. 이에 대응되는 레이어 2 deposit 트랜잭션이 sequencer에 의해 생성

  3. Deposit 완료

2

코드 작성하기

src/deposit_eth.ts
import {publicClientL1, publicClientL2, account, walletClientL1} from './config';
import {formatEther, parseEther} from "viem";
import {getL2TransactionHashes} from "viem/op-stack";

async function main() {
  // GIWA로 브릿징 하기 전, 여러분의 레이어 1 지갑에서 ETH 잔고를 확인해요.
  const l1Balance = await publicClientL1.getBalance({ address: account.address });
  console.log(`L1 Balance: ${formatEther(l1Balance)} ETH`);

  // 레이어 1에서 deposit 트랜잭션을 전송하기 위해 파라미터를 build 합니다.
  const depositArgs = await publicClientL2.buildDepositTransaction({
    mint: parseEther("0.001"),
    to: account.address,
  });

  // 레이어 1에서 deposit 트랜잭션을 전송해요.
  // 이 과정에서 여러분의 ETH가 OptimismPortal 컨트랙트로 전송됩니다.
  const depositHash = await walletClientL1.depositTransaction(depositArgs);
  console.log(`Deposit transaction hash on L1: ${depositHash}`);

  // 위에서 전송한 레이어 1 트랜잭션이 완전히 처리될때까지 기다려요.
  const depositReceipt = await publicClientL1.waitForTransactionReceipt({ hash: depositHash });
  console.log('L1 transaction confirmed:', depositReceipt);

  // 레이어 1 트랜잭션의 결과인 receipt를 통해 레이어 2에서 발생할 deposit 트랜잭션의 hash를 미리 계산해요.
  const [l2Hash] = getL2TransactionHashes(depositReceipt);
  console.log(`Corresponding L2 transaction hash: ${l2Hash}`);

  // 위에서 계산한 레이어 2 deposit 트랜잭션이 처리될때까지 기다려요.
  // 이 과정은 대략 1~3분정도 소요됩니다.
  const l2Receipt = await publicClientL2.waitForTransactionReceipt({
    hash: l2Hash,
  });
  console.log('L2 transaction confirmed:', l2Receipt);
  console.log('Deposit completed successfully!');
}

main().then();
3

실행하기

node --import=tsx src/deposit_eth.ts

왜 레이어 1 deposit 트랜잭션을 전송하고, 레이어 2 지갑 잔고에 반영되기까지 수 분이 소요되나요?

여러분이 레이어 1 deposit 트랜잭션을 전송하면, 레이어 2 sequencer가 이를 확인하고 레이어 2 deposit 트랜잭션을 생성해요. 이 과정에서 레이어 1 체인이 reorg가 발생할 수 있기 때문에 시스템 안정성을 위해 레이어 2 sequencer는 레이어 1 deposit 트랜잭션이 발생하고 대략 N 블록 이후에 이를 처리하는 형태입니다.

Withdraw ETH (GIWA -> 이더리움)

이더리움에서 GIWA로 ETH가 잘 전송되었나요? 이제 반대로 GIWA에서 이더리움으로 ETH를 브릿징해요.

아래 코드를 실행하면 GIWA에 있던 여러분의 ETH가 실제로는 L2ToL1MessagePasser 컨트랙트로 전송되고, 전송한 수량만큼 여러분의 이더리움 지갑으로 전송되는 것을 확인할 수 있어요. 이때는 Deposit에 의해 OptimismPortal 컨트랙트에 Lock되어있던 ETH가 다시 Unlock되는 형태에요. 이러한 방식을 Burn-and-Unlock 이라고 합니다.

L2ToL1MessagePasser 컨트랙트로 전송된 ETH는 사실상 소각(burn)되었다고 볼 수 있어요. Balance를 조회하면 남아있지만 언제든 해당 컨트랙트의 burn() 함수를 호출하면 모든 balance가 소각되는 구조에요.

1

구성

  1. 레이어 2에서 withdrawal 개시 트랜잭션 전송

  2. 레이어 1에서 withdrawal 증명 트랜잭션 전송

  3. 레이어 1에서 withdrawal 완료 트랜잭션 전송

  4. Withdrawal 완료

2

코드 작성하기

src/withdraw_eth.ts
import {publicClientL1, publicClientL2, account, walletClientL1, walletClientL2} from './config';
import {formatEther, parseEther} from "viem";

async function main() {
  // 이더리움으로 브릿징하기 전, 여러분의 레이어 2 지갑에서 ETH 잔고를 확인해요.
  const l2Balance = await publicClientL2.getBalance({ address: account.address });
  console.log(`L2 Balance: ${formatEther(l2Balance)} ETH`);

  // 레이어 2에서 withdrawal을 개시하는 트랜잭션을 전송하기 위해 파라미터를 build 합니다.
  const withdrawalArgs = await publicClientL1.buildInitiateWithdrawal({
    to: account.address,
    value: parseEther("0.00005"),
  });

  // 레이어 2에서 withdrawal을 개시해요.
  // 이 과정에서 여러분의 ETH가 L2ToL1MessagePasser 컨트랙트로 전송됩니다.
  const withdrawalHash = await walletClientL2.initiateWithdrawal(withdrawalArgs);
  console.log(`Withdrawal transaction hash on L2: ${withdrawalHash}`);

  // 위에서 전송한 레이어 2 트랜잭션이 완전히 처리될때까지 기다려요.
  const withdrawalReceipt = await publicClientL2.waitForTransactionReceipt({ hash: withdrawalHash });
  console.log('L2 transaction confirmed:', withdrawalReceipt);

  // 레이어 2 withdrawawl transaction을 레이어 1에서 증명할 수 있을때까지 기다려요.
  // 이 과정은 최대 2시간이 소요될 수 있습니다.
  const { output, withdrawal } = await publicClientL1.waitToProve({
    receipt: withdrawalReceipt,
    targetChain: walletClientL2.chain
  });

  // 레이어 1에서 withdrawal을 증명하는 트랜잭션을 전송하기 위해 파라미터를 build 합니다.
  const proveArgs = await publicClientL2.buildProveWithdrawal({
    output,
    withdrawal,
  });

  // 레이어 1에서 withdrawal을 증명해요.
  const proveHash = await walletClientL1.proveWithdrawal(proveArgs);
  console.log(`Prove transaction hash on L1: ${proveHash}`);

  // 위에서 전송한 레이어 1 트랜잭션이 완전히 처리될때까지 기다려요.
  const proveReceipt = await publicClientL1.waitForTransactionReceipt({ hash: proveHash });
  console.log('Prove transaction confirmed:', proveReceipt);

  // Withdrawal을 finalize할 수 있을때까지 기다려요.
  // 이 기간을 challenge period라고 부르며 대략 7일이 소요됩니다.
  await publicClientL1.waitToFinalize({
    targetChain: walletClientL2.chain,
    withdrawalHash: withdrawal.withdrawalHash,
  });

  // 레이어 1에서 withdrawal 과정을 마무리해요.
  const finalizeHash = await walletClientL1.finalizeWithdrawal({
    targetChain: walletClientL2.chain,
    withdrawal,
  });
  console.log(`Finalize transaction hash on L1: ${finalizeHash}`);

  // 위에서 전송한 레이어 1 트랜잭션이 완전히 처리될때까지 기다려요.
  const finalizeReceipt = await publicClientL1.waitForTransactionReceipt({
    hash: finalizeHash
  });
  console.log('Finalize transaction confirmed:', finalizeReceipt);

  // Withdrawal 상태를 레이어 1에서 읽어옵니다.
  // Withdrawal은 완료되기까지 많은 시간이 소요되기 때문에 필요한 경우 해당 함수로 withdrawal 상태를 조회할 수 있어요.
  const status = await publicClientL1.getWithdrawalStatus({
    receipt: withdrawalReceipt,
    targetChain: walletClientL2.chain,
  });
  console.log('Withdrawal completed successfully!');
}

main().then();
3

실행하기

node --import=tsx src/withdraw_eth.ts

더 알아보기

viem 문서를 통해 더 많은 가이드를 읽어보세요.

Last updated