[web3] Ethereum の Sepolia上のイベントログから ERC-721, ERC-1155 の所有者の見つけ方

概要

こんにちは、サイオステクノロジーの安藤 浩です。 Ethereum のテストネット: Sepolia でフルノードを構築したので フルノードから取得できるトランザクションログやイベントログから ERC-721, ERC-1155 の所有者を調べる方法をご紹介します。

前提条件

フルノードが用意されていること。

フルノードの構築方法についてはこちらのブログを参照ください。

アプローチ方法

NFT の所有者を調べるには、 ERC-721 では メソッド: ownerOf があるので取得可能ですが、ノードで取得できるトランザクションログやイベントログからNFTの所有者を特定する方法を考えてみます。

ここでは、ERC-721 や ERC-1155 が主流だと思いますので、これらの所有者を調べる方法を検討してみます。

ERC-721 の場合

ERC-721 の規格に関しては以下に記載があり、event が定義されています。

event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);

_tokenId の所有者を変更する際に event を発行することになっています。 送信元: _from から 送信先: _to に どのトークンID: _tokenId を送信したかをイベントログとして残しています。

また、 indexed の付いたパラメータはそれぞれのパラメータで検索可能です。

例えば、イベントログはEtherscanだとこのようになります。

イベントログで直近の送信先: _to が分かれば、ERC-721 の所有者だと分かります。

ロジック

ロジックとしては以下のようになると思います。

  1. 現在のブロック番号から過去にさかのぼって、指定のブロック数(例: 3000ブロックの間)ごとに コントラクトアドレス に対応する event Transfer のイベントログを取得する。
  2. 指定のブロック数内に 検索対象の トークンID が存在しなければ次のブロックの間の event Transfer のイベントログを取得する。
  3. 指定のブロック数内に 検索対象の トークンID が存在したら、イベントログの To ( EOA ) を現在の所有者とする。

コードスニペット(typescript)

以下に主な箇所のスニペットを記載します。ここでは表に記載のnpm パッケージを利用しています。

npm パッケージ名 version
@types/node 22.13.10
nodemon 3.1.9
ts-node 10.9.2
typescript 5.8.2
ethers 6.13.5

この関数では、引数に対象のコントラクトアドレスとトークンID、開始ブロック番号、終了ブロック番号を指定します。

event.topics[3] がイベントログのトークンIDを示しているので、その値と引数で指定したトークンIDが一致するか比較します。一致していたら、To ( (<ethers.Log>lastEvent).topics[2] ) に対応するアドレスをERC-721 の所有者とします。

export async function getEventTransferFilter(contractAddress: string, tokenId: bigint,
     fromBlock: number = 0, toBlock: number = 0): Promise<EventFilterLog | null> {
   try {
        const contract = new ethers.Contract(contractAddress, [TRANSFER_EVENT_SIGNATURE], provider);
        const filter = contract.filters.Transfer(null, null, null);
        const events = await contract.queryFilter(filter, fromBlock, toBlock);
        if(events.length === 0) {
            return null;
        }
        const matchingEvents = events.filter(event => {
            const eventTokenId = ethers.getBytes(event.topics[3]);
            const inputTokenId = ethers.getBytes(ethers.toBeHex(tokenId));
            return ethers.zeroPadValue(eventTokenId, 32) === ethers.zeroPadValue(inputTokenId, 32); // TokenId と一致判定
        });
        if (matchingEvents.length > 0) {
            const lastEvent = matchingEvents[matchingEvents.length - 1];
            const lastToAddress = (<ethers.Log>lastEvent).topics[2];
            const lastTokenId = BigInt((<ethers.Log>lastEvent).topics[3]);
            return {
                tokenId: lastTokenId,
                toAddress: lastToAddress,
                val: BigInt(1),
                transactionHash: lastEvent.transactionHash,
                blockNumber: lastEvent.blockNumber
            } as EventFilterLog;
        } else {
            console.log("No matching events found.");
            return null;
        }
    } catch (error) {
        console.error("Error:", error);
        return null;
    }
}

ERC-1155 の場合

ERC-1155 の規格に関しては以下に記載があります。

