TIL

블록체인: ERC-20과 ERC-721

1. 코인과 토큰 🎯 1-1. 복습 ✅ 디지털 시대의 자유와 신뢰를 보장하기 위해, 비잔틴 장군 문제 해결 과정에서 블록체인 기술이 탄생했습니다. 블록체인 시스템은 메시지 위변조 방지는 공개키 암호화 방식으로, 메시지 처리의 무결성(Integrity)은 합의 알

2025년 11월 20일14min read

1. 코인과 토큰 🎯

1-1. 복습 ✅

디지털 시대의 자유와 신뢰를 보장하기 위해, 비잔틴 장군 문제 해결 과정에서 블록체인 기술이 탄생했습니다.

블록체인 시스템은 메시지 위변조 방지는 공개키 암호화 방식으로, 메시지 처리의 무결성(Integrity)은 합의 알고리즘을 통해 확보합니다.

이러한 블록체인 기반의 디지털 자산을 관리하기 위해 지갑(Wallet) 소프트웨어를 사용하며, 지갑의 핵심은 개인키(Private Key)입니다.

1. ECC(타원곡선 암호):개인키와 공개키 쌍을 수학적으로 생성하는 기초 암호화 메커니즘입니다. 2. 니모닉(Mnemonic): 개인키를 사람이 읽을 수 있는 단어 형태로 변환하여 자산 복구의 기준을 마련합니다. 3. HD Wallet(계층적 결정론적 지갑): 하나의 니모닉(Seed)으로부터 무수히 많은 개인키와 주소를 효율적으로 생성하고 관리하는 자산 관리의 표준입니다.

1-2. 무엇을 보관할 것인가 ✅

이제서야 지갑이 무엇인지 감을 잡았는데요, 그렇다면 지갑에 무엇을 보관할까요? 일반적으로 비트 코인을 보관한다고 하지 비트 토큰을 보관한다고 하지 않는데요. 그렇다면 코인과 토큰의 차이는 무엇일까요?

코인은 자체적인 블록체인 네트워크(메인넷)를 기반으로 동작합니다. 메인넷 자체의 기본적인 자산이기에 네이티브 코인이라고 부릅니다. 코인은 우리가 이미 아는 것처럼 화폐로 사용되며 트랜잭션 수수료(Gas Fee)를 지불하는 데 사용됩니다. BTC, ETH, SOL과 같은 코인을 예로 들 수 있습니다.

토큰은 무엇일까요? 토큰은 자체 블록체인인 메인넷이 없고, 이더리움이나 솔라나와 같은 기존 블록체인 위에 스마트 컨트랙트를 통해 구축된 보조 자산입니다. 탈중앙화 애플리케이션(DApp)이나 특정 용도(서비스 접근 권한, 투표권 등)를 위해 사용됩니다. 메인넷이 없고 네트워크 상에서 보조적인 역할을 수행하는 것이 토큰이겠네요. USDC, UNI, NFT와 같은 토큰을 예로 들 수 있습니다.


2. 환경 설정 🎯

2-1. 배경 ✅

코인은 메인넷에 깊숙이 관여하기에, 직접 만들거나 수정하려면 복잡한 네트워크 수준의 이해가 필요하겠죠? 토큰은 기본 블록체인 위에 스마트 컨트랙트를 배포하는 방식으로 코인에 비해서는 쉽게 생성하고 조작할 수 있을 것 같습니다.

ERC-20은 이더리움 블록체인 상에서 FT 토큰을 발행하기 위한 스탠다드이고, ERC-721은 이더리움 블록체인 상에서 NFT를 발행하기 위한 스탠다드입니다. 각각에 대한 실습을 진행하기 전에 환경 설정을 먼저 해봅시다.

