📌

Viemを使用したNFT発行システム

に公開

viemを使用したERC721 NFT発行システムの実装と技術仕様

概要

本記事では、viemを使用したERC721標準のNFT発行・管理システムの実装について詳しく解説します。SolidityスマートコントラクトとReact + TypeScriptフロントエンドで構成された、完全にオンチェーンでメタデータを生成するNFTシステムです。従来のWeb3.jsからviemへの移行により、型安全性の向上とモダンな開発体験を実現しました。

viemとは

viemは、Ethereumとの相互作用のための軽量で型安全なTypeScriptライブラリです。以下の特徴があります:

  • 型安全性: TypeScriptによる完全な型推論
  • 軽量: 最適化されたバンドルサイズ
  • モジュラー: 必要な機能のみをインポート
  • パフォーマンス: 他のライブラリと比較して高速

システムアーキテクチャ

全体構成

┌─────────────────────────────────────────┐
│ Frontend (React + TypeScript + Viem)    │
├─────────────────────────────────────────┤
│ - ViemNFTAppTest (メインアプリ)           │
│ - NFTOrderConfirmationTest (発行UI)      │
│ - NFTViewer (管理UI)                    │
│ - useViemWallet (ウォレット統合)         │
└─────────────────────────────────────────┘
                    │ HTTP/WebSocket
                    ▼
┌─────────────────────────────────────────┐
│ Blockchain Network                      │
├─────────────────────────────────────────┤
│ - Anvil (Local: 31337)                 │
│ - Sepolia Testnet (11155111)           │
│ - Ethereum Mainnet (1)                 │
└─────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│ Smart Contract: Issuer_test             │
├─────────────────────────────────────────┤
│ - ERC721 NFT Standard                   │
│ - On-chain SVG Generation               │
│ - Certificate Logic                     │
└─────────────────────────────────────────┘

実装したシステムの特徴

1. オンチェーンSVGメタデータ生成

コントラクト内でSVG画像とJSONメタデータを完全にオンチェーンで生成します:

function tokenURI(uint256 tokenId) public view override returns (string memory) {
    require(_ownerOf(tokenId) != address(0), "Token does not exist");

    DigitalAsset memory asset = digitalAssets[tokenId];
    string memory svg = _generateSVG(tokenId);

    string memory json = string(abi.encodePacked(
        '{"name": "Digital Asset #', Strings.toString(tokenId),
        '", "description": "On-chain Digital Asset NFT",',
        ' "image": "data:image/svg+xml;base64,', Base64.encode(bytes(svg)),
        '", "attributes": [',
        '{"trait_type": "Region", "value": "', Strings.toString(asset.region), '"},',
        '{"trait_type": "Amount", "value": "', Strings.toString(asset.value / asset.multiplier / 1e18), ' ETH"},',
        '{"trait_type": "Multiplier", "value": ', Strings.toString(asset.multiplier), '},',
        '{"trait_type": "Effective Date", "value": "', _simpleDate(asset.effectiveDate), '"},',
        '{"trait_type": "Expiry Date", "value": "', _simpleDate(asset.expiryDate), '"}',
        ']}')
    ));

    return string(abi.encodePacked(
        "data:application/json;base64,",
        Base64.encode(bytes(json))
    ));
}

2. 型安全なMetaMask接続

// viemを使用した安全なウォレット接続
export const connectMetaMask = async (): Promise<{
  success: boolean
  accounts: Address[]
  chainId: string
  error?: string
}> => {
  if (typeof window === 'undefined' || !window.ethereum) {
    return {
      success: false,
      accounts: [],
      chainId: '',
      error: 'MetaMask is not installed',
    }
  }

  try {
    const accounts = await window.ethereum.request({ 
      method: 'eth_requestAccounts' 
    }) as Address[]
    
    const chainId = await window.ethereum.request({ 
      method: 'eth_chainId' 
    }) as string

    return {
      success: true,
      accounts,
      chainId,
    }
  } catch (error: any) {
    return {
      success: false,
      accounts: [],
      chainId: '',
      error: error.message || 'Failed to connect to MetaMask',
    }
  }
}

