Semantic WebとNFT

Semantic Web(セマンティック・ウェブ)は、Web上のデータに意味(セマンティクス)を付与し、機械が理解・処理できるようにする技術体系です。RDF(Resource Description Framework)やJSON-LD(JSON for Linking Data)といった標準を使って、データの構造と関係性を明示的に記述します。

NFTのメタデータにSemantic Webの技術を適用することで、以下のような利点が得られます。

  • 相互運用性: 異なるシステム間でメタデータの意味を共有できる
  • 来歴の機械可読化: ブロックチェーン上の所有権移転を標準オントロジーで記述できる
  • Linked Data: 他のデータセット(Wikidata、国会図書館等)とリンクできる

本章では、PROV-Oオントロジーを使ってNFTの来歴をRDF/JSON-LDで記述し、ブロックチェーンの不変性とSemantic Webの表現力を組み合わせたメタデータ管理を実践します。

RDFとJSON-LDの基礎

RDFトリプル

RDFはすべての情報を「主語(Subject)- 述語(Predicate)- 目的語(Object)」の3つ組(トリプル)で表現します。

<<<<NNNTFFFXTTT####0000x>>>abc123><<<<ddppccrrttooeevvrr::mmwassat::sTtcGiiremtenelae>etr>oart>edBy>"<<"hT2tX0t#2p04:x-/a0/b1"vc-i11a25f3T.>1o0r:g3/0v:i0a0fZ/"252937213>

JSON-LD

JSON-LDは、通常のJSONにセマンティックな意味を付与する仕組みです。@context で語彙の定義を行い、既存のJSON構造にリンクトデータの機能を追加します。

{
  "@context": {
    "dcterms": "http://purl.org/dc/terms/",
    "prov": "http://www.w3.org/ns/prov#",
    "schema": "https://schema.org/"
  },
  "@id": "https://example.org/nft/0",
  "@type": "schema:VisualArtwork",
  "dcterms:title": "鳥獣戯画 甲巻(部分)",
  "dcterms:creator": {
    "@id": "http://viaf.org/viaf/252937213"
  }
}

PROV-Oオントロジー

PROV-O(The PROV Ontology)は、W3Cが標準化した来歴情報を記述するためのオントロジーです。デジタルオブジェクトの生成・変換・委譲に関する情報を、機械可読な形で記述できます。

PROV-Oの主要クラス

クラス説明NFTでの対応
prov:Entity物理的・デジタル的なものNFTトークン
prov:Activity何かを行う行為ミント、転送
prov:Agent行為の実行者ウォレットアドレス

PROV-Oの主要プロパティ

プロパティ説明
prov:wasGeneratedByEntity が Activity によって生成された
prov:wasAttributedToEntity が Agent に帰属する
prov:wasAssociatedWithActivity が Agent に関連する
prov:atTimeActivity の実行時刻
prov:usedActivity が Entity を使用した

NFT来歴のJSON-LD記述

ブロックチェーンのトランザクション情報をPROV-Oで記述するJSON-LDドキュメントを生成するスクリプトを作成します。

// scripts/generateProvLD.ts
import { ethers } from "hardhat";

interface TransferEvent {
  from: string;
  to: string;
  tokenId: number;
  transactionHash: string;
  blockNumber: number;
  timestamp: number;
}

/**
 * NFTの来歴をJSON-LD形式で生成する
 */