移転に関する event TransferSingle , event TransferBatch が定義されています。

event TransferSingle(address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value);

event TransferSingle は トークンIDが移転された際に発行されるイベントです。パラメータは以下の通りです。

パラメータ 説明
_operator トランザクションを実行したアドレス
_from トークンの送信元アドレス
_to トークンの送信先アドレス
_id 転送されたトークンID
_value 転送されたトークンの数量

event TransferBatch(address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values);

event TransferBatch は、複数のトークンを一度に移動させる際に発行されるイベントです。パラメータは以下の通りです。

パラメータ 説明
_operator トランザクションを実行したアドレス
_from トークンの送信元アドレス
_to トークンの送信先アドレス
_ids 転送されたトークンIDの配列
_values それぞれのトークンIDに対応する数量の配列 (例: _ids[0] のトークンが数量: _values[0] を転送するという意味になります)

event TransferSingleevent TransferBatch の2つがあるので、対象の コントラクトアドレス 、トークンID のすべてのイベントログ取得してきて、関連するアドレスの受け取った総量を計算して、送った総量を計算し、当該のアドレスの (受け取った総量 - 送った総量 ) が0より大きいならERC-1155 を所有していることになります。

※ event TransferBatch に関しては _ids が配列で indexed ではないので絞って検索できず、全件とることになりそうだと思います。

ロジック

ロジックとしては以下のようになると思います。

  1. Contractが生成されたと思われるよりも前のブロック数から現在のブロックにさかのぼって、指定のブロック数ごとに コントラクトアドレス に対応する event TransferSingle と event TransferBatch のイベントログを取得する。
  2. 指定のブロック数内に 検索対象の トークンID が存在しなければ次のブロックの間の event TransferSingle と event TransferBatch のイベントログを取得する。
  3. 指定のブロック数内に 検索対象の トークンID が存在したら、event TransferSingle と event TransferBatch のイベントログを記録する。※ event TransferBatch は対象でない トークンID が含まれる可能性があり、複数の EOA に送信される可能性があるため、ids と values を検索対象の トークンID のみに絞り、ids ごとにイベントログを記録する。
  4. 3で記録したイベントログ内でコントラクトアドレス と トークンID が一致する EOA をすべて取得する。
    • 以下の4-1 ~ 4-3 をすべての EOA に対して行う。※ Zero Addressは除く。
    • 4-1. from_address が EOA と一致する場合は 合計値を算出 する。
    • 4-2. to_address が EOA と一致する場合は 合計値を算出 する。
    • 4-3. (4-2 の値) – (4-1 の値) として、 EOA が所有する数量とする。
  5. 4-3 の所有する数量が0より大きければ、コントラクトアドレス と トークンID に対する所有者とする。

コードスニペット(typescript)

ERC-1155 の場合、指定のブロック数ごとに コントラクトアドレス に対応する event TransferSingle と event TransferBatch のイベントログをすべて取得する必要があり、両方のイベントログのフォーマットを統一してDBに入れてから、イベントログを検索するようにしたいと思います。

以下にスニペットでは表に記載の npm パッケージを利用しています。

npm パッケージ名version
@types/node22.13.10
nodemon3.1.9
ts-node10.9.2
typescript5.8.2
ethers6.13.5
mysql23.14.0
reflect-metadata0.2.2
typeorm0.3.21
import 'reflect-metadata';
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('EventTransferSingleAndBatch')
export class EventTransferSingleAndBatch {
    @PrimaryGeneratedColumn()
    id!: bigint;

    @Column({ type: 'varchar', length: 255 })
    contract_address!: string;

    @Column({ type: 'bigint' })
    token_id!: bigint;

    @Column({ type: 'varchar', length: 255 })
    from_address!: string;

    @Column({ type: 'varchar', length: 255 })
    to_address!: string;

    @Column({ type: 'bigint' })
    val!: bigint;

    @Column({ type: 'varchar', length: 255 })
    transaction_hash!: string;

    @Column({ type: 'int' })
    block_number!: number;

    @Column({ type: 'varchar', length: 255, nullable: true })
    event_hash!: string;
}