3. 高度な日付計算システム

Unix timestampから正確な年月日を計算し、うるう年にも対応:

function _simpleDate(uint256 timestamp) private pure returns (string memory) {
    uint256 daysSince1970 = timestamp / 86400;
    uint256 year = 1970;
    uint256 remainingDays = daysSince1970;

    // うるう年を考慮した年の計算
    while (true) {
        uint256 daysInYear = _isLeapYear(year) ? 366 : 365;
        if (remainingDays >= daysInYear) {
            remainingDays -= daysInYear;
            year++;
        } else {
            break;
        }
    }

    // 月日の計算
    uint256 month = 1;
    uint256[12] memory monthDays = [uint256(31), 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

    if (_isLeapYear(year)) {
        monthDays[1] = 29;
    }

    for (uint256 i = 0; i < 12; i++) {
        if (remainingDays >= monthDays[i]) {
            remainingDays -= monthDays[i];
            month++;
        } else {
            break;
        }
    }

    uint256 day = remainingDays + 1;
    uint256 yearTwoDigits = year % 100;

    // YY/MM/DD形式で返す
    string memory yearStr = yearTwoDigits < 10 ? string(abi.encodePacked("0", Strings.toString(yearTwoDigits))) : Strings.toString(yearTwoDigits);
    string memory monthStr = month < 10 ? string(abi.encodePacked("0", Strings.toString(month))) : Strings.toString(month);
    string memory dayStr = day < 10 ? string(abi.encodePacked("0", Strings.toString(day))) : Strings.toString(day);

    return string(abi.encodePacked(yearStr, "/", monthStr, "/", dayStr));
}

4. NFT発行と管理の包括的フロー

実装したシステムでは、以下の段階的な確認プロセスを提供します:

  1. ウォレット接続: MetaMaskとの型安全な接続
  2. パラメータ入力: リージョンID、購入日、金額、倍率を指定
  3. 注文確認: リアルタイムで計算された詳細情報を表示
  4. トランザクション実行: viemを使用した安全な実行
  5. NFT管理: 所有者フィルタリングによるNFT一覧表示

5. 包括的なエラーハンドリング

export enum ContractError {
  INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS',
  INVALID_AMOUNT = 'INVALID_AMOUNT',
  INVALID_REGION = 'INVALID_REGION',
  INVALID_MULTIPLIER = 'INVALID_MULTIPLIER',
  CONTRACT_ERROR = 'CONTRACT_ERROR',
  USER_REJECTED = 'USER_REJECTED',
  NETWORK_ERROR = 'NETWORK_ERROR',
  UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}

export const ERROR_MESSAGES: Record<ContractError, string> = {
  [ContractError.INSUFFICIENT_FUNDS]: '残高が不足しています',
  [ContractError.INVALID_AMOUNT]: '無効な金額です',
  [ContractError.INVALID_REGION]: '無効なリージョンIDです(0-46の範囲で入力してください)',
  [ContractError.INVALID_MULTIPLIER]: '倍率は0より大きい値を入力してください',
  [ContractError.CONTRACT_ERROR]: 'コントラクトエラーが発生しました',
  [ContractError.USER_REJECTED]: 'トランザクションが拒否されました',
  [ContractError.NETWORK_ERROR]: 'ネットワークエラーが発生しました',
  [ContractError.UNKNOWN_ERROR]: '不明なエラーが発生しました',
}

技術スタック

スマートコントラクト

  • Solidity: ^0.8.0
  • Foundry: ビルド・テストフレームワーク
  • OpenZeppelin: ERC721, Ownable, Strings, Base64
  • ERC721: NFT標準の実装

フロントエンド

  • React 18: UIフレームワーク
  • TypeScript 5: 型安全性
  • Vite 5: ビルドツール
  • viem 2.37.5: Ethereumライブラリ
  • Tailwind CSS: スタイリング
  • shadcn/ui: アクセシブルなUIコンポーネント

デプロイメント

  • Anvil: ローカルブロックチェーン環境
  • Sepolia: Ethereumテストネット
  • MetaMask: ウォレット接続

スマートコントラクトの詳細仕様

1. データ構造

struct DigitalAsset {
    uint256 tokenId;        // NFTトークンID
    uint8 region;           // リージョンID (0-46)
    uint256 purchaseDate;   // 購入日(Unix timestamp)
    uint256 effectiveDate;  // 開始日(購入日+7日)
    uint256 expiryDate;     // 終了日(開始日+365日)
    uint256 value;          // 資産価値(購入金額 × 倍率)
    uint256 multiplier;     // 倍率
    bool isActive;          // 有効フラグ
}

2. 主要関数

// NFT発行
function issueCertificate(
    uint8 regionID,
    uint256 purchaseDate,
    uint256 purchaseAmount,
    uint256 multiplier
) external payable

// 所有者のNFT一覧取得
function getCertificatesByOwner(address owner)
    external view returns (uint256[] memory)

// NFT詳細情報取得
function getCertificate(uint256 tokenId)
    external view returns (DigitalAsset memory)

フロントエンド実装の詳細

1. viemクライアントの設定

// パブリッククライアントの作成
export const createPublicViemClient = () => {
  const rpcUrl = window.location.hostname === 'localhost'
    ? 'http://127.0.0.1:8545'
    : undefined

  return createPublicClient({
    chain: foundry,
    transport: http(rpcUrl)
  })
}

// ウォレットクライアントの作成
export const createWalletViemClient = () => {
  if (typeof window === 'undefined' || !window.ethereum) {
    throw new Error('MetaMask is not installed')
  }

  return createWalletClient({
    chain: foundry,
    transport: custom(window.ethereum!),
  })
}

2. NFT情報取得と表示

const fetchNFTData = async (tokenId: string) => {
  const publicClient = createPublicViemClient()

  const tokenURI = await publicClient.readContract({
    address: contractAddress,
    abi: ISSUER_TEST_ABI,
    functionName: 'tokenURI',
    args: [BigInt(tokenId)]
  })

  // Base64デコード
  if (typeof tokenURI === 'string' && tokenURI.startsWith('data:application/json;base64,')) {
    const base64Data = tokenURI.replace('data:application/json;base64,', '')
    const jsonString = atob(base64Data)
    const metadata = JSON.parse(jsonString)
    return metadata
  }
}

3. 所有NFTフィルタリング

const fetchOwnedTokenIds = async () => {
  if (!userAddress) return

  const publicClient = createPublicViemClient()

  const ownedIds = await publicClient.readContract({
    address: contractAddress,
    abi: ISSUER_TEST_ABI,
    functionName: 'getCertificatesByOwner',
    args: [userAddress]
  }) as bigint[]

  setOwnedTokenIds(ownedIds)
  if (ownedIds.length > 0) {
    setTokenId(ownedIds[0].toString())
  }
}

4. コントラクトとの相互作用

// NFT発行の実行
const handleIssueNFT = async () => {
  if (!formData) return

  setLoading(true)
  setError(null)

  try {
    const walletClient = createWalletClient({
      chain: foundry,
      transport: custom(window.ethereum!)
    })

    const [account] = await walletClient.getAddresses()

    // トランザクションを送信
    const hash = await walletClient.writeContract({
      address: contractAddress,
      abi: ISSUER_TEST_ABI,
      functionName: 'issueCertificate',
      args: [
        formData.regionID,
        formData.purchaseDate,
        formData.purchaseAmount,
        formData.multiplier
      ],
      value: formData.purchaseAmount,
      account
    })

    // トランザクションの完了を待機
    const publicClient = createPublicViemClient()
    const receipt = await publicClient.waitForTransactionReceipt({ hash })

    // イベントからtokenIdを取得
    let tokenId: bigint | undefined
    if (receipt.logs) {
      for (const log of receipt.logs) {
        try {
          const decoded = decodeEventLog({
            abi: ISSUER_TEST_ABI,
            data: log.data,
            topics: log.topics,
          })
          if (decoded.eventName === 'CertificateIssued') {
            tokenId = decoded.args.tokenId as bigint
            break
          }
        } catch {
          // イベントのデコードに失敗した場合はスキップ
        }
      }
    }

    const result: TransactionResult = {
      hash,
      tokenId,
      success: true,
    }

    setLastTransaction(result)
    setStep('completed')
    onOrderComplete?.(result)

  } catch (err: any) {
    console.error('Error issuing NFT:', err)
    handleError(err)
  } finally {
    setLoading(false)
  }
}

5. Reactフックによる状態管理

export const useViemWallet = () => {
  const [isConnected, setIsConnected] = useState(false)
  const [address, setAddress] = useState<Address | null>(null)
  const [chainId, setChainId] = useState<string | null>(null)
  const [balance, setBalance] = useState<string | null>(null)
  const [isLoadingBalance, setIsLoadingBalance] = useState(false)
  const [isConnecting, setIsConnecting] = useState(false)
  const [error, setError] = useState<string | null>(null)

  // ETH残高取得
  const fetchBalance = async (address: Address) => {
    try {
      setIsLoadingBalance(true)
      const publicClient = createPublicViemClient()
      const balance = await publicClient.getBalance({ address })
      const balanceInEth = formatEther(balance)
      setBalance(balanceInEth)
    } catch (err) {
      console.error('Error fetching balance:', err)
      setBalance(null)
    } finally {
      setIsLoadingBalance(false)
    }
  }

  // ウォレット接続
  const connect = async () => {
    if (!window.ethereum) {
      setError('MetaMaskがインストールされていません')
      return
    }

    try {
      setIsConnecting(true)
      setError(null)

      const accounts = await window.ethereum.request({
        method: 'eth_requestAccounts'
      }) as string[]

      if (accounts.length > 0) {
        const account = accounts[0] as Address
        setAddress(account)
        setIsConnected(true)
        await fetchBalance(account)

        const chainId = await window.ethereum.request({
          method: 'eth_chainId'
        }) as string
        setChainId(chainId)
      }
    } catch (err: any) {
      console.error('Connection error:', err)
      setError('ウォレットの接続に失敗しました')
    } finally {
      setIsConnecting(false)
    }
  }

  return {
    isConnected,
    address,
    chainId,
    balance,
    isLoadingBalance,
    isConnecting,
    error,
    connect,
    disconnect,
    refreshBalance: () => address && fetchBalance(address),
    clearError: () => setError(null)
  }
}

プロジェクト構成

プロジェクトルート/
├── solidity-contracts/          # スマートコントラクト
│   ├── src/Issuer_test.sol     # メインコントラクト
│   ├── script/DeployIssuerTest.s.sol  # デプロイスクリプト
│   └── README-Issuer_test.md   # コントラクト詳細ドキュメント
└── frontend/                   # フロントエンドアプリ
    ├── src/components/
    │   ├── ViemNFTAppTest.tsx       # メインアプリケーション
    │   ├── NFTOrderConfirmationTest.tsx # NFT発行フォーム
    │   ├── NFTViewer.tsx            # 保有NFT表示・管理
    │   └── ui/                      # shadcn/uiコンポーネント
    ├── src/hooks/
    │   └── useViemWallet.ts         # ウォレット状態管理フック
    ├── src/lib/
    │   ├── viemClient.ts            # viemクライアント設定
    │   ├── contracts_test.ts        # コントラクトABIと型定義
    │   └── utils.ts                 # ユーティリティ関数
    ├── viem-test.html               # エントリーポイント
    ├── VIEM_NFT_ISSUE_README.md     # プロジェクト概要
    └── VIEM_NFT_ISSUE_TechSpec.md   # 技術仕様書

セットアップ・実行手順

1. 開発環境準備

# Foundryインストール
curl -L https://foundry.paradigm.xyz | bash
foundryup

# 依存関係インストール
cd solidity-contracts && forge install
cd ../frontend && npm install

2. ローカルブロックチェーン起動

# Anvil起動(別ターミナル)
anvil --accounts 10 --balance 10000

3. コントラクトデプロイ

cd solidity-contracts
forge script script/DeployIssuerTest.s.sol --rpc-url http://127.0.0.1:8545 --broadcast

4. フロントエンド起動

cd frontend
npm run dev
# http://localhost:5174/viem-test.html でアクセス

5. 使用方法

  1. MetaMask接続: ローカルネットワーク(Chain ID: 31337)に接続
  2. NFT発行: リージョン、購入日、金額、倍率を入力
  3. 確認・実行: 注文内容を確認してMetaMaskで承認
  4. NFT管理: 保有セクションで所有NFTを一覧・管理

従来のWeb3.jsとの比較

Web3.jsの問題点

  • 型安全性の欠如
  • 大きなバンドルサイズ
  • 複雑なAPI
  • エラーハンドリングの困難さ

viemの利点

  • 型安全性: TypeScriptによる完全な型推論
  • 軽量: 最適化されたバンドルサイズ
  • シンプル: 直感的なAPI
  • エラーハンドリング: 包括的なエラー処理

技術的成果と実装のポイント

1. オンチェーンメタデータの実現

  • SVG生成: コントラクト内で完全なSVG画像を動的生成
  • Base64エンコード: JSONメタデータと画像をオンチェーンでエンコード
  • ガス最適化: 複雑なグラフィック要素を削減してOutOfGasエラーを解決

2. 高精度な日付計算

  • Unix timestamp変換: 秒単位から正確な年月日への変換
  • うるう年対応: 4年周期と100年・400年例外を考慮
  • 月日調整: 各月の日数を正確に計算

3. viemによる型安全性

  • コンパイル時エラー検出: TypeScriptとviemの組み合わせ
  • ABI型推論: コントラクト関数の完全な型情報
  • エラーハンドリング: 包括的なエラー分類と日本語メッセージ

4. レスポンシブUI設計

  • CSS Gridレイアウト: 2カラムグリッドで最適化
  • 自動更新: NFT選択時のリアルタイム表示
  • 日本語ローカライズ: 属性名、メッセージの日本語化

5. 状態管理とユーザビリティ

  • Reactフック: ウォレット状態の一元管理
  • 所有者フィルタリング: 接続ウォレットのNFTのみ表示
  • コントラクト設定統合: 動的アドレス変更機能

現在のデプロイ情報

コントラクトアドレス

  • Anvil Local: 0xbca8cd38fbac85193e1a453a1d4a1d7f74bbe70c
  • Chain ID: 0x7a69 (31337)

サポートチェーン

  • Localhost (Anvil): ローカル開発環境
  • Sepolia Testnet: テストネット
  • Ethereum Mainnet: メインネット

今後の拡張可能性

機能拡張

  • マルチチェーン対応
  • バッチ処理機能
  • トランザクション履歴表示
  • ガス最適化機能

技術的改善

  • より高度なNFT画像生成
  • モバイル対応の改善
  • パフォーマンス最適化
  • アクセシビリティ向上

まとめ

viemを使用したERC721 NFT発行システムの実装により、以下の技術的成果を得ることができました:

技術的達成事項

  1. オンチェーンNFTメタデータ: 完全にオンチェーンでSVG画像とJSONメタデータを生成
  2. 高精度日付処理: Unix timestampからうるう年対応の正確な年月日変換
  3. 型安全性: TypeScriptとviemによる完全な型安全性
  4. ガス最適化: 複雑な処理を簡素化してOutOfGasエラーを解決
  5. UX最適化: 自動更新、エラーハンドリング、日本語ローカライズ

技術スタックの特徴

  • Foundry: モダンなスマートコントラクト開発環境
  • viem: 軽量で型安全なEthereumライブラリ
  • React + TypeScript: モダンなフロントエンド技術
  • shadcn/ui: アクセシブルなUIコンポーネント

このシステムは、オンチェーンNFTメタデータ生成、高精度な日付計算、型安全なWeb3開発のベストプラクティスを結集した、技術的に高度なブロックチェーンアプリケーションの実装例です。

参考資料

Discussion