function generateProvenanceLD(
  contractAddress: string,
  tokenId: number,
  events: TransferEvent[],
  nftMetadata: {
    name: string;
    description: string;
    image: string;
  }
): object {
  const nftId =
    `ethereum:${contractAddress}/token/${tokenId}`;

  // エージェント(ウォレットアドレス)の一覧を抽出
  const agentAddresses = new Set<string>();
  for (const event of events) {
    if (event.from !== ethers.ZeroAddress) {
      agentAddresses.add(event.from);
    }
    agentAddresses.add(event.to);
  }

  // エージェントのJSON-LD表現
  const agents = Array.from(agentAddresses).map((addr) => ({
    "@id": `ethereum:address:${addr}`,
    "@type": "prov:Agent",
    "rdfs:label": `Ethereum Account ${addr.slice(0, 8)}...`,
    "schema:identifier": addr,
  }));

  // アクティビティ(トランザクション)のJSON-LD表現
  const activities = events.map((event, index) => {
    const isMint = event.from === ethers.ZeroAddress;
    const activityId =
      `ethereum:tx:${event.transactionHash}`;

    const activity: any = {
      "@id": activityId,
      "@type": ["prov:Activity"],
      "rdfs:label": isMint
        ? `NFT Minting (Token #${event.tokenId})`
        : `NFT Transfer (Token #${event.tokenId})`,
      "prov:atTime": new Date(
        event.timestamp * 1000
      ).toISOString(),
      "prov:wasAssociatedWith": {
        "@id": `ethereum:address:${event.to}`,
      },
      "schema:identifier": event.transactionHash,
      "custom:blockNumber": event.blockNumber,
    };

    if (isMint) {
      activity["@type"].push("custom:MintActivity");
      activity["prov:generated"] = { "@id": nftId };
    } else {
      activity["@type"].push("custom:TransferActivity");
      activity["prov:used"] = { "@id": nftId };
      activity["custom:from"] = {
        "@id": `ethereum:address:${event.from}`,
      };
      activity["custom:to"] = {
        "@id": `ethereum:address:${event.to}`,
      };
    }

    return activity;
  });

  // NFT(Entity)のJSON-LD表現
  const nftEntity: any = {
    "@id": nftId,
    "@type": ["prov:Entity", "schema:VisualArtwork"],
    "rdfs:label": nftMetadata.name,
    "dcterms:title": nftMetadata.name,
    "dcterms:description": nftMetadata.description,
    "schema:image": nftMetadata.image,
    "schema:identifier": `${contractAddress}#${tokenId}`,
  };

  // ミントイベントがあれば wasGeneratedBy を設定
  const mintEvent = events.find(
    (e) => e.from === ethers.ZeroAddress
  );
  if (mintEvent) {
    nftEntity["prov:wasGeneratedBy"] = {
      "@id": `ethereum:tx:${mintEvent.transactionHash}`,
    };
    nftEntity["prov:wasAttributedTo"] = {
      "@id": `ethereum:address:${mintEvent.to}`,
    };
    nftEntity["prov:generatedAtTime"] = new Date(
      mintEvent.timestamp * 1000
    ).toISOString();
  }

  // 最新の所有者
  const latestEvent = events[events.length - 1];
  if (latestEvent) {
    nftEntity["custom:currentOwner"] = {
      "@id": `ethereum:address:${latestEvent.to}`,
    };
  }

  // JSON-LDドキュメント全体
  return {
    "@context": {
      "prov": "http://www.w3.org/ns/prov#",
      "dcterms": "http://purl.org/dc/terms/",
      "schema": "https://schema.org/",
      "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
      "custom": "https://example.org/ontology/nft#",
      "ethereum": "https://etherscan.io/",
    },
    "@graph": [nftEntity, ...agents, ...activities],
  };
}

async function main() {
  const contractAddress = process.env.CONTRACT_ADDRESS!;
  const tokenId = 0;

  // ブロックチェーンからイベントを取得
  const contract = await ethers.getContractAt(
    "CulturalNFT",
    contractAddress
  );

  const filter = contract.filters.Transfer(null, null, tokenId);
  const rawEvents = await contract.queryFilter(filter);

  const events: TransferEvent[] = [];
  for (const event of rawEvents) {
    const block = await event.getBlock();
    const args = (event as any).args;
    events.push({
      from: args.from,
      to: args.to,
      tokenId: Number(args.tokenId),
      transactionHash: event.transactionHash,
      blockNumber: event.blockNumber,
      timestamp: block.timestamp,
    });
  }

  // tokenURIを取得
  const tokenURI = await contract.tokenURI(tokenId);

  // JSON-LDの生成
  const jsonld = generateProvenanceLD(
    contractAddress,
    tokenId,
    events,
    {
      name: "鳥獣戯画 甲巻(部分)",
      description: "国宝・鳥獣人物戯画のデジタル複製",
      image: tokenURI,
    }
  );

  console.log(JSON.stringify(jsonld, null, 2));
}

main().catch(console.error);

生成されるJSON-LDの例

