Web3 방식(이더리움 기반의 특정 NFT 프로젝트의 스마트컨트랙트에 직접 접근)을 이용하여 홀더 목록을 수집하는 방법에 대해 설명


1. 개요

이전 글(NFT 홀더 목록 수집하기 (1) - Web Crawling)에서는 Web Crawling 방식을 이용해 이더리움 기반 NFT 프로젝트의 홀더 목록을 수집하는 방법에 대해 설명하였다.

해당 방식은 이더스캔 검색 결과를 크롤링하였으며, 이더스캔에서 최대 1000개의 홀더(지갑 주소) 목록만 출력해주기 때문에 전체 홀더 목록을 수집할 수 없는 단점이 존재했다.

본 글에서는 Web Crawling 방식의 단점을 극복하여 NFT 프로젝트의 모든 홀더 목록을 수집하기 위한 방법에 대해 설명한다.

홀더 목록을 가장 정확하게 수집할 수 있는 방법은 이더리움에 배포되어있는 대상 NFT 프로젝트의 스마트컨트랙트에 접근하여 NFT 토큰 별 소유하고 있는 지갑주소를 직접 쿼리하는 것 이다.

Rust의 Web3 라이브러리를 이용해 이더리움 네트워크 및 스마트컨트랙트에 접근하여 홀더 목록을 수집하는 기능에 대해 설명한다.

또한 2개 이상의 NFT 홀더 목록을 수집하여, 두 프로젝트의 Mutual Holder를 계산하는 기능에 대해서도 설명한다. (Mutual Holder의 의미와 활용에 대해서는 뒤에서 설명)


2. ERC721 컨트랙트

ERC721은 NFT(Non-Fungible Token) Standard 즉 스마트컨트랙트에서 NFT에 대한 API 구현을 위한 표준이며, NFT를 추적하거나 전송하기 위한 등의 기본 기능을 제공한다.

NFT(Non-Fungible Token, 대체불가능 토큰) 디지털 자산에 대한 소유권을 나타내기 위한 목적을 가진다. (ERC20과 구별!)

이더리움에서 ERC20 표준을 따라서 개발된 토큰(ex. WETH, AAVE, APE 등)은 다른 토큰을 대체할 수 있으며, 소수점 단위로 쪼개서 거래가 가능하다.

하지만 ERC721 표준을 따라서 개발된 NFT 토큰은 다른 것과 대체 불가능하며 기본적으로는 쪼개서 거래가 불가능하다. (요즘 NFT 파생시장에서는 쪼개서 거래하는 등의 상품도 있는 것으로 알고 있음)

다른 토큰과 대체 가능하다는 것과 불가능하다는 것의 차이는 무엇일까?

  • ERC20 토큰 WETH(WrappedETH) : Alice가 가진 1 WETH 토큰과 Bob이 가진 1 WETH 토큰은 같은 가치를 가지며 대체될 수 있다.

  • ERC721 토큰 BAYC : Alice가 가진 BAYC#1 토큰과 Bob이 가진 BAYC#2 토큰은 가치가 다르며 대체가 불가능하다. (BAYC#1과 BAYC#2는 각각 아트웍이 다르다!)

이처럼 ERC721 표준을 따라 개발된 NFT 토큰은 각 토큰별로 id 값(1,2,..)을 가지며, 각 토큰별로 다른 가치를 가진다.

pragma solidity ^0.4.20;

/// @title ERC-721 Non-Fungible Token Standard
/// @dev See https://eips.ethereum.org/EIPS/eip-721
///  Note: the ERC-165 identifier for this interface is 0x80ac58cd.
interface ERC721 /* is ERC165 */ {
    event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
    event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
    event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

    function balanceOf(address _owner) external view returns (uint256);

    function ownerOf(uint256 _tokenId) external view returns (address);

    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
    function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
    function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
    function approve(address _approved, uint256 _tokenId) external payable;
    function setApprovalForAll(address _operator, bool _approved) external;
    function getApproved(uint256 _tokenId) external view returns (address);
    function isApprovedForAll(address _owner, address _operator) external view returns (bool);
}