이더리움 프라이빗 테스트 네트워크(Private Test Network)를 구축하고, 블록 탐색기(Block Explorer)를 설정할 생각입니다. 실제 이더리움 메인넷을 사용할 경우 Gas Fee가 발생하므로, 이더리움 프라이빗 테스트 네트워크라는 실제 돈이 들지 않는 가상의 블록체인 실험실을 만들 것입니다. 그리고 터미널 환경이 아닌 웹 기반의 시각적 인터페이스로 프라이빗 네트워크의 상태나 블록, 거래, 토큰 등을 모니터링하기 위해 Blockscout라는 도구를 사용할 것입니다.

2-2. Private Networks via Kurtosis ✅

Kurtosis는 이더리움 블록체인 네트워크와 같은 복잡한 분산 시스템을 자동으로 설정, 배포 및 테스트할 수 있게 해주는 오픈 소스 프레임워크입니다.

다음 단계를 이행해 주세요.

code
1. Install Docker
2. Install Kurtosis
3. mkdir private-network && cd private-network
4. vi network_params.yaml
5. kurtosis run github.com/ethpandaops/ethereum-package --args-file ./network_params.yaml --image-download always

4번 과정에서는 아래의 코드를 입력해주세요.

code
participants:
  - el_type: geth
    cl_type: lighthouse
    count: 2
  - el_type: geth
    cl_type: teku

network_params:
  network_id: "695874"

additional_services:
  dora: {}

Kurtosis가 이더리움 네트워크를 성공적으로 구동하고 각 서비스의 상태를 보여주는 터미널 출력 화면입니다. rpc: 8545/tcp -> 127.0.0.1:49996은 외부에서 트랜잭션을 보내거나 데이터를 조회할 때 사용할 RPC 접속 주소입니다.

위 이미지는 네트워크 설정 시 미리 자금이 할당된 테스트용 계정 목록을 보여줍니다. 공개 주소와 개인키 쌍이 나열되어 있습니다. Docker와 Kurtosis를 통해 Gas Fee 걱정 없이 토큰 실습을 진행할 수 있는 안정적인 블록체인 환경이 완벽하게 준비되었습니다.

프라이빗 이더리움 네트워크에 RPC 요청을 보내 첫 번째 주소의 잔액을 조회해 볼까요?

code
curl -X POST http://localhost:49996 \
-H "Content-Type: application/json" \
--data '{
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params":
["0x8943545177806ED17B9F23F0a21ee
5948eCaa776", "latest"],
"id": 1
}'
code
{"jsonrpc":"2.0","id":1,"result":"0x33b2e3c9fd0803ce8000000"}

십진수로 변환해 보면 약 1500만 이더리움인 것을 확인할 수 있는데요, 글을 작성하는 현재 기준으로 한화 약 66조 8800억에 해당하는 금액이네요. 저는 이제 부자입니다.

위에서는 제가 커맨드를 잘못 입력해서 Dora가 나오지 않았지만, 제가 작성한 내용을 그대로 따라오시면 Dora에 대한 주소를 받을 수 있습니다. Dora는 네트워크의 상태를 보여주는 대시보드 도구입니다.

2-3. Blockscout setup ✅

Dora가 네트워크 상태를 보여준다면, Blockscout는 트랜잭션의 상세 내용을 보여줍니다.

code
1. git clone https://github.com/zsystm/blockscout.git
2. cd blockscout/docker-compose
3. git checkout fastcampus
4. vi envs/common-blockscout.env
	4-1. ETHEREUM_JSONRPC_HTTP_URL=http://host.docker.internal:<your_geth_port>/
	4-2. ETHEREUM_JSONRPC_TRACE_URL=http://host.docker.internal:<your_geth_port>/
5. docker-compose -f geth.yml up -d

이후에 크롬 주소창에 'localhost'를 입력하면 Blockscout를 볼 수 있게 됩니다.


3. ERC-20 🎯

3-1. ERC-20 개념 ✅

ERC-2O은 Ethereum Request for Comment 20의 약자로, 이더리움 블록체인 상에서 대체 가능한(Fungible) 토큰을 발행하고 관리하기 위한 기술 표준(Standard)입니다.

