ERC-20 토큰 브릿징하기

이더리움에서 GIWA로, GIWA에서 이더리움으로 ERC-20 토큰(Token)을 브릿징해요.

요구사항

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

개발 환경 세팅

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

1

프로젝트 폴더 생성

mkdir giwa-bridging-erc20
cd giwa-bridging-erc20
2

프로젝트 초기화

pnpm init
3

Dependencies 설치

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

지갑 준비

ERC-20 토큰 브릿징을 위해 지갑이 필요해요.

1

세폴리아 ETH 준비

토큰 브릿징을 위해 이더리움 세폴리아 네트워크 및 GIWA 세폴리아 네트워크에서 ETH가 필요해요.

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

2

Private Key 환경변수 세팅

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

export TEST_PRIVATE_KEY=0x...

Chain Client 설정

ERC-20 토큰 브릿징을 위해 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());

컨트랙트 주소 및 ABI 설정

토큰 브릿징을 바로 테스트할 수 있도록 이더리움과 GIWA에 테스트용 ERC-20 토큰을 배포해두었어요. 아래에 정의된 레이어 2 토큰은 레이어 1 토큰의 브릿지된 버전이에요.

ERC-20 토큰 브릿징은 L1StandardBridge 컨트랙트를 통해 이루어져요. 이를 위해 필요한 함수 ABI들도 같이 정의해요.

ABI가 뭔가요?

ABI(Application Binary Interface)는 스마트 컨트랙트와 상호작용할 때 필요한 인터페이스에요. 특정 스마트 컨트랙트의 함수 이름, 파라미터, 반환값 등이 포함되어 있어요. Client나 SDK 등에서 컨트랙트를 호출할 때 ABI를 사용해 정확한 호출을 수행할 수 있습니다.

src/contract.ts
import {giwaSepolia} from "./config";
import {erc20Abi, parseAbi} from "viem";
import {sepolia} from "viem/chains";

// ERC-20 토큰 브릿징에 필요한 컨트랙트 주소 정의
export const l1TokenAddress = '0x50B1eF6e0fe05a32F3E63F02f3c0151BD9004C7c';
export const l2TokenAddress = '0xB11E5c9070a57C0c33Df102436C440a2c73a4c38';
export const l1StandardBridgeAddress = giwaSepolia.contracts.l1StandardBridge[sepolia.id].address;

// ERC-20 토큰 및 L1StandardBridge ABI(함수 인터페이스) 정의
export const testTokenAbi = [
  ...erc20Abi,
  ...parseAbi([
    'function claimFaucet() external'
  ]),
];
export const l1StandardBridgeAbi = parseAbi([
  'function depositERC20To(address _l1Token, address _l2Token, address _to, uint256 _amount, uint32 _minGasLimit, bytes calldata _extraData) external'
]);

L1 faucet 토큰 받기

Deposit (이더리움 -> GIWA) 을 위해 이더리움 세폴리아 네트워크에서 faucet 토큰이 필요해요. 위에서 정의한 레이어 1 토큰에는 claimFaucet 함수가 구현되어있어요. 아래 코드를 실행해서 claimFaucet 을 실행하고 레이어 1 facuet 토큰을 받으세요.

1

코드 작성하기

src/get_l1_token.ts
import {walletClientL1, account} from "./config";
import {l1TokenAddress, testTokenAbi } from "./contract";

async function main() {
  console.log('Getting tokens from faucet...');
  const claimHash = await walletClientL1.writeContract({
    address: l1TokenAddress,
    abi: testTokenAbi,
    functionName: 'claimFaucet',
    account,
  });
  console.log(`Claim Faucet transaction hash on L1: ${claimHash}`);
  
  const claimReceipt = await publicClientL1.waitForTransactionReceipt({ hash: claimHash });
  console.log('L1 transaction confirmed:', claimReceipt);
}

main().then();
2

실행하기

node --import=tsx src/get_l1_token.ts

Deposit ERC-20 Token (이더리움 -> GIWA)

이제 이더리움에서 GIWA로 위에서 받은 Faucet 토큰을 브릿징 해볼까요?

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

1

