来歴追跡の重要性

文化財管理において、「来歴」(provenance)の記録は極めて重要です。ある文化財がいつ制作され、誰が所有し、どのような修復が行われ、どこで展示されてきたか—これらの情報は文化財の真正性を証明し、その価値を裏付ける根拠となります。

従来、来歴情報は紙の書類やデータベースで管理されてきましたが、改ざんのリスクや機関間でのデータ共有の困難さといった問題がありました。本章では、ブロックチェーンのイベントログを活用して、改ざん不可能な来歴追跡システムを実装します。

履歴イベントの設計

Solidityのイベント(Event)機能を使用して、文化財に関する様々な出来事を記録します。イベントはブロックチェーン上のトランザクションログに永続的に記録され、後からフィルタリングして検索できます。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract CulturalHeritageNFT is ERC721, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;

    // 履歴の種類を表す列挙型
    enum HistoryType {
        Creation,       // 登録
        Restoration,    // 修復
        Exhibition,     // 展示
        Transfer,       // 移管
        Appraisal,      // 鑑定
        Documentation,  // 記録更新
        Other           // その他
    }

    // 履歴エントリの構造体
    struct HistoryEntry {
        uint256 tokenId;
        HistoryType historyType;
        string description;
        string documentUri;   // 関連ドキュメントのIPFS URI
        address recordedBy;
        uint256 timestamp;
    }

    // tokenId => 履歴エントリの配列
    mapping(uint256 => HistoryEntry[]) public histories;

    // 履歴記録権限を持つアドレス
    mapping(address => bool) public authorizedRecorders;

    // イベント定義
    event HistoryRecorded(
        uint256 indexed tokenId,
        HistoryType indexed historyType,
        string description,
        string documentUri,
        address indexed recordedBy,
        uint256 timestamp
    );

    event RecorderAuthorized(address indexed recorder);
    event RecorderRevoked(address indexed recorder);

    modifier onlyAuthorized() {
        require(
            owner() == msg.sender || authorizedRecorders[msg.sender],
            "Not authorized to record history"
        );
        _;
    }

    constructor()
        ERC721("CulturalHeritageNFT", "CHNFT")
        Ownable(msg.sender)
    {}

    /// @notice 履歴記録権限を付与する
    function authorizeRecorder(address recorder) external onlyOwner {
        authorizedRecorders[recorder] = true;
        emit RecorderAuthorized(recorder);
    }

    /// @notice 履歴記録権限を取り消す
    function revokeRecorder(address recorder) external onlyOwner {
        authorizedRecorders[recorder] = false;
        emit RecorderRevoked(recorder);
    }

    /// @notice 文化財の履歴を記録する
    function recordHistory(
        uint256 tokenId,
        HistoryType historyType,
        string memory description,
        string memory documentUri
    ) external onlyAuthorized {
        require(tokenId < _nextTokenId, "Token does not exist");

        HistoryEntry memory entry = HistoryEntry({
            tokenId: tokenId,
            historyType: historyType,
            description: description,
            documentUri: documentUri,
            recordedBy: msg.sender,
            timestamp: block.timestamp
        });

        histories[tokenId].push(entry);

        emit HistoryRecorded(
            tokenId,
            historyType,
            description,
            documentUri,
            msg.sender,
            block.timestamp
        );
    }

    /// @notice 特定トークンの履歴件数を取得する
    function getHistoryCount(uint256 tokenId)
        external view returns (uint256)
    {
        return histories[tokenId].length;
    }

    /// @notice 特定トークンの指定インデックスの履歴を取得する
    function getHistory(uint256 tokenId, uint256 index)
        external view returns (HistoryEntry memory)
    {
        require(index < histories[tokenId].length, "Index out of bounds");
        return histories[tokenId][index];
    }

    // --- 以下、前章のミント機能など(省略)---

    function tokenURI(uint256 tokenId)
        public view override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public view override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

履歴記録の権限管理

文化財の履歴は、信頼できる関係者のみが記録できるべきです。コントラクトでは authorizedRecorders マッピングを使い、コントラクトのオーナー(管理者)が特定のアドレスに記録権限を付与できる仕組みを採用しています。

// 権限の付与(管理者のみ実行可能)
const tx = await contract.authorizeRecorder(curatorAddress);
await tx.wait();
console.log("学芸員のアドレスに記録権限を付与しました");

これにより、博物館の学芸員や修復士など、文化財に直接関わる専門家のみが履歴を記録できるようになります。

履歴の記録

実際に履歴を記録する例を見てみましょう。

import { ethers } from "ethers";