interface ERC165 {
  function supportsInterface(bytes4 interfaceID) external view returns (bool);
}

ERC721은 위와 같은 인터페이스로 구성되며, NFT 토큰을 추적/전송 등을 지원하기 위한 기능으로 구성되어 있다.

  • balanceOf(address _owner) : 특정 지갑 주소(_owner)가 가진 NFT 토큰 수량 반환

  • ownerOf(uint256 _tokenId) : 특정 토큰(_tokenId)을 가진 지갑 주소 반환

  • safeTransferFrom() / transferFrom(), approve(), setApprovalForAll(), getApproved(), isApprovedForAll() : NFT 토큰 전송을 위한 기능

ERC165는 컨트랙트가 어떤 인터페이스를 구현하고 있는지 알려주기 위한 용도로 사용되며, NFT 컨트랙트 초기화 시 supportInterface()에 ERC721의 인터페이스 ID를 입력하여 실행함으로써 ERC721 인터페이스를 구현하고 있음을 다른 컨트랙트에게 알려줄 수 있다.

인터페이스 ID는 지원하고 있는 인터페이스 함수들의 selector들을 모두 계산한 후, XOR한 결과값을 사용한다.

bytes4(keccak256('balanceOf(address)')) == 0x70a08231
bytes4(keccak256('ownerOf(uint256)')) == 0x6352211e
bytes4(keccak256('approve(address,uint256)')) == 0x095ea7b3
bytes4(keccak256('getApproved(uint256)')) == 0x081812fc
bytes4(keccak256('setApprovalForAll(address,bool)')) == 0xa22cb465
bytes4(keccak256('isApprovedForAll(address,address)')) == 0xe985e9c
bytes4(keccak256('transferFrom(address,address,uint256)')) == 0x23b872dd
bytes4(keccak256('safeTransferFrom(address,address,uint256)')) == 0x42842e0e
bytes4(keccak256('safeTransferFrom(address,address,uint256,bytes)')) == 0xb88d4fde

0x70a08231 ^ 0x6352211e ^ 0x095ea7b3 ^ 0x081812fc ^ 0xa22cb465 ^ 0xe985e9c ^ 0x23b872dd ^ 0x42842e0e ^ 0xb88d4fde
== 0x80ac58cd

supportInterface(0x80ac58cd);

위처럼 ERC165로 인터페이스 ID를 등록한 경우, 다른 컨트랙트에서 ERC165Checker를 이용하여 해당 컨트랙트가 위 ERC721의 인터페이스가 구현된 컨트랙트임을 확인할 수 있다.

/// @dev Note: the ERC-165 identifier for this interface is 0x150b7a02.
interface ERC721TokenReceiver {
    function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) external returns(bytes4);
}

onERC721Received()는 safe transfer를 위해 필요한 인터페이스로, safe transfer를 통해 NFT 토큰을 전송받을 컨트랙트(Receiver)에서는 해당 인터페이스를 꼭 구현해줘야 한다.

function safeTransferFrom(
    address from,
    address to,
    uint256 tokenId,
    bytes memory data
) public virtual override {
    require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner nor approved");
    _safeTransfer(from, to, tokenId, data);
}

NFT 토큰을 전송하려는 개체(Sender)가 토큰 전송을 위해 safeTransferFrom() 호출 시, 내부적으로 _safeTransfer()가 호출되고 해당 함수는 토큰을 전송하기 전에 Receiver 컨트랙트에 onERC721Received() 인터페이스가 구현되었는지 require문을 통해 체크한다.

/// @title ERC-721 Non-Fungible Token Standard, optional metadata extension
/// @dev See https://eips.ethereum.org/EIPS/eip-721
///  Note: the ERC-165 identifier for this interface is 0x5b5e139f.
interface ERC721Metadata /* is ERC721 */ {
    function name() external view returns (string _name);

    function symbol() external view returns (string _symbol);