ERC-2O의 핵심은 대체 가능성입니다. 내가 가진 1000원짜리 지폐와 다른 사람이 가진 1000원짜리 지폐는 구별할 필요가 없죠? 구별할 필요가 없다는 말은 대체 가능하다는 말과 동일합니다. 즉 화폐로서의 가치를 가진 무언가를 만들려면, 대체 가능성이라는 특징을 포함해야 합니다.

코인은 메인넷이 있어야 하는데 토큰은 스마트 컨트랙트로 돌아가고, 스마트 컨트랙트에서 토큰에 화폐의 개념을 적용하려면 대체 가능하다는 특징이 필요하기에 ERC-20이라는 표준이 생겨난 것이죠.

ERC-20의 주요 인터페이스는 다음과 같습니다.

1. totalSupply(): 발행된 토큰의 총 개수를 반환합니다.

2. balanceOf(address): 특정 주소가 가진 토큰 잔액을 반환합니다.

3. transfer(to, amount): 토큰을 다른 주소로 직접 보냅니다.

4. approve(spender, amount): 다른 컨트랙트나 사용자가 내 토큰을 대신 사용할 수 있도록 권한을 위임합니다.

5. transferFrom(from, to, amount): 위임된 권한을 이용해 토큰을 대리 전송합니다.

ERC-2O의 이벤트에는 두 가지가 있습니다.

1. Transfer(address from, address to, uint256 value): 토큰 이동 이벤트 2. Approval(address owner, address spender, uint256 value): 승인 이벤트

DeFi나 DEX에서 표준적으로 사용된다는 장점이 있지만 approve와 transferForm을 이용한 이중 호출 문제가 있습니다. 이 부분은 ERC-2612로 개선되었습니다. 본 글에서는 ERC-2612까지는 다루지 않겠습니다.

3-2. ERC-20 실습 ✅

기본 설정은 아래의 스니펫을 참고해 주세요.

code
1. git clone https://github.com/zsystm/token-examples.git
2. cd token-examples
3. vi .env

env에는 다음과 같은 내용을 작성해 주세요.

code
ETH_RPC_URL="http://localhost:50302"
SENDER_PRIVATE_KEY="bcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31"
RECIPIENT_PRIVATE_KEY="39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d"

3-2-1. Bytecode & Abi 🌀

제가 수강 중인 강의에서는 Bytecode와 Abi를 제공해 줍니다.

사람이 작성한 Solidity 코드를 이더리움 가상 머신(EVM)이 실행할 수 있는 기계어로 변환하는 과정이 필요합니다. 해당 코드는 실제 블록체인에 배포되는 코드입니다.

API가 소스 코드 수준의 인터페이스라면 Abi는 바이너리 수준의 인터페이스입니다. 지갑이나 DApp, 터미널 등 외부 프로그램이 블록체인에 배포된 컨트랙트와 어떻게 통신해야 하는지 알려주는 인터페이스입니다.

solidity
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract MyToken is ERC20, ERC20Permit {
    constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {
        _mint(msg.sender, 1000000000000000000);
    }
}

복잡한 로컬 설정 없이 간단한 Solidity 코드를 작성한 후 컴파일하고 테스트 네트워크에 배포하기 위해 Remix IDE를 사용할 수 있습니다.

3-2-2. erc20.js 🌀

초기 설정 및 지갑 준비