// 修復履歴の記録
async function recordRestoration(
  tokenId: number,
  description: string,
  reportUri: string
) {
  const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
  const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
  const contract = new ethers.Contract(
    process.env.CONTRACT_ADDRESS!,
    CulturalHeritageNFT.abi,
    signer
  );

  // HistoryType.Restoration = 1
  const tx = await contract.recordHistory(
    tokenId,
    1, // Restoration
    description,
    reportUri
  );

  const receipt = await tx.wait();
  console.log("修復履歴を記録しました:", receipt.hash);
}

// 使用例
await recordRestoration(
  0, // tokenId
  "表装の修復を実施。裏打ち紙の交換と折れ伏せの補修を行った。",
  "ipfs://QmRestorationReport123"
);

// 展示履歴の記録
async function recordExhibition(
  tokenId: number,
  exhibitionName: string,
  venue: string
) {
  const description = `${exhibitionName} @ ${venue}`;

  // HistoryType.Exhibition = 2
  const tx = await contract.recordHistory(
    tokenId,
    2, // Exhibition
    description,
    "" // ドキュメントなし
  );

  await tx.wait();
  console.log("展示履歴を記録しました");
}

await recordExhibition(
  0,
  "国宝展2024",
  "東京国立博物館"
);

履歴の照会

記録された履歴は、コントラクトの関数から直接照会できるほか、イベントログをフィルタリングして効率的に検索できます。

// 方法1: コントラクトのストレージから取得
async function getAllHistory(tokenId: number) {
  const count = await contract.getHistoryCount(tokenId);
  console.log(`トークン #${tokenId} の履歴件数: ${count}`);

  const histories = [];
  for (let i = 0; i < count; i++) {
    const entry = await contract.getHistory(tokenId, i);
    histories.push({
      type: ["登録", "修復", "展示", "移管", "鑑定", "記録更新", "その他"][
        entry.historyType
      ],
      description: entry.description,
      documentUri: entry.documentUri,
      recordedBy: entry.recordedBy,
      timestamp: new Date(Number(entry.timestamp) * 1000).toISOString(),
    });
  }

  return histories;
}

// 方法2: イベントログからフィルタリングして取得(より効率的)
async function getHistoryFromEvents(tokenId: number) {
  const filter = contract.filters.HistoryRecorded(tokenId);
  const events = await contract.queryFilter(filter);

  return events.map((event: any) => ({
    type: event.args.historyType,
    description: event.args.description,
    documentUri: event.args.documentUri,
    recordedBy: event.args.recordedBy,
    timestamp: new Date(
      Number(event.args.timestamp) * 1000
    ).toISOString(),
    transactionHash: event.transactionHash,
    blockNumber: event.blockNumber,
  }));
}

// 特定の種類の履歴のみをフィルタリング
async function getRestorationHistory(tokenId: number) {
  const filter = contract.filters.HistoryRecorded(
    tokenId,
    1 // HistoryType.Restoration
  );
  const events = await contract.queryFilter(filter);
  return events;
}

イベントログを使った方法は、ストレージからの直接取得と比べてガスコストが低く、インデックスによるフィルタリングが可能なため、大量の履歴データを扱う場合に適しています。

テストコードの追加

履歴機能のテストを記述します。

describe("履歴の追加", function () {
  it("権限のあるアドレスが履歴を記録できる", async function () {
    const [owner, curator] = await ethers.getSigners();
    const contract = await deployContract();

    // NFTをミント
    await contract.mintCulturalItem(
      owner.address, "ipfs://test", "テスト文化財",
      "テスト用", "テスト機関", "2024"
    );

    // 学芸員に権限を付与
    await contract.authorizeRecorder(curator.address);

    // 学芸員として履歴を記録
    await contract.connect(curator).recordHistory(
      0, 1, "修復作業を実施", "ipfs://report"
    );

    const count = await contract.getHistoryCount(0);
    expect(count).to.equal(1);

    const entry = await contract.getHistory(0, 0);
    expect(entry.description).to.equal("修復作業を実施");
    expect(entry.recordedBy).to.equal(curator.address);
  });

  it("権限のないアドレスは履歴を記録できない", async function () {
    const [owner, unauthorized] = await ethers.getSigners();
    const contract = await deployContract();

    await contract.mintCulturalItem(
      owner.address, "ipfs://test", "テスト文化財",
      "テスト用", "テスト機関", "2024"
    );

    await expect(
      contract.connect(unauthorized).recordHistory(
        0, 1, "不正な記録", ""
      )
    ).to.be.revertedWith("Not authorized to record history");
  });
});

まとめ

本章では、Solidityのイベント機能と構造体を活用して、文化財の来歴情報をブロックチェーン上に記録するシステムを実装しました。権限管理により信頼できる関係者のみが記録を追加でき、イベントログを通じて効率的に履歴を検索できます。

次章では、文化財NFTのレンタル機能の実装に取り組みます。

関連記事