Appearance
L3-1: Offchain Voting(オフチェーン投票)
1. 問題
DAOガバナンスは根本的な矛盾に直面しています。投票には幅広いコミュニティ参加が必要ですが、オンチェーン投票では各トランザクションにガス代がかかります。少額のトークンしか保有しないユーザーにとって、ガス代が投票自体の経済的価値を上回る可能性があり、投票率は極めて低くなります(大規模DAOの参加率は通常1〜5%程度です)。同時に、すべての投票データを直接オンチェーンに保存すると莫大なストレージコストが発生します。10万人の投票者がいる提案では、投票記録の保存だけで数十MBのオンチェーンスペースが必要になります。
投票結果の検証可能性と改ざん耐性を保証しながら、投票者がゼロガスで参加でき、かつオンチェーンストレージコストをO(1)に抑えるにはどうすればよいでしょうか。これこそがSnapshotなどのオフチェーン投票システムが解決しようとしている問題です。
2. 理由
SnapshotはDAOガバナンスのデファクトスタンダードとなり、数百のDAOで数十億規模の議決権を処理しています。その中核的アイデアはLayer 2スケーリングの考え方を借用したものです。高コストな計算とストレージをオフチェーンに置き、最終結果のみをオンチェーンで決済します。ユーザーはEIP-712署名を通じて投票意思を表明し(無料)、バックエンドがすべての署名を収集してMerkle Treeを構築し、Merkle Rootと最終集計結果をオンチェーンに提出します。
オフチェーン投票の技術アーキテクチャを理解することは、DAOガバナンスインフラ開発に携わる上で必須のスキルです。これにはEIP-712署名標準、Merkle Treeの暗号学的保証、オフチェーン計算とオンチェーン検証の信頼モデルが含まれます。このパターンは投票だけでなく、エアドロップ請求、ホワイトリスト検証、分散型アイデンティティシステムにも広く応用されています。
3. 解決策
OffchainVoting.solは3段階のワークフローを実装しています。
フェーズ1:提案の作成。createProposal()がオンチェーンで提案を作成し、投票期間(ブロック番号で計測)を記録します。提案自体はその存在性を保証するためにオンチェーンに保存されます。
フェーズ2:オフチェーン投票収集。投票期間中、ユーザーはオフチェーンでEIP-712署名を使用して自分の投票選択(賛成/反対)と重みを署名します。バックエンドがすべての署名を収集し、Merkle Treeを構築します。各リーフノードはkeccak256(abi.encodePacked(voter, weight, support))のハッシュです。
フェーズ3:オンチェーン決済。submitResults()が投票集計(forVotes、againstVotes)とMerkle Rootをオンチェーンに提出します。この時点で提案はsettledとしてマークされます。その後、誰でもverifyVote()を呼び出して特定の投票が結果に含まれているかを検証できます。これはMerkle Proof検証を通じて行われ、1回の検証あたりのガスコストはO(log n)です(nは投票者数)。
主要な技術詳細:
- リーフハッシュ計算:
keccak256(abi.encodePacked(voter, weight, support))。abi.encodePackedを使用してコンパクトにエンコードし、プルーフデータサイズを削減します - Merkle Proof検証ではダブルハッシュソートペアリング(
computedHash <= proofElementで順序を判断)を使用し、セカンドプリイメージ攻撃を防止します - 重複検証防止:
voteVerifiedマッピングが各投票者の検証状態を追跡します - カスタムエラー(
ProposalAlreadySettled、InvalidMerkleProofなど)でrequire文字列を置き換え、ガスを節約します
参考ソースファイル:src/level3/OffchainVoting.sol
4. 遭遇した落とし穴
- Merkle Proofの順序不一致:オフチェーンのJavaScriptとオンチェーンのSolidityで兄弟ノードのハッシュ順序が完全に一致していないと検証が失敗します
- ABIエンコードの差異:
abi.encodePackedとabi.encodeでは動的型(string、bytes)の扱いが異なり、誤ったエンコード方式を使用するとリーフハッシュが一致しません - 署名形式の非互換性:EIP-712の
signTypedDataと基本的なeth_signでは生成される署名形式が異なり、署名者を復元する際に正しいメッセージプレフィックスを一致させる必要があります - フロントエンド状態の非同期:オフチェーンでMerkle Rootを提出した後、ユーザーが検証前に同じ投票を複数回検証しようとすると、
VoteAlreadyVerifiedエラーが発生します - 除外攻撃:提出者が意図的に特定の投票を除外する可能性があります(バックエンドがオンチェーンで制約されていないため)。唯一の防御策は、投票者が自身の投票が集計に含まれているかを自ら検証することです
5. 落とし穴の原因
Merkle Proofの順序問題の根本原因は、オフチェーンのJavaScriptのmerkletreejsライブラリがデフォルトでsortPairs: trueを使用し、ハッシュ値の文字列辞書順でソートするのに対し、Solidityコントラクトでは数値の大小で比較することにあります。両者の比較ロジックが異なると、計算される中間ハッシュ値が一致しません。さらに微妙な問題として、merkletreejsが使用するBuffer比較とSolidityのbytes32数値比較では、先行ゼロの処理において異なるソート結果が生じる可能性があります。
ABIエンコードの差異は、abi.encodePackedが型の境界を保持しないために発生します。複数の動的型パラメータが密にパックされる一方、abi.encodeは標準の32バイトアライメントを使用します。リーフノードにaddress(20バイト)とuint256(32バイト)が含まれる場合、encodePackedは52バイトの入力を生成し、オフチェーンのethers.solidityPackedもパラメータの順序と型を完全に一致させる必要があります。
6. 落とし穴の解決方法
Merkle Proofの順序一貫性:Solidity側(_verifyMerkleProof関数)とJavaScript側で同じ比較ロジックを使用します。推奨される方法は、両側でbytes32の数値比較(uint256変換)を使用することです。SolidityではcomputedHash <= proofElementとなります。JavaScriptではBigInt比較を使用します:
javascript
// JavaScript側:Solidityと一貫した順序を確保
function hashPair(a, b) {
const aBig = BigInt(a);
const bBig = BigInt(b);
if (aBig <= bBig) {
return keccak256(solidityPacked(['bytes32', 'bytes32'], [a, b]));
} else {
return keccak256(solidityPacked(['bytes32', 'bytes32'], [b, a]));
}
}
リーフハッシュの一貫性:オフチェーンでリーフハッシュを生成する際、コントラクト内のエンコード方式と厳密に一致させます:
javascript
const leaf = ethers.keccak256(
ethers.solidityPacked(
['address', 'uint256', 'bool'],
[voter, weight, support]
)
);
署名検証:バックエンドで署名を収集する際、EIP-712標準形式を使用し、Merkle Treeを構築する前に各署名の有効性をオンチェーン(または公開検証可能なオフチェーンスクリプト)で検証します。これにより、バックエンドが存在しない投票を偽造することができなくなります。
7. 技術ポイント
| 技術ポイント | 説明 |
|---|---|
| EIP-712署名 | 型付き構造化データ署名、ユーザー可読な署名内容 |
| Merkle Tree | O(log n)の検証複雑度、100万人の投票者でもわずか20層 |
| ダブルハッシュソートペアリング | セカンドプリイメージ攻撃とクロス実装の不一致を防止 |
abi.encodePacked | コンパクトエンコード、リーフサイズとプルーフ長を削減 |
| オフチェーン計算/オンチェーン検証 | 投票収集はオフチェーン(ゼロガス)、決済はオンチェーン |
| カスタムエラー | 4バイトセレクタ+パラメータ、require文字列よりガス節約 |
voteVerifiedによる再検証防止 | マッピングで検証状態を追跡、同一投票の重複計上を防止 |
| 3段階ライフサイクル | Create(オンチェーン)→ Vote(オフチェーン)→ Settle(オンチェーン) |