js
require("dotenv").config();
const fs = require("fs");
const { Wallet, ethers, BigNumber } = require("ethers");

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function deployAndExecuteContract() {
  // Load contract ABI and Bytecode
  const abi = JSON.parse(fs.readFileSync("contracts/erc20.abi", "utf8"));
  let bytecode = fs
    .readFileSync("contracts/erc20.bytecode", "utf8")
    .replace(/\n/g, "");

  // Setup Provider & Wallet (Private key and RPC URL loaded from .env)
  const privateKey = process.env.SENDER_PRIVATE_KEY;
  const provider = new ethers.providers.JsonRpcProvider(
    process.env.ETH_RPC_URL || "http://localhost:8545"
  );
  const wallet = new Wallet(privateKey, provider);
  const sender = wallet.address;

  console.log(`🟢 Sender Address: ${sender}`);

  // Check Native Balance (Used for Gas Fee)
  const beforeBalance = await provider.getBalance(sender);
  console.log(
    `💰 Native Balance of Sender: ${ethers.utils.formatEther(
      beforeBalance
    )} ETH`
  );

env 정보를 가져온 뒤, 컨트랙트 파일(abi / bytecode)을 읽을 수 있도록 Node.js의 파일 시스템 모듈을 불러옵니다. 이후 트랜잭션에 서명할 권한을 가진 디지털 지갑 생성합니다.

컨트랙트 배포

js
// Deploy Contract
  console.log(`🚀 Deploying ERC20 contract...`);
  const contractFactory = new ethers.ContractFactory(abi, bytecode, wallet);
  const contract = await contractFactory.deploy({ gasLimit: 5000000 });

  console.log(`⏳ Waiting for contract deployment...`);
  await contract.deployTransaction.wait();
  console.log(`✅ Contract deployed at: ${contract.address}`);

  // Query Deploy Transaction Receipt
  await delay(5000); // Wait for confirmation
  const deployReceipt = await provider.getTransactionReceipt(
    contract.deployTransaction.hash
  );
  console.log(`📜 Deploy TX Receipt:`, deployReceipt);

토큰의 설계도(ABI/바이트코드)와 서명 권한(wallet)을 하나로 묶어 컨트랙트를 만들 contractFactory를 생성합니다. contractFactory의 deploy 메서드를 통해 블록체인에 컨트랙트를 생성하라는 트랜잭션을 보냅니다. 이때 gasLimit은 해당 배포에 사용할 수 있는 최대 수수료 한도를 뜻합니다. 배포에 성공하면 컨트랙트가 블록체인에 영구적으로 자리 잡은 contract.address를 출력합니다. 이 주소가 곧 새 토큰의 주소가 되겠죠.

contract.deployTransaction.hash를 사용하여 최종 처리 결과가 담긴 영수증을 블록체인 노드로부터 조회합니다. status나 gas fee 등의 중요 정보가 해당 영수증에 담겨 있습니다.

Recipient 설정 및 초기 잔액 확인

js
// Define recipient and amount
  const recipientWallet = new Wallet(
    process.env.RECIPIENT_PRIVATE_KEY,
    provider
  ); 
  const recipient = recipientWallet.address;
  const amount = BigNumber.from(10); 

  // Check ERC20 Balances Before Transfer (Initial balance set by constructor)
  let senderBalance = await contract.balanceOf(sender);
  let recipientBalance = await contract.balanceOf(recipient);
  console.log(`🔹 Sender ERC20 Balance: ${senderBalance.toString()}`);
  console.log(`🔹 Recipient ERC20 Balance: ${recipientBalance.toString()}`);

  // Guard clause for insufficient balance
  if (senderBalance.lt(amount)) {
    console.error(`❌ Insufficient ERC20 balance for transfer`);
    return;
  }

recipientWallet은 토큰을 받을 사람의 디지털 지갑 전체입니다. recipient는 토큰을 받을 사람의 주소겠죠? amount는 전송할 ERC-20의 토큰 수량인데 해당 코드에서는 10으로 지정했습니다. senderBalance는 토큰을 보내는 발신자가 현재 가지고 있는 토큰의 수량이고, recipientBalance는 토큰을 받는 수신자가 현재 가지고 있는 토큰의 수량을 의미합니다.

직접 전송(transfer) 테스트

js
// Perform ERC20 Transfer
  console.log(
    `📤 Transferring ${amount.toString()} ERC20 tokens to ${recipient}`
  );
  const transferTx = await contract.transfer(recipient, amount, {
    gasLimit: 100000,
  });
  console.log(`⏳ Waiting for transfer to confirm...`);
  await transferTx.wait();
  console.log(`✅ Transfer TX Hash: ${transferTx.hash}`);

  // Query Transfer Event and Check Updated Balances
  const transferReceipt = await provider.getTransactionReceipt(transferTx.hash);
  console.log(`📜 Transfer TX Receipt:`, transferReceipt);

  await delay(5000); // Wait for logs to be indexed

  // Fetch Transfer Event Logs
  const transferEvents = await contract.queryFilter(
    contract.filters.Transfer(sender, recipient)
  );
  transferEvents.forEach((event) => {
    console.log(
      `📩 Transfer Event: ${event.args.from} → ${
        event.args.to
      }, Amount: ${event.args.value.toString()}`
    );
  });

  // Check Updated Balances
  senderBalance = await contract.balanceOf(sender);
  recipientBalance = await contract.balanceOf(recipient);
  console.log(
    `🔹 Sender ERC20 Balance After Transfer: ${senderBalance.toString()}`
  );
  console.log(
    `🔹 Recipient ERC20 Balance After Transfer: ${recipientBalance.toString()}`
  );

transferTx는 토큰을 보내라는 트랜잭션 직후에 반환되는 객체입니다. 해당 객체에는 트랜잭션의 해시가 담겨 있습니다. transferReceipt는 트랜잭션이 블록에 기록되어 최종 처리된 후에 블록체인에서 조회되는 공식적인 결과 보고서에 해당합니다. 마지막으로 transferEvents는 블록체인 로그에서 Transfer라는 이름의 특정 기록을 필터링하여 찾아낸 배열입니다.

대리 사용 권한 위임(approve)

js
// ========== APPROVE CASE ==========
  console.log(
    `🔹 Approving ${recipient} to spend ${amount.toString()} tokens on behalf of sender`
  );
  const approveTx = await contract.approve(recipient, amount, {
    gasLimit: 100000,
  });
  await approveTx.wait();
  console.log(`✅ Approval TX Hash: ${approveTx.hash}`);

  // Query Approval Event
  const approvalReceipt = await provider.getTransactionReceipt(approveTx.hash);
  console.log(`📜 Approval TX Receipt:`, approvalReceipt);

  // Fetch Approval Event Logs
  const approvalEvents = await contract.queryFilter(
    contract.filters.Approval(sender, recipient)
  );
  approvalEvents.forEach((event) => {
    console.log(
      `📩 Approval Event: Owner ${event.args.owner} → Spender ${
        event.args.spender
      }, Amount: ${event.args.value.toString()}`
    );
  });

approveTx는 발신자가 수신자에게 토큰 사용 권한을 위임하라는 트랜잭션을 네트워크에 보낸 후 반환되는 객체입니다. approvalReceipt는 approve 거래가 성공적으로 블록에 기록되고 처리된 후 조회하는 공식적인 결과 보고서에 해답합니다. approvalEvents는 위임 기록을 필터링하여 찾아낸 배열입니다.

대리 전송 실행(transferForm)

js
// ========== TRANSFER FROM ==========
  console.log(
    `📤 ${recipient} attempting to transfer ${amount.toString()} tokens from ${sender}`
  );

  // Connect as recipient to execute transferFrom (Acts as the Spender)
  const recipientContract = contract.connect(recipientWallet);

  const newRecipient = "0x614561D2d143621E126e87831AEF287678B442b8";
  let newRecipientBalance = await contract.balanceOf(newRecipient);
  console.log(
    `🔹 New Recipient ERC20 Balance: ${newRecipientBalance.toString()}`
  );
  
  // Execute transferFrom
  const transferFromTx = await recipientContract.transferFrom(
    sender,
    newRecipient,
    amount,
    { gasLimit: 100000 }
  );
  await transferFromTx.wait();
  console.log(`✅ TransferFrom TX Hash: ${transferFromTx.hash}`);

  // Fetch TransferFrom Event Logs (Note: This still tracks the transfer event)
  const transferFromEvents = await contract.queryFilter(
    contract.filters.Transfer(sender, recipient)
  );
  transferFromEvents.forEach((event) => {
    console.log(
      `📩 TransferFrom Event: ${event.args.from} → ${
        event.args.to
      }, Amount: ${event.args.value.toString()}`
    );
  });

recipientContract는 토큰을 대신 보내는 권한이 부여된 수신자의 지갑으로 연결된 컨트랙트 객체입니다. newRecipient는 recipient가 토큰을 꺼내서 최종적으로 전달하는 제3자의 계좌에 해당합니다. newRecipientBalance는 제3자가 원래 가지고 있던 토큰 수량입니다. transferFromTx는 recipientContract가 최종 수신자에게 전송한 거래 요청이고, transferFromEvents는 토큰이 성공적으로 이동되었다는 사실을 보여주는 기록 증거입니다.

최종 잔액 확인

js
// Check Balances After TransferFrom
  senderBalance = await contract.balanceOf(sender);
  newRecipientBalance = await contract.balanceOf(newRecipient);
  console.log(
    `🔹 Sender ERC20 Balance After transferFrom: ${senderBalance.toString()}`
  );
  console.log(
    `🔹 New Recipient ERC20 Balance After transferFrom: ${newRecipientBalance.toString()}`
  );

  // Check Native Balance
  const afterBalance = await provider.getBalance(sender);
  console.log(
    `💰 Native Balance of Sender after all txs: ${ethers.utils.formatEther(
      afterBalance
    )} ETH`
  );
}