public async processEventLogs(): Promise<void> {
    const startBlockNumber = this.contractBlockNumber === null ? 0 : this.contractBlockNumber;
    for (let i = startBlockNumber; i < this.currentBlockNumber; i += this.iter) {
        let toBlock = i + this.iter;
        let fromBlock = i;
        console.log(`ERC1155: ${this.contractAddress}; ${this.tokenId}. Fetching events from block ${fromBlock} to ${toBlock}...`);
        //NOTE: すべてのEvent ログを取得して、DB内に保存する。
        let eventTransferSingleFilter = await getEventTransferSingleFilter(this.contractAddress, this.tokenId, fromBlock, toBlock);  // ロジックの1,2の箇所に対応
        let eventTransferBatchFilter = await getEventTransferBatchFilter(this.contractAddress, this.tokenId, fromBlock, toBlock);  // ロジックの1,2の箇所に対応
        eventTransferSingleFilter?.forEach(async (eventTransferSingle: EventFilterLog) => {
            const existEventTransferSingleAndBatch = await getEventTransferSingleAndBatch(this.contractAddress, this.tokenId, eventTransferSingle);
            if(existEventTransferSingleAndBatch === null) {
                await insertEventTransferSingleAndBatch(this.contractAddress, this.tokenId, eventTransferSingle);
            }
        });
        
        eventTransferBatchFilter?.forEach(async (eventTransferBatch: EventTransferBatchFilterLog) => {
            eventTransferBatch.tokenIds.forEach(async (eventTokenId: bigint, idx: number) => {
                const eventTransferLog: EventFilterLog = {
                    tokenId: eventTokenId,
                    fromAddress: eventTransferBatch.fromAddress,
                    toAddress: eventTransferBatch.toAddress,
                    val: eventTransferBatch.vals[idx],
                    transactionHash: eventTransferBatch.transactionHash,
                    blockNumber: eventTransferBatch.blockNumber,
                    eventHash: eventTransferBatch.eventHash
                }

                const existEventTransferSingleAndBatch = await getEventTransferSingleAndBatch(this.contractAddress, this.tokenId, eventTransferLog);
                if(existEventTransferSingleAndBatch === null) {
                    await insertEventTransferSingleAndBatch(this.contractAddress, this.tokenId, eventTransferLog);
                }
            });
        });
    }
}

説明(ロジック1~3)

  • getEventTransferSingleFilter , getEventTransferBatchFilter の箇所がロジックの1,2に対応します。
  • EventTransferSingleAndBatch のEntityを作成し、insertEventTransferSingleAndBatch で対象のイベントログをすべてインサートしていきます。(ロジックの3に対応)
//NOTE: DB内に保存したEvent ログを元に、OwnerAddress を取得する。
public async processNftOwnerAddressForEventLog(): Promise<void> {
    const accountAddresses = await getAccountAddressesEventTransferSingleAndBatch(this.contractAddress, this.tokenId);
    accountAddresses?.forEach(async (accountAddress: string) => {
        //NOTE: 0x0000000000000000000000000000000000000000 が Owner というのは変なので無視して良さそう。
        if(accountAddress === "0x0000000000000000000000000000000000000000") {
            console.log("accountAddress is zero address. skip");
            return;
        }
        const accountTotalVal = await getTotalValEventTransferSingleAndBatch(this.contractAddress, this.tokenId, accountAddress);
        if(accountTotalVal !== null && accountTotalVal.val > 0){
            const ownerRecords: Owner[] | null = await getOwnerByAccountAddress(this.contractAddress, this.tokenId, accountAddress);
            
            if(ownerRecords === null || ownerRecords.length === 0) {
                await insertOwnerAndNftsOwners(this.contractAddress, this.tokenId, 
                    accountAddress, accountTotalVal.val, 
                    accountTotalVal.last_tx_hash, accountTotalVal.last_block_number);
            } else {
                ownerRecords.forEach((ownerRecord: Owner) => {
                    console.log("ownerRecord: ", ownerRecord);
                })
            }
            
        }
    });
}