    function tokenURI(uint256 _tokenId) external view returns (string);
}

name()은 ERC721 NFT 토큰 이름(ex. Bored Ape Yacht Club)을 의미하고, symbol()은 ERC721 NFT 토큰의 심볼(ex. BAYC)을 의미한다.

tokenURI()는 토큰 ID를 인자로 입력받아 해당 NFT 토큰의 JSON 객체 정보가 담긴 URI를 반환해준다.

BAYC#1의 tokenURI()는 이더스캔에서 확인 할 수 있으며, “ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/1” 값을 가진다.

image1

해당 IPFS 값을 조회해보면 아래와 같이 JSON 객체 정보를 확인할 수 있으며, OpenSea에서 확인한 BAYC#1과 동일한 특성 정보를 가진 것을 확인할 수 있다.

image2

image3


3. Web3 기반 수집 코드 개발

이더리움 로컬 풀노드(Full Node) 구동

이더리움은 탈중앙화된 시스템이며, 이더리움의 노드는 블록체인 네트워크에서 발생한 모든 거래를 검증하고 채굴자가 채굴한 블록이 합당한지 검증(PoW 기준)하는 역할을 수행한다.

블록체인 데이터베이스를 저장 및 관리하고 있는 중앙화된 시스템이 없고, 수많은 분산화된 노드를 이용해 데이터베이스를 유지/관리한다.

풀 노드는 이더리움 네트워크의 제네시스 블록(Genesis Block, 블록체인 네트워크의 첫번째 블록)부터 현재까지의 모든 블록을 저장하고 있는 노드로 모든 블록(거래내역)이 저장되어 있기 때문에 스스로 거래를 검증할 수 있다.

이더리움 블록체인 및 네트워크에 접근하기 위해서는 노드가 필요 하다.

Infura나 Alchemy와 같은 이더리움 게이트웨이 역할을 수행하는 서비스를 통해 이더리움 네트워크에 접근해도 되지만, 속도가 매우 느리고 무료로 이용할 경우 쿼리 수에 제한이 있다.

나의 경우 로컬 PC에 geth를 이용하여 풀노드를 구축해 사용 중이며, 로컬 풀노드를 통해 빠른 속도로 이더리움 네트워크에 접근하여 블록 및 데이터를 조회할 수 있다.

  • (실제로 로컬노드와 Infura와 같은 상용노드에 같은 쿼리를 날려보면, 꽤 성능 차이가 발생하고 로컬노드를 사용하는 것이 매우 효율적이고 빠른 것을 확인할 수 있다)

image4

이더리움 노드 및 컨트랙트 설정

let http_transport = web3::transports::Http::new("http://localhost:8545")?;

let bayc_addr = "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d";
let addr = Address::from_str(bayc_addr).parse().unwrap();
let contract = Contract::from_json(web3s.lock().unwrap().eth(), addr, include_bytes!("../../../metadata/artifacts/token/ERC721/ERC721.json")).unwrap();
  • Web3(JSON-RPC 기반) 쿼리를 전송하기 위해 로컬 이더리움 풀노드에 연결한다.

  • 컨트랙트 주소와 JSON ABI 파일을 이용해 홀더 목록을 수집하고자 하는 컨트랙트에 연결한다.

NFT 컨트랙트의 발행 수량 획득

// Get Total Supply of NFT
let mut total_supply: u64;
let r: Result<U256, web3::contract::Error> = contract.query("totalSupply", (), None, Options::default(), None).await;
match r {
    Ok(ts) => total_supply = ts.as_u64(),
    _ => total_supply = 10000,
}
  • 대부분의 ERC721 스마트컨트랙트가 토큰의 총 발행수량을 totalSupply 라는 상태변수에 유지하기 때문에, 해당 값을 스마트컨트랙트의 스토리지부터 읽어온다.
    • ex> totalSupply 값이 5555인 경우, 토큰은 #1~#5555까지 총 5555개 발행된 것으로 간주

각 토큰을 소유한 지갑주소(홀더) 획득