deployAndExecuteContract();

A => B => C 순서로 토큰이 전송되었다고 보면, senderBalance는 A의 최종 ERC-20 잔액이고, newRecipientBalance는 C의 최종 ERC-20 잔액이며, afterBalance는 A의 Gas Fee가 차감된 ETH 잔액입니다. 이제 npm을 설치한 후 해당 파일을 실행합니다.

3-2-3. Result 🌀


4. ERC-721 🎯

4-1. ERC-721 개념 ✅

ERC-721은 Ethereum Request for Comment 721의 약자로, 이더리움 블록체인 상에서 대체 불가능한(Non-Fungible) 토큰을 발행하고 관리하기 위한 기술 표준(Standard)입니다.

ERC-721의 핵심은 대체 불가능성입니다. 대체 불가능하다는 것은 토큰마다 고유한 식별자(ID)를 가지고 있으며, 서로 다른 토큰은 가치와 속성이 달라 1:1 교환이 불가능하다는 의미입니다.

실생활의 예시로는 고유한 일련번호가 있는 미술품, 부동산 등기 권리, 희귀한 수집품 등을 들 수 있습니다. 나의 '모나리자' 그림 NFT는 다른 사람의 '피카소' 그림 NFT와 교환될 수 없습니다. ERC-721 토큰은 고유한 자산의 소유권을 디지털로 증명하는 용도로 사용됩니다. 이것이 바로 우리가 흔히 NFT(Non-Fungible Token)라고 부르는 디지털 자산의 기반입니다.