export async function getAccountAddressesEventTransferSingleAndBatch(contractAddress: string, tokenId: bigint): Promise<string[] | null> {
    return connection.then(async (conn: DataSource) => {
        
        const eventTransferSingleAndBatches = await conn.getRepository(EventTransferSingleAndBatch)
            .find(
                { where: 
                    { 
                        contract_address: contractAddress, token_id: tokenId
                    },
                    order: { block_number: "ASC" }
                }
            );
        let accountAddresses: string[] = [];
        
        eventTransferSingleAndBatches.forEach((eventTransferSingleAndBatch: EventTransferSingleAndBatch) => {
            accountAddresses.push(eventTransferSingleAndBatch.from_address);
            accountAddresses.push(eventTransferSingleAndBatch.to_address);
        });

        return Array.from(new Set(accountAddresses));
    })
    .catch((error: any) => {
        console.error('データベース接続エラー:', error);
        return null;
    });
}

export async function getTotalValEventTransferSingleAndBatch(
    contractAddress: string, tokenId: bigint, accountAddress: string): Promise<Owner | null> {
    return await connection.then(async (conn: DataSource) => {
        
        const eventTransferSingleAndBatches = await conn.getRepository(EventTransferSingleAndBatch)
            .find(
                { where: 
                    { 
                        contract_address: contractAddress, token_id: tokenId
                    },
                    order: { block_number: "asc" }
                }
            );
        const eventTransferSingleAndBatchesForFromAddress = eventTransferSingleAndBatches.filter(
            (event: EventTransferSingleAndBatch) => {
                return event.from_address === accountAddress;
            });
        const eventTransferSingleAndBatchesForToAddress = eventTransferSingleAndBatches.filter(
            (event: EventTransferSingleAndBatch) => {
                return event.to_address === accountAddress;
            });
        let eventTransferTotalVal: bigint = BigInt(0);
        
        eventTransferSingleAndBatchesForFromAddress.forEach((eventTransferSingleAndBatch: EventTransferSingleAndBatch) => {
            eventTransferTotalVal -= BigInt(eventTransferSingleAndBatch.val);
        });

        eventTransferSingleAndBatchesForToAddress.forEach((eventTransferSingleAndBatch: EventTransferSingleAndBatch) => {
            eventTransferTotalVal += BigInt(eventTransferSingleAndBatch.val);
        });

        return {
            owner_address: accountAddress,
            val: eventTransferTotalVal,
            last_tx_hash: eventTransferSingleAndBatches[eventTransferSingleAndBatches.length - 1]?.transaction_hash,
            last_block_number: eventTransferSingleAndBatches[eventTransferSingleAndBatches.length - 1]?.block_number
        } as Owner;
    })
    .catch((error: any) => {
        console.error('データベース接続エラー:', error);
        return null;
    });
}

説明(ロジック4,5)

  • getAccountAddressesEventTransferSingleAndBatch でコントラクトアドレスとトークンIDに対応するイベントログのtofrom のアドレスをすべて取得します。※ 0x0000000000000000000000000000000000000000 はZero Address でありMint や Burnで使われるアドレスなので、今回取得したいEOAではないので無視することにします。
  • getTotalValEventTransferSingleAndBatch で ロジック4に対応する計算を行います。
  • eventTransferTotalVal が 4-3 で求めたい総量です。
  • ロジック5では eventTransferTotalVal > 0 の時に ERC-1155の所有者として、 processNftOwnerAddressForEventLog の insertOwnerAndNftsOwners でレコードをインサートしています。

まとめ

規格ごとに所有者を特定する方法を検討しました。簡単にまとめると以下のようになります。

ERC-721の場合

  • Transfer イベントを追跡
  • トークンIDごとに直近のTransfer イベントの送信先を所有者とする

ERC-1155の場合

  • TransferSingleとTransferBatchのイベントを追跡
  • アカウントごとに「受け取った総量」-「送った総量」で所有数を計算
  • 所有数が0より大きい場合に所有者とする。

ERC-721 ではownerOf メソッドがあり、かつ イベントログから容易に所有者を見つけられますが、ERC-1155のような複雑な所有形態でも、イベントログから所有量を計算することで所有者を見つけることができました。

今回、Sepoliaで実施しましたが、メインネットでもロジック自体は変わらないかと思います。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です