let mut hm_holder: HashMap<Address, u64> = HashMap::new();
let mtx = Arc::new(Mutex::new(hm_holder));

// task split
let num_of_threads = 10;
let range = total_supply / num_of_threads;
let mut range_list = (0..total_supply).step_by(range as usize).collect::<Vec<u64>>();
range_list.pop(); range_list.push(total_supply+1);

let range_list = range_list.windows(2).map(|w| (w[0], w[1])).collect::<Vec<(u64,u64)>>();

// collect wallet address to hashmap (async thread)
let mut handles = vec![];
for i in range_list {
    let mtx_c = Arc::clone(&mtx);
    let web3s_c = Arc::clone(&web3s);

    let handle = tokio::spawn(async move { holder_crawl_thread(addr, i.0, i.1, mtx_c, web3s_c).await });
    handles.push(handle);
}
for handle in handles {
    tokio::join!(handle);
}
  • 순차적으로 #1~#10000 토큰을 소유한 지갑주소를 쿼리하는 것은 시간이 오래걸리므로, 비동기 멀티쓰레딩을 이용한다. (1~1000, 1001~2000, …, 9001~10000)
    • rust에서 비동기 쓰레드 사용 시 동일 개체 접근 및 값 변경을 위해 ArcMutex를 사용
async fn holder_crawl_thread(contract_addr: Address, start_tokenid: u64, end_tokenid: u64, mtx_c: Arc<Mutex<HashMap<Address, u64>>>, web3s_c: Arc<Mutex<web3::Web3<web3::transports::Http>>>) {
    for tid in start_tokenid..end_tokenid {
        let contract = Contract::from_json(web3s_c.lock().unwrap().eth(), contract_addr, include_bytes!("../../../metadata/artifacts/token/ERC721/ERC721.json")).unwrap();
        let id: U256 = U256::from(tid);                    
        let r: Result<Address, web3::contract::Error> = contract.query("ownerOf", (id,), None, Options::default(), None).await;
        match r {
            Ok(addr) => {
                            let mut hm = mtx_c.lock().unwrap();
                            let cnt = hm.entry(addr).or_insert(0);
                            *cnt += 1;
                        },
            Err(_) => {},
        }
    }
}
  • 각 쓰레드는 자신이 쿼리할 토큰 ID의 시작과 끝을 전달받으며, 해당 범위 내에서 순차적으로 컨트랙트에 접근하여 토큰을 소유한 지갑 주소를 획득한다.

  • ERC721 컨트랙트의 onwerOf(uint256) 함수가 토큰을 소유한 지갑 주소를 반환해준다.

  • 획득한 지갑 주소는 Key->Value 를 매핑한 hashmap 구조체에 입력하며, 본 코드에서는 Key(Address)->Value(Token Quantity)로 구성된다.

    • hashmap 구조체에 이미 지갑 주소(Key)가 존재하는 경우 수량을 +1

실행 결과

// totalSupply
println!("Total Supply : {:?}", total_supply);

// Number of Holders
let hm = mtx.lock().unwrap();
println!("# of Holders : {}", hm.len());

// Top 20
let mut v: Vec<_> = hm.clone().into_iter().collect();
v.sort_by(|x,y| y.1.cmp(&x.1));
for i in 0..20 {
    let t = v.get(i).unwrap();
    println!("{:?} : {:?}", t.0, t.1);
}
  • 실행 결과를 확인하기 위해 정보를 출력한다.

    • (1) totalSupply (총 토큰 발행수량)
    • (2) # of Holders (토큰을 가진 홀더 수)
    • (3) 전체 홀더 중 보유 수량 상위 10개의 지갑 주소 및 보유 수량
  • BAYC를 대상으로 홀더 수집 시, 다음과 같은 결과를 확인할 수 있다. (Ethereum Block Number 15152072 기준)

