ERC-20 토큰 브릿징하기
이더리움에서 GIWA로, GIWA에서 이더리움으로 ERC-20 토큰(Token)을 브릿징해요.
요구사항
아래 항목들이 설치되어 있어야해요.
개발 환경 세팅
이 튜토리얼에서는 viem을 사용해요. Viem은 Node.js 라이브러리이기 때문에 Node.js 프로젝트로 생성합니다.
지갑 준비
ERC-20 토큰 브릿징을 위해 지갑이 필요해요.
세폴리아 ETH 준비
토큰 브릿징을 위해 이더리움 세폴리아 네트워크 및 GIWA 세폴리아 네트워크에서 ETH가 필요해요.
Chain Client 설정
ERC-20 토큰 브릿징을 위해 chain client를 설정합니다.
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들도 같이 정의해요.
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 토큰을 받으세요.
코드 작성하기
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();
Deposit ERC-20 Token (이더리움 -> GIWA)
이제 이더리움에서 GIWA로 위에서 받은 Faucet 토큰을 브릿징 해볼까요?
아래 코드를 실행하면 이더리움에 있던 여러분의 Faucet 토큰이 실제로는 L1StandardBridge 컨트랙트로 전송되고, 전송한 수량만큼 여러분의 GIWA 지갑으로 전송되는 것을 확인할 수 있어요. 이러한 방식을 Lock-and-Mint 라고 해요.
구성
L1StandardBridge 컨트랙트가 ERC-20 토큰을 전송할 수 있도록 권한 부여
레이어 1에서 deposit 트랜잭션 전송
이에 대응되는 레이어 2 deposit 트랜잭션이 sequencer에 의해 생성
Deposit 완료
코드 작성하기
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();
Withdraw ERC-20 Token (GIWA -> 이더리움)
이더리움에서 GIWA로 ERC-20 토큰이 잘 전송되었나요? 이제 반대로 GIWA에서 이더리움으로 ERC-20 토큰을 브릿징해요.
아래 코드를 실행하면 GIWA에 있던 여러분의 ERC-20 토큰이 소각(burn)되고, 전송한 수량만큼 여러분의 이더리움 지갑으로 전송되는 것을 확인할 수 있어요. 이때는 Deposit에 의해 L1StandardBridge 컨트랙트에 Lock되어있던 ERC-20 토큰이 다시 Unlock되는 형태에요. 이러한 방식을 Burn-and-Unlock 이라고 합니다.
코드 작성하기
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();
더 알아보기
ETH, ERC-20 토큰과 같은 자산 외에도 데이터를 브릿징하고 싶나요? OP 스택 문서를 읽고 여러분이 직접 구현해보세요.
Last updated