ERC-20이 화폐의 통일성을 위해 필요했다면, ERC-721은 고유한 디지털 자산의 소유권을 블록체인 상에서 안전하고 투명하게 보장하기 위해 필요합니다. 이 표준은 지갑이 어떤 고유 자산을 담고 있는지, 그리고 그 자산의 소유자가 누구인지를 모두가 동일하게 인식하도록 규칙을 제공합니다.

ERC-721 표준은 토큰의 고유성을 관리하고 소유권을 이전하는 핵심 기능을 정의합니다.

1. ownerOf(tokenId): 특정 고유 ID를 가진 토큰의 현재 소유자 주소를 반환합니다. (소유권 확인)

2. balanceOf(owner): 특정 주소가 가진 NFT의 총 개수를 반환합니다.

3. transferFrom(from, to, tokenId): 특정 고유 ID를 가진 토큰의 소유권을 한 주소에서 다른 주소로 이동시킵니다.

4. approve(to, tokenId): 다른 주소에게 특정 토큰을 대신 전송할 수 있는 권한을 위임합니다.

5. setApprovalForAll(operator, approved): 특정 주소에게 소유자가 가진 모든 토큰의 관리 권한을 위임합니다. (대량 관리 권한 부여)

ERC-721의 이벤트에는 세 가지가 있습니다.