{
  "@context": {
    "prov": "http://www.w3.org/ns/prov#",
    "dcterms": "http://purl.org/dc/terms/",
    "schema": "https://schema.org/",
    "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
    "custom": "https://example.org/ontology/nft#",
    "ethereum": "https://etherscan.io/"
  },
  "@graph": [
    {
      "@id": "ethereum:0x1234.../token/0",
      "@type": ["prov:Entity", "schema:VisualArtwork"],
      "rdfs:label": "鳥獣戯画 甲巻(部分)",
      "dcterms:title": "鳥獣戯画 甲巻(部分)",
      "dcterms:description": "国宝・鳥獣人物戯画のデジタル複製",
      "prov:wasGeneratedBy": {
        "@id": "ethereum:tx:0xabc123..."
      },
      "prov:wasAttributedTo": {
        "@id": "ethereum:address:0x742d..."
      },
      "prov:generatedAtTime": "2024-01-15T10:30:00.000Z"
    },
    {
      "@id": "ethereum:address:0x742d...",
      "@type": "prov:Agent",
      "rdfs:label": "Ethereum Account 0x742d35..."
    },
    {
      "@id": "ethereum:tx:0xabc123...",
      "@type": ["prov:Activity", "custom:MintActivity"],
      "rdfs:label": "NFT Minting (Token #0)",
      "prov:atTime": "2024-01-15T10:30:00.000Z",
      "prov:generated": {
        "@id": "ethereum:0x1234.../token/0"
      }
    }
  ]
}

NFTメタデータへのJSON-LD埋め込み

NFTの標準メタデータにJSON-LDの @context を追加することで、既存のマーケットプレイスとの互換性を保ちながらセマンティックな情報を付与できます。

{
  "@context": {
    "dcterms": "http://purl.org/dc/terms/",
    "schema": "https://schema.org/",
    "prov": "http://www.w3.org/ns/prov#"
  },
  "@type": "schema:VisualArtwork",
  "name": "鳥獣戯画 甲巻(部分)",
  "description": "国宝・鳥獣人物戯画のデジタル複製",
  "image": "ipfs://QmYourImageHash",
  "dcterms:creator": {
    "@id": "http://viaf.org/viaf/252937213",
    "schema:name": "鳥羽僧正覚猷(伝)"
  },
  "dcterms:spatial": {
    "@id": "http://www.wikidata.org/entity/Q2072892",
    "schema:name": "高山寺"
  },
  "dcterms:temporal": "平安時代後期",
  "schema:material": "紙本墨画",
  "attributes": [
    { "trait_type": "Institution", "value": "高山寺" },
    { "trait_type": "Period", "value": "平安時代後期" }
  ]
}

@context が存在しないJSONパーサー(OpenSea等)は @context フィールドを単に無視するため、後方互換性が保たれます。一方、JSON-LD対応のツールは dcterms:creatordcterms:spatial のリンクをたどって関連情報を取得できます。

PythonでRDFグラフを構築する

より本格的なRDF処理には、Pythonの rdflib ライブラリが便利です。

# scripts/build_rdf_graph.py
from rdflib import Graph, Namespace, Literal, URIRef, BNode
from rdflib.namespace import RDF, RDFS, DCTERMS, XSD, PROV
from datetime import datetime
import json

# 名前空間の定義
SCHEMA = Namespace("https://schema.org/")
CUSTOM = Namespace("https://example.org/ontology/nft#")
ETH = Namespace("https://etherscan.io/")

def build_provenance_graph(
    contract_address: str,
    token_id: int,
    events: list,
    metadata: dict,
) -> Graph:
    """NFTの来歴情報をRDFグラフとして構築する"""

    g = Graph()

    # 名前空間のバインド
    g.bind("prov", PROV)
    g.bind("dcterms", DCTERMS)
    g.bind("schema", SCHEMA)
    g.bind("custom", CUSTOM)
    g.bind("eth", ETH)

    # NFT Entity
    nft_uri = URIRef(
        f"https://etherscan.io/{contract_address}/token/{token_id}"
    )
    g.add((nft_uri, RDF.type, PROV.Entity))
    g.add((nft_uri, RDF.type, SCHEMA.VisualArtwork))
    g.add((nft_uri, DCTERMS.title,
           Literal(metadata["name"], lang="ja")))
    g.add((nft_uri, DCTERMS.description,
           Literal(metadata["description"], lang="ja")))

    for event in events:
        tx_uri = URIRef(
            f"https://etherscan.io/tx/{event['transactionHash']}"
        )
        agent_uri = URIRef(
            f"https://etherscan.io/address/{event['to']}"
        )

        # Activity
        g.add((tx_uri, RDF.type, PROV.Activity))
        g.add((tx_uri, PROV.atTime,
               Literal(event["timestamp"],
                       datatype=XSD.dateTime)))
        g.add((tx_uri, PROV.wasAssociatedWith, agent_uri))

        # Agent
        g.add((agent_uri, RDF.type, PROV.Agent))
        g.add((agent_uri, RDFS.label,
               Literal(f"Account {event['to'][:10]}...")))

        is_mint = (
            event["from"]
            == "0x0000000000000000000000000000000000000000"
        )
        if is_mint:
            g.add((tx_uri, RDF.type, CUSTOM.MintActivity))
            g.add((nft_uri, PROV.wasGeneratedBy, tx_uri))
            g.add((nft_uri, PROV.wasAttributedTo, agent_uri))
        else:
            g.add((tx_uri, RDF.type, CUSTOM.TransferActivity))
            from_uri = URIRef(
                f"https://etherscan.io/address/{event['from']}"
            )
            g.add((from_uri, RDF.type, PROV.Agent))
            g.add((tx_uri, PROV.used, nft_uri))

    return g


