Web開発者のためのEthereum:誇大広告なしのスマートコントラクト
Web開発者に必要なEthereumの概念:アカウント、トランザクション、スマートコントラクト、ABIエンコーディング、ethers.js、WAGMI、ノードなしでオンチェーンデータを読む方法。
「開発者のためのEthereum」コンテンツの大半は2つのカテゴリに分類される:何も構築する助けにならない過度に単純化された比喩か、Merkle Patricia Trieが何かをすでに知っていることを前提とした深いプロトコル仕様か。トークン残高を読んだり、ユーザーにトランザクションに署名させたり、ReactアプリでNFTメタデータを表示したいWeb開発者には、どちらも役に立たない。
この記事は実践的な中間地点だ。フロントエンドがEthereumと通信するとき正確に何が起こるのか、構成要素は何か、そしてモダンなツール(ethers.js、viem、WAGMI)がWebアプリケーション構築で既に理解している概念にどうマッピングされるかを正確に説明する。
自動販売機のメタファーはない。「もし〜の世界を想像してください...」もない。技術モデルとコードだけだ。
メンタルモデル#
Ethereumは複製されたステートマシンだ。ネットワーク内のすべてのノードがステートの同一コピーを保持している。アドレスからアカウントデータへのマッピングである巨大なキーバリューストアだ。「トランザクションを送信する」とは、ステート遷移を提案していることだ。十分なバリデーターがそれを有効と認めれば、ステートが更新される。それだけだ。
ステート自体は簡単だ。20バイトのアドレスからアカウントオブジェクトへのマッピングだ。すべてのアカウントには4つのフィールドがある:
- nonce: このアカウントが送信したトランザクション数(EOAの場合)またはこのアカウントが作成したコントラクト数(コントラクトアカウントの場合)。リプレイ攻撃を防ぐ。
- balance: ETHの量、weiで表示(1 ETH = 10^18 wei)。常にビッグインテジャー。
- codeHash: EVMバイトコードのハッシュ。通常のウォレット(EOA)の場合、空バイトのハッシュ。コントラクトの場合、デプロイされたコードのハッシュ。
- storageRoot: アカウントのストレージトライのルートハッシュ。コントラクトのみが意味のあるストレージを持つ。
2種類のアカウントがあり、この区別は以降のすべてに関わる:
外部所有アカウント(EOA) は秘密鍵によって制御される。MetaMaskが管理するものだ。トランザクションを開始できる。コードは持たない。誰かが「ウォレット」と言うとき、EOAを意味している。
コントラクトアカウント はコードによって制御される。トランザクションを開始できない。呼び出されたことへの応答としてのみ実行できる。コードとストレージを持つ。誰かが「スマートコントラクト」と言うとき、これを意味している。コードはデプロイ後に不変だ(プロキシパターンによる例外があるが、それは全く別の議論だ)。
重要な洞察:Ethereum上のすべてのステート変更は、EOAがトランザクションに署名することから始まる。コントラクトは他のコントラクトを呼び出せるが、実行のチェーンは常に秘密鍵を持つ人間(またはボット)から始まる。
ガス:計算にはコストがある#
EVM内のすべての操作にはガスがかかる。2つの数を加算するのに3ガス。32バイトのワードを保存するのに20,000ガス(初回)または5,000ガス(更新)。ストレージの読み取りに2,100ガス(コールド)または100ガス(ウォーム、このトランザクションで既にアクセス済み)。
「ガスユニット」でガスを支払うのではない。ETHで支払う。合計コストは:
totalCost = gasUsed * gasPrice
EIP-1559(Londonアップグレード)後、ガス価格設定は2部構成のシステムになった:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: ネットワーク混雑度に基づいてプロトコルが設定。バーン(破棄)される。
- priorityFee(チップ): バリデーターに渡る。チップが高いほど早く含まれる。
- maxFeePerGas: ガスユニットあたりに支払う意思のある最大額。
- maxPriorityFeePerGas: ガスユニットあたりの最大チップ。
baseFee + priorityFee > maxFeePerGasの場合、baseFeeが下がるまでトランザクションは待機する。高混雑時にトランザクションが「スタック」する理由がこれだ。
Web開発者にとっての実践的な意味:データの読み取りは無料。データの書き込みにはお金がかかる。 これがWeb2とWeb3の間の最も重要なアーキテクチャの違いだ。すべてのSELECTは無料。すべてのINSERT、UPDATE、DELETEには実際のお金がかかる。それに応じてdAppを設計せよ。
トランザクション#
トランザクションは署名されたデータ構造だ。重要なフィールドは以下の通り:
interface Transaction {
// このトランザクションの受信者 — EOAアドレスまたはコントラクトアドレス
to: string; // 20バイトの16進アドレス、コントラクトデプロイの場合はnull
// 送信するETH量(wei単位)
value: bigint; // 純粋なコントラクト呼び出しの場合は0n
// エンコードされた関数呼び出しデータ、単純なETH転送の場合は空
data: string; // 16進エンコードされたバイト、単純な転送の場合は"0x"
// シーケンシャルカウンター、リプレイ攻撃を防ぐ
nonce: number; // 送信者の現在のnonceと正確に一致する必要がある
// ガスリミット — このtxが消費できる最大ガス
gasLimit: bigint;
// EIP-1559手数料パラメータ
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// チェーン識別子(1 = メインネット、11155111 = Sepolia、137 = Polygon)
chainId: number;
}トランザクションのライフサイクル#
-
構築: アプリがトランザクションオブジェクトを構築する。コントラクト関数を呼び出す場合、
dataフィールドにABIエンコードされた関数呼び出しが含まれる(これについては後述)。 -
署名: 秘密鍵がRLPエンコードされたトランザクションに署名し、
v、r、sの署名コンポーネントを生成する。これにより、送信者がこの特定のトランザクションを承認したことが証明される。送信者アドレスは署名から導出される。トランザクション内に明示的には含まれていない。 -
ブロードキャスト: 署名されたトランザクションが
eth_sendRawTransaction経由でRPCノードに送信される。ノードがそれを検証し(正しいnonce、十分な残高、有効な署名)、メンプールに追加する。 -
メンプール: トランザクションは保留中のトランザクションのプールに待機する。バリデーターは次のブロックに含めるトランザクションを選択し、一般的に高いチップを優先する。ここでフロントランニングが発生する。他のアクターがあなたの保留中のトランザクションを見て、より高いチップで独自のトランザクションを提出し、あなたの前に実行できる。
-
包含: バリデーターがあなたのトランザクションをブロックに含める。EVMがそれを実行する。成功すれば、ステート変更が適用される。リバートすれば、ステート変更はロールバックされる。ただし、リバートポイントまでに消費されたガスは支払う。
-
ファイナリティ: プルーフオブステークのEthereumでは、ブロックは2エポック後(約12.8分)に「ファイナライズ」される。ファイナリティ前は、チェーンの再編成が理論的に可能だ(まれだが)。ほとんどのアプリは、クリティカルでない操作には1〜2ブロック確認で「十分」と見なす。
ethers.js v6でシンプルなETH転送を送信する方法:
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
const tx = await wallet.sendTransaction({
to: "0xRecipientAddress...",
value: ethers.parseEther("0.1"), // "0.1"をwei(100000000000000000n)に変換
});
console.log("Tx hash:", tx.hash);
// ブロックへの包含を待つ
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = 成功, 0 = リバートviemでの同じ処理:
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = createWalletClient({
account,
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
const hash = await client.sendTransaction({
to: "0xRecipientAddress...",
value: parseEther("0.1"),
});
console.log("Tx hash:", hash);違いに注目:ethersは.wait()メソッドを持つTransactionResponseオブジェクトを返す。viemはハッシュだけを返す。確認を待つには別途publicClient.waitForTransactionReceipt({ hash })を呼び出す。この関心の分離はviemの設計上意図的なものだ。
スマートコントラクト#
スマートコントラクトは、特定のアドレスにデプロイされたバイトコードと永続的ストレージだ。コントラクトを「呼び出す」とは、dataフィールドにエンコードされた関数呼び出しを設定したトランザクションを送信する(または読み取り専用の呼び出しを行う)ことだ。
バイトコードとABI#
バイトコードはコンパイルされたEVMコードだ。直接操作しない。EVMが実行するものだ。
ABI(Application Binary Interface)は、コントラクトのインターフェースのJSON記述だ。関数呼び出しのエンコード方法と戻り値のデコード方法をクライアントライブラリに伝える。コントラクトのOpenAPI仕様と考えるとよい。
ERC-20トークンABIの一部:
const ERC20_ABI = [
// 読み取り専用関数(view/pure — 外部呼び出し時のガスコストなし)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
// ステート変更関数(トランザクションが必要、ガスがかかる)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
// イベント(実行中に発行、トランザクションログに保存)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;ethers.jsはこの「人間が読めるABI」形式を受け入れる。viemもこれを使えるが、多くの場合SolidityコンパイラによってフルJSON ABIが使われる。両方とも等価だ。人間が読める形式は一般的なインターフェースに対してより便利なだけだ。
関数呼び出しのエンコード方法#
ほとんどのチュートリアルが省略する部分であり、デバッグ時間を何時間も節約できる部分だ。
transfer("0xBob...", 1000000)を呼び出すと、トランザクションのdataフィールドは次のように設定される:
0xa9059cbb // 関数セレクター
0000000000000000000000000xBob...000000000000000000000000 // アドレス、32バイトにパディング
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 量(1000000の16進数)
関数セレクターは関数シグネチャのKeccak-256ハッシュの最初の4バイトだ:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = 最初の4バイト = 0xa9059cbb
残りのバイトはABIエンコードされた引数で、それぞれ32バイトにパディングされる。このエンコーディングスキームは決定論的だ。同じ関数呼び出しは常に同じcalldataを生成する。
なぜこれが重要か?Etherscan上の生のトランザクションデータが0xa9059cbbで始まっていれば、それがtransfer呼び出しだとわかるからだ。トランザクションがリバートしてエラーメッセージが16進ブロブだけの場合、ABIを使ってデコードできる。そしてトランザクションバッチを構築したりmulticallコントラクトとやり取りするとき、calldataを手動でエンコードすることになる。
ethers.jsで手動でエンコード・デコードする方法:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// 関数呼び出しをエンコード
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// calldataを関数名と引数にデコード
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// 関数の戻りデータをデコード
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueストレージスロット#
コントラクトストレージは、キーと値の両方が32バイトのキーバリューストアだ。Solidityはストレージスロットを0から順番に割り当てる。最初に宣言されたステート変数がスロット0、次がスロット1、以下同様。マッピングと動的配列はハッシュベースのスキームを使用する。
Solidityで変数がprivateとマークされていても、任意のコントラクトのストレージを直接読み取ることができる。「Private」は他のコントラクトが読めないことだけを意味する。eth_getStorageAt経由で誰でも読める:
// コントラクトのストレージスロット0を読み取り
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // 生の32バイト16進値ブロックエクスプローラーが「内部」コントラクトステートを表示する方法がこれだ。ストレージ読み取りにアクセス制御はない。パブリックブロックチェーン上のプライバシーは根本的に限定されている。
イベントとログ#
イベントは、コントラクトがトランザクションログに保存されるが、コントラクトストレージには保存されない構造化データを発行する方法だ。ストレージ書き込みより安価で(最初のトピックに375ガス + データバイトあたり8ガス、ストレージ書き込みの20,000ガスに対して)、効率的なクエリのために設計されている。
イベントは最大3つのindexedパラメータ(「トピック」として保存)と任意の数の非indexedパラメータ(「データ」として保存)を持てる。indexedパラメータはフィルタリングできる。「toがこのアドレスであるすべてのTransferイベントをください」と要求できる。非indexedパラメータはフィルタリングできない。マッチするすべてのイベントを取得してクライアント側でフィルタリングする必要がある。
// ethers.jsでリアルタイムのTransferイベントをリッスン
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
contract.on("Transfer", (from, to, value, event) => {
console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} tokens`);
console.log("Block:", event.log.blockNumber);
console.log("Tx hash:", event.log.transactionHash);
});
// 過去のイベントをクエリ
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=any, to=specific
const events = await contract.queryFilter(filter, 19000000, 19100000); // ブロック範囲
for (const event of events) {
console.log("From:", event.args.from);
console.log("Value:", event.args.value.toString());
}viemでの同じ処理:
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
// 過去のログ
const logs = await client.getLogs({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: {
to: "0xMyAddress...",
},
fromBlock: 19000000n,
toBlock: 19100000n,
});
for (const log of logs) {
console.log("From:", log.args.from);
console.log("To:", log.args.to);
console.log("Value:", log.args.value);
}
// リアルタイム監視
const unwatch = client.watchEvent({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
onLogs: (logs) => {
for (const log of logs) {
console.log(`Transfer: ${log.args.from} -> ${log.args.to}`);
}
},
});
// リスニングを停止するにはunwatch()を呼び出すオンチェーンデータの読み取り#
ここがEthereumがWeb開発者にとって実用的になるところだ。ノードを実行する必要はない。マイニングする必要はない。ウォレットすら必要ない。Ethereumからのデータ読み取りは無料で、パーミッションレスで、シンプルなJSON-RPC APIで動作する。
JSON-RPC:EthereumのHTTP API#
すべてのEthereumノードはJSON-RPC APIを公開している。文字通りJSONボディを持つHTTP POSTだ。トランスポート層にはブロックチェーン特有のものは何もない。
// これはライブラリが内部で行っていること
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: [
{
to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
// balanceOf(vitalik.eth)
},
"latest",
],
}),
});
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }これは生のeth_callだ。実際にトランザクションを送信せずに実行をシミュレートする。ガスコストなし。ステート変更なし。戻り値だけを読む。viewやpure関数が外部からどう動作するかがこれだ。eth_sendRawTransactionの代わりにeth_callを使用する。
2つの重要なRPCメソッド#
eth_call: 実行をシミュレート。無料。ステート変更なし。すべての読み取り操作に使用。残高チェック、価格読み取り、view関数の呼び出し。ブロック番号を「latest」の代わりに指定することで、任意の過去のブロックで呼び出せる。
eth_sendRawTransaction: 署名されたトランザクションをブロックへの包含のために送信。ガスがかかる。ステートを変更する(成功した場合)。すべての書き込み操作に使用。転送、承認、スワップ、ミント。
JSON-RPC APIの他のすべては、これら2つのバリアントかユーティリティメソッド(eth_blockNumber、eth_getTransactionReceipt、eth_getLogsなど)だ。
プロバイダー:チェーンへのゲートウェイ#
自分でノードを実行する必要はない。アプリケーション開発ではほとんど誰もそうしない。代わりにプロバイダーサービスを使用する:
- Alchemy: 最も人気。優れたダッシュボード、Webhookサポート、NFTとトークンメタデータの拡張API。無料枠:月間約3億コンピュートユニット。
- Infura: オリジナル。ConsenSys所有。信頼性が高い。無料枠:1日10万リクエスト。
- QuickNode: マルチチェーンに適している。やや異なる価格モデル。
- パブリックRPCエンドポイント:
https://rpc.ankr.com/eth、https://cloudflare-eth.com。無料だがレート制限があり、時々不安定。開発には問題ないが、本番環境には危険。 - Tenderly: シミュレーションとデバッグに優れている。RPCに組み込みのトランザクションシミュレーターが含まれる。
本番環境では、常に少なくとも2つのプロバイダーをフォールバックとして設定すること。RPCのダウンタイムは実際に発生し、最悪のタイミングで起こる。
import { ethers } from "ethers";
// ethers.js v6 フォールバックプロバイダー
const provider = new ethers.FallbackProvider([
{
provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
priority: 1,
stallTimeout: 2000,
weight: 2,
},
{
provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
priority: 2,
stallTimeout: 2000,
weight: 1,
},
]);コントラクトステートの無料読み取り#
ほとんどのWeb2開発者が気づいていない強力な機能:Ethereum上の任意のコントラクトの任意のパブリックデータを、何も支払わず、ウォレットなしで、RPCプロバイダーのAPIキー以外の認証なしで読み取れる。
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// ERC-20インターフェース — 読み取り関数のみ
const erc20 = new ethers.Contract(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
],
provider // 注意:signerではなくprovider。読み取り専用。
);
const [name, symbol, decimals, totalSupply] = await Promise.all([
erc20.name(),
erc20.symbol(),
erc20.decimals(),
erc20.totalSupply(),
]);
console.log(`${name} (${symbol})`); // "USD Coin (USDC)"
console.log(`Decimals: ${decimals}`); // 6(18ではない!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
// 特定のアドレスの残高をチェック
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);ウォレットなし。ガスなし。トランザクションなし。内部的にはJSON-RPCのeth_callだけだ。これはREST APIへのGETリクエストと概念的に同じだ。ブロックチェーンがデータベース、コントラクトがAPI、eth_callがSELECTクエリだ。
ethers.js v6#
ethers.jsはWeb3のjQueryだ。ほとんどの開発者が最初に学んだライブラリであり、いまだに最も広く使われている。バージョン6はv5からの大幅な改善で、ネイティブBigIntサポート(ついに)、ESMモジュール、そしてよりクリーンなAPIを備えている。
3つのコア抽象化#
Provider: ブロックチェーンへの読み取り専用接続。view関数の呼び出し、ブロックの取得、ログの読み取りが可能。署名やトランザクション送信はできない。
import { ethers } from "ethers";
// ノードに接続
const provider = new ethers.JsonRpcProvider("https://...");
// 基本クエリ
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: 秘密鍵の抽象化。トランザクションとメッセージに署名できる。SignerはProviderに常に接続されている。
// 秘密鍵から(サーバーサイド、スクリプト)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// ブラウザウォレットから(クライアントサイド)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// アドレスを取得
const address = await signer.getAddress();Contract: デプロイされたコントラクトのJavaScriptプロキシ。Contractオブジェクトのメソッドは、ABI内の関数に対応する。view関数は値を返す。ステート変更関数はTransactionResponseを返す。
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// 読み取り(無料、値を直接返す)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balanceはbigint: 1000000000n(6デシマルで1000 USDC)
// 書き込みにはsignerで接続
const usdcWithSigner = usdc.connect(signer);
// 書き込み(ガスがかかる、TransactionResponseを返す)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // ブロック包含を待つ
if (receipt.status === 0) {
throw new Error("Transaction reverted");
}型安全のためのTypeChain#
生のABIインタラクションは文字列型だ。関数名のスペルミス、間違った引数型の受け渡し、戻り値の誤解が起こり得る。TypeChainはABIファイルからTypeScript型を生成する:
// TypeChainなし — 型チェックなし
const balance = await contract.balanceOf("0x...");
// balanceは'any'。オートコンプリートなし。誤用しやすい。
// TypeChainあり — 完全な型安全
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balanceはBigNumber。オートコンプリートが機能。型エラーはコンパイル時にキャッチ。新しいプロジェクトでは、別のコード生成ステップなしで同じ結果を達成するviemの組み込みABI型推論の使用を検討すること。
イベントのリスニング#
リアルタイムイベントストリーミングはレスポンシブなdAppに不可欠だ。ethers.jsはこのためにWebSocketプロバイダーを使用する:
// リアルタイムイベント用WebSocket
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// すべてのTransferイベントをリッスン
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
// 特定のアドレスへの転送をリッスン
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Incoming transfer: ${ethers.formatUnits(value, 6)} USDC from ${from}`);
});
// 完了時にクリーンアップ
contract.removeAllListeners();WAGMI + Viem:モダンスタック#
WAGMI(We're All Gonna Make It)はEthereum用のReactフックライブラリだ。Viemはそれが使用する基盤となるTypeScriptクライアントだ。この2つは、フロントエンドdApp開発の標準スタックとしてethers.js + web3-reactをほぼ置き換えた。
なぜシフトしたのか?3つの理由:ABIからの完全なTypeScript推論(コード生成不要)、小さなバンドルサイズ、そしてウォレットインタラクションの面倒な非同期ステート管理を処理するReactフック。
セットアップ#
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(),
walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
],
transports: {
[mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
[sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
},
});// app/providers.tsx
"use client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
);
}コントラクトデータの読み取り#
useReadContractは最もよく使うフックだ。eth_callをReact Queryのキャッシング、再フェッチ、ローディング/エラーステートでラップする:
"use client";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
const ERC20_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "owner", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
] as const;
function TokenBalance({ address }: { address: `0x${string}` }) {
const { data: balance, isLoading, error } = useReadContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
abi: ERC20_ABI,
functionName: "balanceOf",
args: [address],
});
if (isLoading) return <span>Loading...</span>;
if (error) return <span>Error: {error.message}</span>;
// ABIがuint256と言っているので、balanceはbigint型
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}ABIのas constに注目。これは重要だ。これがないとTypeScriptはリテラル型を失い、balanceはbigintではなくunknownになる。型推論システム全体がconstアサーションに依存している。
コントラクトへの書き込み#
useWriteContractはウォレットプロンプト、署名、ブロードキャスト、確認追跡の完全なライフサイクルを処理する。
"use client";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
function SendTokens() {
const { writeContract, data: hash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
});
function handleSend() {
writeContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
functionName: "transfer",
args: [
"0xRecipientAddress...",
parseUnits("100", 6), // 100 USDC
],
});
}
return (
<div>
<button onClick={handleSend} disabled={isPending}>
{isPending ? "ウォレットで確認..." : "100 USDCを送信"}
</button>
{hash && <p>トランザクション: {hash}</p>}
{isConfirming && <p>確認待ち...</p>}
{isSuccess && <p>転送が確認されました!</p>}
{error && <p>エラー: {error.message}</p>}
</div>
);
}イベントの監視#
useWatchContractEventはリアルタイムイベント監視のためのWebSocketサブスクリプションを設定する:
"use client";
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
interface TransferEvent {
from: string;
to: string;
value: string;
}
function LiveTransfers() {
const [transfers, setTransfers] = useState<TransferEvent[]>([]);
useWatchContractEvent({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
eventName: "Transfer",
onLogs(logs) {
const newTransfers = logs.map((log) => ({
from: log.args.from as string,
to: log.args.to as string,
value: formatUnits(log.args.value as bigint, 6),
}));
setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
},
});
return (
<ul>
{transfers.map((t, i) => (
<li key={i}>
{t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
</li>
))}
</ul>
);
}ウォレット接続パターン#
ユーザーのウォレットを接続することはWeb3の「ログイン」だ。ただしログインではない。セッションもクッキーもサーバーサイドステートもない。ウォレット接続により、アプリはユーザーのアドレスを読み取り、トランザクション署名を要求する許可を得る。それだけだ。
EIP-1193プロバイダーインターフェース#
すべてのウォレットはEIP-1193で定義された標準インターフェースを公開する。requestメソッドを持つオブジェクトだ:
interface EIP1193Provider {
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
on(event: string, handler: (...args: unknown[]) => void): void;
removeListener(event: string, handler: (...args: unknown[]) => void): void;
}MetaMaskはこれをwindow.ethereumとしてインジェクトする。他のウォレットは独自のプロパティをインジェクトするか、window.ethereumも使用する(これが競合を引き起こす。「ウォレット戦争」問題で、EIP-6963により部分的に解決された)。
// 低レベルのウォレットインタラクション(直接行うべきではないが、理解するのに有用)
// アカウントアクセスを要求
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Connected address:", accounts[0]);
// 現在のチェーンを取得
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (メインネット)
// アカウント変更をリッスン(ユーザーがMetaMaskでアカウントを切り替え)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Wallet disconnected");
} else {
console.log("Switched to:", accounts[0]);
}
});
// チェーン変更をリッスン(ユーザーがネットワークを切り替え)
window.ethereum.on("chainChanged", (chainId: string) => {
// 推奨されるアプローチはページのリロード
window.location.reload();
});EIP-6963:マルチウォレットディスカバリー#
古いwindow.ethereumアプローチは、ユーザーが複数のウォレットをインストールしている場合に壊れる。どれがwindow.ethereumを取得するか?最後にインジェクトしたもの?最初のもの?競合状態だ。
EIP-6963はブラウザイベントベースのディスカバリープロトコルでこれを修正する:
// 利用可能なすべてのウォレットを発見
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // リバースドメイン名、例: "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// すべてのウォレットに自己アナウンスを要求
window.dispatchEvent(new Event("eip6963:requestProvider"));
// 'wallets'にインストールされたすべてのウォレットが名前とアイコンとともに含まれる
// ウォレット選択UIを表示できるWAGMIはこれらすべてを処理する。injected()コネクターを使用すると、利用可能な場合はEIP-6963を自動的に使用し、window.ethereumにフォールバックする。
WalletConnect#
WalletConnectは、リレーサーバー経由でモバイルウォレットをデスクトップdAppに接続するプロトコルだ。ユーザーがモバイルウォレットでQRコードをスキャンし、暗号化された接続を確立する。トランザクションリクエストはdAppから電話にリレーされる。
WAGMIでは、単に別のコネクターだ:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // cloud.walletconnect.comから取得
showQrModal: true,
});チェーン切り替えの処理#
ユーザーは間違ったネットワークにいることが多い。dAppはメインネット上、ウォレットはSepoliaに接続されている。またはPolygon上にいてメインネットが必要。WAGMIはuseSwitchChainを提供する:
"use client";
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
function NetworkGuard({ children }: { children: React.ReactNode }) {
const { chain } = useAccount();
const { switchChain, isPending } = useSwitchChain();
if (!chain) return <p>ウォレットを接続してください</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Ethereumメインネットに切り替えてください</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "切り替え中..." : "ネットワーク切り替え"}
</button>
</div>
);
}
return <>{children}</>;
}IPFSとメタデータ#
NFTは画像をオンチェーンに保存しない。ブロックチェーンはJSONメタデータファイルを指すURIを保存し、そのファイルが画像へのURLを含む。ERC-721のtokenURI関数で定義される標準パターン:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
そのJSONファイルは標準スキーマに従う:
{
"name": "Cool NFT #42",
"description": "A very cool NFT",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}IPFS CID vs URL#
IPFSアドレスはコンテンツ識別子(CID)を使用する。コンテンツ自体のハッシュだ。ipfs://QmXyz...は「ハッシュがQmXyz...であるコンテンツ」を意味する。これはコンテンツアドレスドストレージだ:URIはコンテンツから導出されるので、URIを変更せずにコンテンツを変更することはできない。これがNFTが依拠する不変性保証だ(実際にIPFSを使用している場合。多くは集中型URLを代わりに使用しており、それは危険信号だ)。
ブラウザでIPFSコンテンツを表示するには、IPFS URIをHTTPに変換するゲートウェイが必要だ:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// または専用ゲートウェイを使用:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// NFTメタデータの取得
async function getNftMetadata(
contractAddress: string,
tokenId: bigint,
provider: ethers.Provider
) {
const contract = new ethers.Contract(
contractAddress,
["function tokenURI(uint256 tokenId) view returns (string)"],
provider
);
const tokenUri = await contract.tokenURI(tokenId);
const httpUri = ipfsToHttp(tokenUri);
const response = await fetch(httpUri);
const metadata = await response.json();
return {
name: metadata.name,
description: metadata.description,
image: ipfsToHttp(metadata.image),
attributes: metadata.attributes,
};
}ピニングサービス#
IPFSはピアツーピアネットワークだ。コンテンツは誰かがそれをホスティング(「ピニング」)している限りのみ利用可能だ。NFT画像をIPFSにアップロードしてからノードをシャットダウンすると、コンテンツは消える。
ピニングサービスはコンテンツを利用可能に保つ:
- Pinata: 最も人気。シンプルなAPI。寛大な無料枠(1GB)。高速読み込みのための専用ゲートウェイ。
- NFT.Storage: 無料、Protocol Labs(IPFSの作成者)が支援。NFTメタデータ専用に設計。長期保存にFilecoinを使用。
- Web3.Storage: NFT.Storageに似ているが、より汎用的。
// Pinataへのアップロード
async function pinToIpfs(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${PINATA_JWT}`,
},
body: formData,
});
const result = await response.json();
return `ipfs://${result.IpfsHash}`; // CIDを返す
}インデックス問題#
ブロックチェーン開発の裏の秘密がこれだ:RPCノードから過去のデータを効率的にクエリすることはできない。
過去1年間のトークンのすべてのTransferイベントが欲しい?数百万のブロックをeth_getLogsで、2,000〜10,000ブロックのチャンクでページネーションしてスキャンする必要がある(最大値はプロバイダーによって異なる)。数千のRPC呼び出しだ。数分から数時間かかり、APIクォータを消費し尽くす。
特定のアドレスが所有するすべてのトークンが欲しい?これには単一のRPC呼び出しがない。すべてのERC-20コントラクトのすべてのTransferイベントをスキャンし、残高を追跡する必要がある。実現不可能だ。
ウォレット内のすべてのNFTが欲しい?同じ問題だ。すべてのNFTコントラクトのすべてのERC-721 Transferイベントをスキャンする必要がある。
ブロックチェーンは書き込み最適化されたデータ構造だ。新しいトランザクションの処理には優れている。過去のクエリへの回答にはひどい。これはdApp UIが必要とするものとチェーンがネイティブに提供するものとの間の根本的なミスマッチだ。
The Graphプロトコル#
The Graphは分散型インデックスプロトコルだ。「サブグラフ」— スキーマとイベントハンドラーのセット — を書くと、The GraphがチェーンをインデックスしてGraphQL API経由でデータを提供する。
# サブグラフスキーマ(schema.graphql)
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
}
type Account @entity {
id: Bytes!
balance: BigInt!
transfersFrom: [Transfer!]! @derivedFrom(field: "from")
transfersTo: [Transfer!]! @derivedFrom(field: "to")
}// フロントエンドからサブグラフをクエリ
const SUBGRAPH_URL =
"https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
async function getRecentTransfers(address: string) {
const query = `
query GetTransfers($address: Bytes!) {
transfers(
where: { from: $address }
orderBy: blockNumber
orderDirection: desc
first: 100
) {
id
from
to
value
blockNumber
timestamp
}
}
`;
const response = await fetch(SUBGRAPH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { address } }),
});
const { data } = await response.json();
return data.transfers;
}トレードオフ:The Graphはレイテンシ(通常チェーンヘッドから1〜2ブロック遅延)と追加の依存関係を追加する。分散ネットワークにはインデックスコストもある(GRTトークンで支払う)。小規模プロジェクトの場合、ホステッドサービス(Subgraph Studio)は無料だ。
AlchemyとMoralisの拡張API#
サブグラフを維持したくない場合、AlchemyとMoralisの両方が一般的なクエリに直接回答するプリインデックスAPIを提供している:
// Alchemy: アドレスのすべてのERC-20トークン残高を取得
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "alchemy_getTokenBalances",
params: ["0xAddress...", "erc20"],
}),
}
);
// すべてのERC-20トークン残高を1回の呼び出しで返す
// すべてのERC-20コントラクトのbalanceOf()をスキャンする代わりに// Alchemy: アドレスが所有するすべてのNFTを取得
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}これらのAPIはプロプライエタリで集中型だ。分散性と引き換えに開発者体験を得ている。ほとんどのdAppにとって、それは価値あるトレードオフだ。ユーザーはポートフォリオビューがサブグラフから来るかAlchemyのデータベースから来るかを気にしない。30秒ではなく200msでロードされることを気にする。
よくある落とし穴#
本番環境のdAppを複数リリースし、他チームのコードをデバッグした後、繰り返し目にするミスがこれらだ。すべて個人的に痛い目に遭っている。
どこでもBigInt#
Ethereumは非常に大きな数を扱う。ETH残高はwei(10^18)。トークン供給量は10^27以上になり得る。JavaScriptのNumberは安全に表現できる整数が2^53 - 1まで(約9 * 10^15)だ。wei量には不十分だ。
// 間違い — 暗黙の精度損失
const balance = 1000000000000000000; // weiで1 ETH
const double = balance * 2;
console.log(double); // 2000000000000000000 — 正しく見えるが...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — 間違い!切り上げ。
console.log(largeBalance === 100000000000000000000); // true — データ破損
// 正しい — BigIntを使用
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — 正しい
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — 正しいdAppコードでのBigIntルール:
- wei量を
Numberに変換しない。どこでもBigIntを使い、表示のためだけに人間が読める文字列に変換する。 - BigIntに
Math.floor、Math.roundなどを使わない。動作しない。整数除算を使用:amount / 10n ** 6n。 - JSONはBigIntをサポートしない。BigIntを含むステートをシリアライズする場合、カスタムシリアライザーが必要:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v)。 - ライブラリのフォーマット関数を使用。
ethers.formatEther()、ethers.formatUnits()、viemのformatEther()、formatUnits()。変換を正しく処理する。
import { formatUnits, parseUnits } from "viem";
// 表示:BigInt → 人間が読める文字列
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// 入力:人間が読める文字列 → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDCは18ではなく6デシマル
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"非同期ウォレット操作#
すべてのウォレットインタラクションは非同期で、アプリが優雅に処理する必要がある方法で失敗し得る:
// ユーザーは任意のウォレットプロンプトを拒否できる
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// ユーザーがウォレットでトランザクションを拒否
// これは正常 — レポートすべきエラーではない
showToast("トランザクションがキャンセルされました");
} else if (error.code === -32603) {
// 内部JSON-RPCエラー — トランザクションがリバートすることが多い
showToast("トランザクションは失敗します。残高を確認してください。");
} else {
// 予期しないエラー
console.error("Transaction error:", error);
showToast("エラーが発生しました。もう一度お試しください。");
}
}主な非同期の落とし穴:
- ウォレットプロンプトはユーザー側でブロッキング。コード内の
awaitはユーザーがMetaMaskでトランザクション詳細を読んでいる間30秒かかる可能性がある。何かが壊れていると思わせるローディングスピナーを表示しないこと。 - ユーザーはインタラクション中にアカウントを切り替えられる。アカウントAから承認を要求し、ユーザーがアカウントBに切り替えてから承認する。アカウントBが承認したが、アカウントAからトランザクションを送信しようとしている。重要な操作の前に常に接続されたアカウントを再確認すること。
- 2ステップの書き込みパターンは一般的。多くのDeFi操作は
approve+executeが必要。ユーザーは2つのトランザクションに署名する必要がある。承認したが実行しなかった場合、allowanceステートをチェックして次回は承認ステップをスキップする必要がある。
ネットワークミスマッチエラー#
これは他のどの問題よりも多くのデバッグ時間を浪費する。コントラクトはメインネット上。ウォレットはSepoliaに接続。RPCプロバイダーはPolygonを指している。3つの異なるネットワーク、3つの異なるステート、3つの完全に無関係なブロックチェーン。そしてエラーメッセージは通常役に立たない。「execution reverted」か「contract not found」だ。
// 防御的なチェーンチェック
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "ウォレットを接続してください" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `${getChainName(requiredChainId)}に切り替えてください。現在${getChainName(chainId)}にいます。`,
};
}
return { ready: true, error: null };
}DeFiにおけるフロントランニング#
DEXでスワップを送信すると、保留中のトランザクションがメンプールで可視になる。ボットがあなたのトレードを見て、価格を押し上げてフロントランし、あなたのトレードをより悪い価格で実行させ、その後すぐに売却して利益を得ることができる。これは「サンドイッチ攻撃」と呼ばれる。
フロントエンド開発者としてこれを完全に防ぐことはできないが、軽減できる:
// Uniswapスタイルのスワップでスリッページ許容度を設定
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5%のスリッページ許容
// デッドラインを使用して長寿命の保留トランザクションを防止
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20分
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // 最小許容出力 — これより少なければリバート
[tokenA, tokenB],
userAddress,
deadline, // 20分以内に実行されなければリバート
);高額トランザクションの場合、パブリックメンプールの代わりにブロックビルダーに直接トランザクションを送信するFlashbots Protect RPCの使用を検討すること。ボットが保留中のトランザクションを見ないため、サンドイッチ攻撃を完全に防止する:
// Flashbots ProtectをRPCエンドポイントとして使用
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");デシマルの混乱#
すべてのトークンが18デシマルではない。USDCとUSDTは6。WBTCは8。一部のトークンは0、2、または任意のデシマルを持つ。量をフォーマットする前に常にコントラクトからdecimals()を読み取ること:
async function formatTokenAmount(
tokenAddress: string,
rawAmount: bigint,
provider: ethers.Provider
): Promise<string> {
const contract = new ethers.Contract(
tokenAddress,
["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
provider
);
const [decimals, symbol] = await Promise.all([
contract.decimals(),
contract.symbol(),
]);
return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"ガス推定の失敗#
estimateGasが失敗する場合、通常はトランザクションがリバートすることを意味する。しかしエラーメッセージは「cannot estimate gas」だけで理由の表示がないことが多い。eth_callを使用してトランザクションをシミュレートし、実際のリバート理由を取得する:
import { createPublicClient, http, decodeFunctionResult } from "viem";
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
try {
await client.call({
account: tx.from,
to: tx.to,
data: tx.data,
value: tx.value,
});
return null; // エラーなし — トランザクションは成功する
} catch (error) {
// リバート理由をデコード
if (error.data) {
// 一般的なリバート文字列
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — メッセージ付きの標準リバート
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}すべてを組み合わせる#
ウォレットを接続し、トークン残高を読み取り、転送を送信する完全で最小限のReactコンポーネントがここにある。これはすべてのdAppのスケルトンだ:
"use client";
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
export function TokenDashboard() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
// 残高を読み取り — addressが定義されている時のみ実行
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// 書き込み:トークンを転送
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// 確認を待つ
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// 確認後に残高を再取得
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
ウォレットを接続
</button>
);
}
return (
<div>
<p>接続済み: {address}</p>
<p>
USDC残高:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "読み込み中..."}
</p>
<div>
<input
placeholder="受取人アドレス (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="金額 (例: 100)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={() => {
writeContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "transfer",
args: [recipient as `0x${string}`, parseUnits(amount, 6)],
});
}}
disabled={isSigning || isConfirming}
>
{isSigning
? "ウォレットで確認..."
: isConfirming
? "確認中..."
: "USDCを送信"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>転送が確認されました!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
Etherscanで確認
</a>
)}
<button onClick={() => disconnect()}>切断</button>
</div>
);
}ここからどこへ#
この記事では、Ethereumに入門するWeb開発者に必要な本質的な概念とツールを扱った。各分野にはさらに深い内容がある:
- Solidity: コントラクトを操作するだけでなく書きたい場合。公式ドキュメントとPatrick Collinsのコースが最良の出発点。
- ERC標準: ERC-20(代替可能トークン)、ERC-721(NFT)、ERC-1155(マルチトークン)、ERC-4626(トークン化されたボールト)。それぞれがそのカテゴリのすべてのコントラクトが実装する標準インターフェースを定義。
- レイヤー2: Arbitrum、Optimism、Base、zkSync。同じ開発者体験、低いガスコスト、わずかに異なる信頼の前提。ethers.jsとviemのコードは全く同じに動作する。チェーンIDとRPC URLを変更するだけ。
- アカウント抽象化(ERC-4337): ウォレットUXの次の進化。ガススポンサーシップ、ソーシャルリカバリー、バッチトランザクションをサポートするスマートコントラクトウォレット。「ウォレット接続」パターンが向かう先がこれだ。
- MEVとトランザクション順序: DeFiを構築する場合、最大抽出可能価値を理解することは任意ではない。Flashbotsのドキュメントが正典リソース。
ブロックチェーンエコシステムは速く動くが、この記事の基本 — アカウント、トランザクション、ABIエンコーディング、RPC呼び出し、イベントインデックス — は2015年から変わっておらず、すぐには変わらない。これらをよく学べば、他のすべてはAPIサーフェスに過ぎない。