1. Transfer(address from, address to, uint256 tokenId): NFT 이동 이벤트 2. Approval(address owner, address spender, uint256 tokenId): 개별 NFT 승인 이벤트 3. ApprovalForAll(address owner, address operator, bool approved): 전체 승인 이벤트

디지털 소유권을 온 체인에서 증명할 수 있고 희소성과 유일성을 보장하는 자산 모델로 사용할 수 있다는 장점이 있지만, NFT는 단순히 메타 데이터(예: URL)만 저장하고 실제 데이터는 외부 스토리지에 저장하기 때문에 메타 데이터가 변조될 경우 소유권 증명에 문제가 발생할 수 있다는 단점이 있습니다.

4-2. ERC-721 실습 ✅

4-2-1. Bytecode & Abi 🌀

마찬가지로 제가 수강 중인 강의에서는 ERC-721에 대한 Bytecode와 Abi를 제공해 줍니다. 설명은 위에서 진행했으니 생략하겠습니다.

4-2-2. erc721.js 🌀

초기 설정 및 지갑 준비

js
require("dotenv").config();
const fs = require("fs");
const { Wallet, ethers } = require("ethers");

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function deployAndExecuteNFTContract() {
  // Load contract ABI and Bytecode
  const abi = JSON.parse(fs.readFileSync("contracts/erc721.abi", "utf8"));
  let bytecode = fs
    .readFileSync("contracts/erc721.bytecode", "utf8")
    .replace(/\n/g, "");

  // Setup Provider & Wallet
  const privateKey = process.env.SENDER_PRIVATE_KEY;
  const provider = new ethers.providers.JsonRpcProvider(
    process.env.ETH_RPC_URL || "http://localhost:8545"
  );
  const wallet = new Wallet(privateKey, provider);
  const sender = wallet.address;

  console.log(`🟢 Sender Address: ${sender}`);

초기 설정 및 지갑 준비 부분은, ERC-721용 abi와 bytecode를 읽는 것을 제외하고는 ERC-20과 전혀 다른 점이 없으니 넘어가겠습니다.

NFT 컨트랙트 배포

js
// Deploy Contract
  console.log(`🚀 Deploying MyToken (ERC721) contract...`);
  const contractFactory = new ethers.ContractFactory(abi, bytecode, wallet);
  const contract = await contractFactory.deploy({ gasLimit: 5000000 });

  console.log(`⏳ Waiting for contract deployment...`);
  await contract.deployTransaction.wait();
  console.log(`✅ Contract deployed at: ${contract.address}`);

  await delay(5000);

  // Query Deploy Transaction Receipt
  const deployReceipt = await provider.getTransactionReceipt(
    contract.deployTransaction.hash
  );
  console.log(`📜 Deploy TX Receipt:`, deployReceipt);

컨트랙트 배포도 ERC-20과 기능적으로 동일하니 넘어가겠습니다.

초기 소유권 확인

js
// Define recipient and token ID
  const recipientWallet = new Wallet(
    process.env.RECIPIENT_PRIVATE_KEY,
    provider
  );
  const recipient = recipientWallet.address;
  const tokenId = 0; // First minted NFT

  // Check Initial NFT Ownership
  let owner = await contract.ownerOf(tokenId);
  console.log(`🔹 Owner of Token ID ${tokenId}: ${owner}`);

  if (owner !== sender) {
    console.error(`❌ Unexpected NFT owner. Expected ${sender}, but got ${owner}.`);
    return;
  }

ERC-20에서는 balanceOf 메서드를 통해 토큰의 수량을 확인했는데요, ERC-721에서는 ownerOf 메서드를 통해 토큰의 소유권을 확인하는 부분이 다르네요.

대리 권한 위임(approve)

js
// Approve Recipient for NFT Transfer
  console.log(`🔹 Approving ${recipient} to transfer Token ID ${tokenId}`);
  const approveTx = await contract.approve(recipient, tokenId, {
    gasLimit: 100000,
  });
  await approveTx.wait();
  console.log(`✅ Approval TX Hash: ${approveTx.hash}`);

  // Fetch Approval Event Logs
  const approvalEvents = await contract.queryFilter(
    contract.filters.Approval(sender, recipient)
  );
  approvalEvents.forEach((event) => {
    console.log(
      `📩 Approval Event: Owner ${event.args.owner} → Approved ${event.args.approved}, Token ID: ${event.args.tokenId.toString()}`
    );
  });

ERC-2O에서는 approve 함수에 토큰의 수량을 넘겼던 것과 달리, ERC-721에서는 토큰 ID를 넘기는 것을 확인할 수 있습니다. 지정 대상에게 NFT에 대한 소유권 관리 권한을 주는 것으로 이해할 수 있습니다.

대리 전송 실행(safeTransferForm)

js
const newRecipient = "0x614561D2d143621E126e87831AEF287678B442b8";
  // Execute Transfer as Recipient
  console.log(`📤 ${recipient} attempting to transfer NFT Token ID ${tokenId} to ${newRecipient}`);

  const recipientContract = contract.connect(recipientWallet);
  const transferTx = await recipientContract["safeTransferFrom(address,address,uint256)"](
    sender,
    newRecipient,
    tokenId,
    { gasLimit: 100000 }
  );
  await transferTx.wait();
  console.log(`✅ Transfer TX Hash: ${transferTx.hash}`);

ERC-20에서 transferFrom 대신 safeTransferFrom을 사용한 것이 핵심입니다. 만약 수신자 컨트랙트가 NFT를 처리할 수 없는 컨트랙트라면, NFT는 컨트랙트 주소에 영원히 갇혀(burned) 버릴 수 있습니다. safe 함수는 이런 영구적인 NFT 손실을 방지해 줍니다.

최종 소유권 확인 및 로그 출력

js
// Fetch Transfer Event Logs
  const transferEvents = await contract.queryFilter(
    contract.filters.Transfer(sender, recipient)
  );
  transferEvents.forEach((event) => {
    console.log(
      `📩 Transfer Event: ${event.args.from} → ${event.args.to}, Token ID: ${event.args.tokenId.toString()}`
    );
  });

  // Check Final Owner
  const finalOwner = await contract.ownerOf(tokenId);
  console.log(`🔹 Final Owner of Token ID ${tokenId}: ${finalOwner}`);
}

