📌
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発行と管理の包括的フロー
実装したシステムでは、以下の段階的な確認プロセスを提供します:
- ウォレット接続: MetaMaskとの型安全な接続
- パラメータ入力: リージョンID、購入日、金額、倍率を指定
- 注文確認: リアルタイムで計算された詳細情報を表示
- トランザクション実行: viemを使用した安全な実行
- 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. 使用方法
- MetaMask接続: ローカルネットワーク(Chain ID: 31337)に接続
- NFT発行: リージョン、購入日、金額、倍率を入力
- 確認・実行: 注文内容を確認してMetaMaskで承認
- 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発行システムの実装により、以下の技術的成果を得ることができました:
技術的達成事項
- オンチェーンNFTメタデータ: 完全にオンチェーンでSVG画像とJSONメタデータを生成
- 高精度日付処理: Unix timestampからうるう年対応の正確な年月日変換
- 型安全性: TypeScriptとviemによる完全な型安全性
- ガス最適化: 複雑な処理を簡素化してOutOfGasエラーを解決
- UX最適化: 自動更新、エラーハンドリング、日本語ローカライズ
技術スタックの特徴
- Foundry: モダンなスマートコントラクト開発環境
- viem: 軽量で型安全なEthereumライブラリ
- React + TypeScript: モダンなフロントエンド技術
- shadcn/ui: アクセシブルなUIコンポーネント
このシステムは、オンチェーンNFTメタデータ生成、高精度な日付計算、型安全なWeb3開発のベストプラクティスを結集した、技術的に高度なブロックチェーンアプリケーションの実装例です。
Discussion