Total Supply : 10000
# of Holders : 6453
0xdbfd76af2157dc15ee4e57f3f942bb45ba84af24 : 260
0x1b523dc90a79cf5ee5d095825e586e33780f7188 : 107
0x54be3a794282c030b15e43ae2bb182e14c409c5e : 105
0xd38a87d7b690323ef6883e887614502abcf9b1eb : 71
0x98e711f31e49c2e50c1a290b6f2b1e493e43ea76 : 71
0xf896527c49b44aab3cf22ae356fa3af8e331f280 : 66
0x8ad272ac86c6c88683d9a60eb8ed57e6c304bb0c : 57
0x020ca66c30bec2c4fe3861a94e4db4a498a35872 : 52
0xed2ab4948ba6a909a7751dec4f34f303eb8c7236 : 49
0x04f5df957ce0405ba0264eca6130161cfaa12571 : 34
0x720a4fab08cb746fc90e88d1924a98104c0822cf : 33
0x7a9fe22691c811ea339d9b73150e6911a5343dca : 31
0xca1257ade6f4fa6c6834fdc42e030be6c0f5a813 : 30
0xd66f8eaf84b11654a19126a98a3f55b960846dd8 : 28
0x1cfb8a2e4c2e849593882213b2468e369271dad2 : 27
  • 이더스캔에서 확인한 값과 동일한 것을 확인할 수 있다. (.eth와 같은 ENS 주소도 resolve해보면 같은 값임)

image5

image6

4. 두 NFT 토큰 간의 Mutual Holder 확인

구현한 기능을 기반으로, 서로 다른 NFT 프로젝트의 Mutual Holder를 확인할 수 있다.

Mutual Holder는 A NFT 프로젝트의 토큰도 가지고 있고, B NFT 프로젝트의 토큰도 가지고 있는 홀더를 의미한다.

쉽게 다시 설명하면 Mutual Holder 확인 기능은 BAYC NFT 토큰 홀더 중, Doodles NFT 토큰을 가지고 있는 지갑 주소와 보유 수량을 확인하는 것이다.

이러한 기능은 고래 지갑 추적과 유사하게 NFT 블루칩 프로젝트의 토큰을 가진 지갑들이, 다른 특정 신규 NFT 프로젝트의 토큰을 얼마나 가지고 있는지 확인할 수 있다.

(블루칩 프로젝트 홀더들의 알파가 무엇인지 빠르게 체크하여 2차 거래에 참고하는 목적 등 활용 가능)

코드

use web3::contract::{Contract, Options};
use web3::types::{Address, U256};
// use web3::contract::Error;


use std::str::FromStr;
use std::collections::HashMap;

use std::sync::{Mutex, Arc};

use std::collections::HashSet;

#[tokio::main]
async fn main() -> web3::Result<()> {
    // BAYC Holder Crawl
    println!("[+] BAYC");
    let bayc_holder_tuple = holder_crawl("0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D");      // BAYC
    let bayc_holder_tuple = bayc_holder_tuple.await.unwrap();
    let bayc_holder = bayc_holder_tuple.0;
    let _num_of_nfts_bayc = bayc_holder_tuple.1;


    // Doodles Holder Crawl
    println!("[+] Doodles");
    let doodles_holder_tuple = holder_crawl("0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e");      // Doodles
    let doodles_holder_tuple = doodles_holder_tuple.await.unwrap();
    let doodles_holder = doodles_holder_tuple.0;
    let num_of_nfts_doodles = doodles_holder_tuple.1;

    // Mutual Holder Calculation
    let unique_bayc = bayc_holder.keys().collect::<HashSet<_>>();
    let unique_doodles = doodles_holder.keys().collect::<HashSet<_>>();

    let intersect = unique_bayc.intersection(&unique_doodles).collect::<Vec<_>>();

    let num_of_h = intersect.len();
    let mut num_of_nft = 0;
    for s in intersect {
        num_of_nft += doodles_holder.get(&s).unwrap();
    }

    // Check
    println!("[+] {} BAYC Holders({:.3}%) have {} NFTs({:.3}%) of Doodles", 
                    num_of_h, 
                    (num_of_h as f64 / doodles_holder.len() as f64 * 100 as f64), 
                    num_of_nft, 
                    (num_of_nft as f64 / num_of_nfts_doodles as f64) * 100 as f64);

    Ok(())
}