if __name__ == "__main__":
    events = [
        {
            "eventType": "Mint",
            "transactionHash": "0xabc123def456...",
            "blockNumber": 12345678,
            "timestamp": "2024-01-15T10:30:00Z",
            "from": "0x" + "0" * 40,
            "to": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
            "tokenId": 0,
        },
        {
            "eventType": "Transfer",
            "transactionHash": "0xdef789ghi012...",
            "blockNumber": 12345700,
            "timestamp": "2024-02-01T14:00:00Z",
            "from": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
            "to": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72",
            "tokenId": 0,
        },
    ]

    metadata = {
        "name": "鳥獣戯画 甲巻(部分)",
        "description": "国宝・鳥獣人物戯画のデジタル複製",
    }

    g = build_provenance_graph(
        "0x1234567890abcdef1234567890abcdef12345678",
        0,
        events,
        metadata,
    )

    # Turtle形式で出力
    print("=== Turtle ===")
    print(g.serialize(format="turtle"))

    # JSON-LD形式で出力
    print("\n=== JSON-LD ===")
    print(g.serialize(format="json-ld", indent=2))

    # トリプル数
    print(f"\nTotal triples: {len(g)}")

    # SPARQLクエリ: ミントされたNFTとその日時
    print("\n=== SPARQL Query: Mint Events ===")
    query = """
    PREFIX prov: <http://www.w3.org/ns/prov#>
    PREFIX custom: <https://example.org/ontology/nft#>

    SELECT ?nft ?mintTime ?agent WHERE {
        ?activity a custom:MintActivity ;
                  prov:atTime ?mintTime ;
                  prov:wasAssociatedWith ?agent .
        ?nft prov:wasGeneratedBy ?activity .
    }
    """
    for row in g.query(query):
        print(f"  NFT: {row.nft}")
        print(f"  Minted at: {row.mintTime}")
        print(f"  By: {row.agent}")

AIPパッケージへの統合

前章で作成したBagItパッケージに、JSON-LDの来歴記述を追加する方法を示します。

# generate_aip.py への追記
def add_linked_data_to_bag(
    bag_path: str,
    jsonld_provenance: dict,
    turtle_provenance: str,
):
    """BagItパッケージにLinked Dataファイルを追加する"""
    ld_dir = os.path.join(bag_path, "data", "linked-data")
    os.makedirs(ld_dir, exist_ok=True)

    # JSON-LD形式の来歴情報
    with open(
        os.path.join(ld_dir, "provenance.jsonld"),
        "w",
        encoding="utf-8",
    ) as f:
        json.dump(jsonld_provenance, f, ensure_ascii=False, indent=2)

    # Turtle形式の来歴情報
    with open(
        os.path.join(ld_dir, "provenance.ttl"),
        "w",
        encoding="utf-8",
    ) as f:
        f.write(turtle_provenance)

    # BagItのマニフェストを更新
    bag = bagit.Bag(bag_path)
    bag.save(manifests=True)

これにより、AIPパッケージには画像・NFTメタデータ・ブロックチェーントランザクション情報に加えて、構造化された来歴記述がLinked Data形式で含まれることになります。

まとめ

本章では、Semantic Web技術を使ってNFTの来歴を構造的に記述する方法を学びました。PROV-Oオントロジーによるミント・転送イベントのRDF表現、JSON-LDによるNFTメタデータの拡張、rdflibを使ったRDFグラフの構築とSPARQLクエリを実践しました。

ブロックチェーンの不変性とSemantic Webの表現力を組み合わせることで、デジタル文化財の来歴情報を機械可読かつ信頼性の高い形で管理できます。この仕組みは、GLAM(Gallery、Library、Archive、Museum)分野における相互運用性のあるデジタル保存基盤として、今後さらに重要性を増していくでしょう。

関連記事