概要
こんにちは、サイオステクノロジーの安藤 浩です。 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 の所有者だと分かります。
ロジック
ロジックとしては以下のようになると思います。
- 現在のブロック番号から過去にさかのぼって、指定のブロック数(例: 3000ブロックの間)ごとに コントラクトアドレス に対応する
event Transfer
のイベントログを取得する。 - 指定のブロック数内に 検索対象の トークンID が存在しなければ次のブロックの間の
event Transfer
のイベントログを取得する。 - 指定のブロック数内に 検索対象の トークン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 TransferSingle
, event TransferBatch
の2つがあるので、対象の コントラクトアドレス 、トークンID のすべてのイベントログ取得してきて、関連するアドレスの受け取った総量を計算して、送った総量を計算し、当該のアドレスの (受け取った総量 - 送った総量 )
が0より大きいならERC-1155 を所有していることになります。
※ event TransferBatch
に関しては _ids
が配列で indexed ではないので絞って検索できず、全件とることになりそうだと思います。
ロジック
ロジックとしては以下のようになると思います。
- Contractが生成されたと思われるよりも前のブロック数から現在のブロックにさかのぼって、指定のブロック数ごとに コントラクトアドレス に対応する
event TransferSingle
とevent TransferBatch
のイベントログを取得する。 - 指定のブロック数内に 検索対象の トークンID が存在しなければ次のブロックの間の
event TransferSingle
とevent TransferBatch
のイベントログを取得する。 - 指定のブロック数内に 検索対象の トークンID が存在したら、
event TransferSingle
とevent TransferBatch
のイベントログを記録する。※event TransferBatch
は対象でない トークンID が含まれる可能性があり、複数の EOA に送信される可能性があるため、ids
とvalues
を検索対象の トークンID のみに絞り、ids
ごとにイベントログを記録する。 - 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 が所有する数量とする。
- 4-3 の所有する数量が0より大きければ、コントラクトアドレス と トークンID に対する所有者とする。
コードスニペット(typescript)
ERC-1155 の場合、指定のブロック数ごとに コントラクトアドレス に対応する event TransferSingle
と event TransferBatch
のイベントログをすべて取得する必要があり、両方のイベントログのフォーマットを統一してDBに入れてから、イベントログを検索するようにしたいと思います。
以下にスニペットでは表に記載の 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 |
mysql2 | 3.14.0 |
reflect-metadata | 0.2.2 |
typeorm | 0.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に対応するイベントログのto
,from
のアドレスをすべて取得します。※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で実施しましたが、メインネットでもロジック自体は変わらないかと思います。