async fn holder_crawl(contract_address: &str) -> web3::Result<(HashMap<Address,u64>, u64)> {
    // Ethereum Node Set
    let http_transport = web3::transports::Http::new("http://localhost:8540")?;
    let w3s = web3::Web3::new(http_transport);
    let web3s = Arc::new(Mutex::new(w3s));

    // Contract Load
    let addr = Address::from_str(contract_address).unwrap();
    let contract = Contract::from_json(web3s.lock().unwrap().eth(), addr, include_bytes!("../../metadata/artifacts/token/ERC721/ERC721.json")).unwrap();
    
    // Get Total Supply of NFT
    let total_supply: u64;
    let r: Result<U256, web3::contract::Error> = contract.query("totalSupply", (), None, Options::default(), None).await;
    match r {
        Ok(ts) => total_supply = ts.as_u64(),
        _ => total_supply = 10000,
    }
    print!("Total Supply : {:?} / ", total_supply);

    // Get Owner List & balance
    let hm_holder: HashMap<Address, u64> = HashMap::new();
    let mtx = Arc::new(Mutex::new(hm_holder));

    // task split
    let num_of_threads = 10;
    let range = total_supply / num_of_threads;
    let mut range_list = (0..total_supply).step_by(range as usize).collect::<Vec<u64>>();
    range_list.pop(); range_list.push(total_supply+1);

    let range_list = range_list.windows(2).map(|w| (w[0], w[1])).collect::<Vec<(u64,u64)>>();
    
    // async thread 
    let mut handles = vec![];
    for i in range_list {
        let mtx_c = Arc::clone(&mtx);
        let web3s_c = Arc::clone(&web3s);

        let handle = tokio::spawn(async move { holder_crawl_thread(addr, i.0, i.1, mtx_c, web3s_c).await });
        handles.push(handle);
    }
    for handle in handles {
        tokio::join!(handle);
    }

    let hm = mtx.lock().unwrap();
    println!("Holders : {}", hm.len());
    
    Ok((hm.clone(), total_supply))

}

async fn holder_crawl_thread(contract_addr: Address, start_tokenid: u64, end_tokenid: u64, mtx_c: Arc<Mutex<HashMap<Address, u64>>>, web3s_c: Arc<Mutex<web3::Web3<web3::transports::Http>>>) {
    for tid in start_tokenid..end_tokenid {
        let contract = Contract::from_json(web3s_c.lock().unwrap().eth(), contract_addr, include_bytes!("../../metadata/artifacts/token/ERC721/ERC721.json")).unwrap();
        let id: U256 = U256::from(tid);                    
        let r: Result<Address, web3::contract::Error> = contract.query("ownerOf", (id,), None, Options::default(), None).await;
        match r {
            Ok(addr) => {
                            let mut hm = mtx_c.lock().unwrap();
                            let cnt = hm.entry(addr).or_insert(0);
                            *cnt += 1;
                        },
            Err(_) => {},
        }
    }
}

결과

> cargo run --release
...
...
    Finished release [optimized] target(s) in 37.71s
     Running `target\release\Erc721HolderCrawl_Web3.exe`

[+] BAYC
Total Supply : 10000 / Holders : 6453
[+] Doodles
Total Supply : 10000 / Holders : 5207
[+] 543 BAYC Holders(10.428%) have 1228 NFTs(12.280%) of Doodles

(Ethereum Block Number 15152171 기준)

BAYC 토큰을 가진 6453개의 지갑 중 543개의 지갑이 Mutual Holder로써 Doodles 토큰을 가지고 있다.

해당 Mutual Holder 543개의 지갑은 5207개의 Doodle 홀더 중에 10.428% 비중이며, Doodles 토큰 10000개중에 1228개(12.280%)를 가지고 있다.