transferEvents는 safeTransferFrom 함수가 실행된 후 블록체인 로그에서 조회된 Transfer 이벤트 기록입니다. finalOwner는 모든 거래가 완료된 후 해당 NFT를 현재 누가 소유하고 있는지 블록체인에서 직접 조회한 결과라고 볼 수 있습니다.

함수 호출

js
deployAndExecuteNFTContract();

함수를 호출합니다.

4-2-3. Result 🌀

5. 마치며 🎯

10시간 가까이 무언가에 집중해 본 적이 거의 처음인 것 같습니다. 블록체인을 조금 더 일찍 공부했으면 더 행복하지 않았을까 할 정도로요. 그런데 화면을 개발할 줄 몰랐다면 서버 공부를 했을까요? 서버를 공부하지 않았다면 데이터베이스 공부를 했을까요? 처음부터 끝까지 풀스택 프로젝트를 개발하지 않았다면, 네트워크 응용 필드의 끝판왕인 블록체인을 이해할 수 있었을까요?

Just do it이라는 말을 제일 싫어합니다. 누가 그걸 모르냐고요. 문제는 '하기' 자체가 아니라 '두려움' 때문에 시작조차 못하는 거잖아요. 두려워 하지 말라는 말이 더 유효한 것 같습니다. 오늘 내가 하는게 세상에서 제일 중요하고 하루하루 모두 필요한 시간이니, 계산기 두드리면서 의심하지 말고 계속 나아가는 게 중요하다고 생각합니다. 두려워하지 마십쇼.