구성

  1. L1StandardBridge 컨트랙트가 ERC-20 토큰을 전송할 수 있도록 권한 부여

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

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

  4. Deposit 완료

2

코드 작성하기

src/deposit_erc20.ts
import {
  account,
  publicClientL1,
  walletClientL1
} from "./config";
import {
  l1StandardBridgeAbi,
  l1StandardBridgeAddress,
  l1TokenAddress, l2TokenAddress,
  testTokenAbi
} from "./contract";
import {formatEther, parseUnits} from "viem";
import {getL2TransactionHashes} from "viem/op-stack";

async function main() {
  // GIWA로 브릿징 하기 전, 여러분의 레이어 1 지갑에서 ERC-20 토큰 잔고를 확인해요.
  const l1TokenBalance = await publicClientL1.readContract({
    address: l1TokenAddress,
    abi: testTokenAbi,
    functionName: 'balanceOf',
    args: [account.address],
  });
  console.log(`L1 Token Balance: ${formatUnits(l1TokenBalance, 18)} FAUCET`);

  // 브릿징할때 여러분의 ERC-20 토큰이 L1StandardBridge 컨트랙트로 전송되어야해요.
  // 다만 여러분이 직접 전송하는 형태가 아닌, L1StandardBridge 컨트랙트 함수가 실행될 때 내부적으로 전송되어야합니다.
  // 이를 위해 approve 함수를 먼저 호출하여 L1StandardBridge 컨트랙트에 전송 권한을 부여합니다.
  const approveHash = await walletClientL1.writeContract({
    address: l1TokenAddress,
    abi: testTokenAbi,
    functionName: 'approve',
    args: [l1StandardBridgeAddress, parseUnits('1', 18)],
  });
  console.log(`Approve transaction hash on L1: ${approveHash}`);

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

  // 레이어 1에서 deposit 트랜잭션을 전송해요.
  // 이 과정에서 여러분의 ETH가 L1StandardBridge 컨트랙트로 전송됩니다.
  const depositHash = await walletClientL1.writeContract({
    address: l1StandardBridgeAddress,
    abi: l1StandardBridgeAbi,
    functionName: 'depositERC20To',
    args: [
      l1TokenAddress,
      l2TokenAddress,
      account.address,
      parseUnits('1', 18),
      200000,
      '0x',
    ],
  });
  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_erc20.ts

Withdraw ERC-20 Token (GIWA -> 이더리움)

이더리움에서 GIWA로 ERC-20 토큰이 잘 전송되었나요? 이제 반대로 GIWA에서 이더리움으로 ERC-20 토큰을 브릿징해요.

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

1

구성

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

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

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

  4. Withdrawal 완료

2

코드 작성하기

src/withdraw_erc20.ts
import {account, publicClientL1, walletClientL1, publicClientL2, walletClientL2} from "./config";
import {l2StandardBridgeAbi, l2StandardBridgeAddress, l2TokenAddress, testTokenAbi} from "./contract";
import {formatUnits, parseUnits} from "viem";

async function main() {
  // 이더리움으로 브릿징 하기 전, 여러분의 레이어 2 지갑에서 ERC-20 토큰 잔고를 확인해요.
  const l2TokenBalance = await publicClientL2.readContract({
    address: l2TokenAddress,
    abi: testTokenAbi,
    functionName: 'balanceOf',
    args: [account.address],
  });
  console.log(`L2 Token Balance: ${formatUnits(l2TokenBalance, 18)} FAUCET`);

  // 레이어 2에서 withdrawal 트랜잭션을 전송해요.
  // 이 과정에서 여러분의 ERC-20 토큰이 소각(burn)됩니다.
  const withdrawalHash = await walletClientL2.writeContract({
    address: l2StandardBridgeAddress,
    abi: l2StandardBridgeAbi,
    functionName: 'withdrawTo',
    args: [
      l2TokenAddress,
      account.address,
      parseUnits('0.5', 18),
      200000,
      '0x',
    ],
  });
  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_erc20.ts

더 알아보기

ETH, ERC-20 토큰과 같은 자산 외에도 데이터를 브릿징하고 싶나요? OP 스택 문서를 읽고 여러분이 직접 구현해보세요.

Last updated