オフチェーン投票(Snapshot)の検証可能性について
はじめに
最近、可能な限り検証可能性を保ちつつ、オフチェーンで投票できないかを考える機会がありました。
SnapshotはIPFSを使用して、投票プロセス全体を完全に透明化し、監査可能にします。(ソース)
しかし実際のところ、検証可能性の実現方法が不明だったのと、今更ですがSnapshotの裏側を解説した記事をあまり見かけなかったため、今回この記事に綴りながら調査していきたいと思います。
調べながら書いていくので、記事構成がわかりにくいかもですがご容赦を。
私の理解
💡わかっていること
- 投票先を自身の所有する秘密鍵で署名しIPFSに保存することで、何に投票をしたのか、を証明できる。
💭わかっていないこと
- 「署名が正しい権限を持ったアドレスによるものか」を検証できるのか?
- ①例えば本当に投票期間中に行われた投票であるのか、はどうやって検証するのでしょうか。署名する内容に投票時間のタイムスタンプを含めたとしても、これは偽装できてしまいます。
- ②特定の権限を持っているユーザ(例えばNFTを所持している、ホワイトリストに登録されているなど)のみが、投票できるプロポーザルがあったとして、投票署名した時に本当に権限を持っていたのか、をどうやって検証するのでしょうか。また、space管理者が投票方法を変更しても、投票当時の設定を参照できるようになっているのでしょうか?
- 「何にどのくらいの投票が集まったのか」を検証できるのか?
- ③ある選択肢の投票数から、その投票1つ1つの署名は参照できるようになっているのでしょうか。また、その参照は署名に漏れがないことを証明できるようになっているのでしょうか。実際の投票数が101件だとして100件に改竄することができない仕組みを担保する必要があります。
アーキテクチャを理解する
そもそもSnapshotの裏側の仕組みを全く理解していなかったため、どのように動いているかを確認していきます。
Snapshotは主に3つの層で成り立っています。
1. プレゼンテーション層
スナップショットのUI部分ですね。まさにsnapshot.orgのフロント部分に当たります。
GitHub:https://github.com/snapshot-labs/snapshot
2. ロジック層
Snapshot Hub
GraphQLで動くAPIです。(公開されているAPIはこちら)
主にデータのreadを担うAPIとなります。
GraphQL Explorerも用意されており、気軽に試すことができます。
GitHub:https://github.com/snapshot-labs/snapshot-hub
Snapshot Sequencer
Snapshotのフロントから情報を受け取り、検証し、永続化する役割を担っています。後述するScore APIからユーザーの投票力を計算したあと、Sequencerがオフチェーンでデータを永続化するプロセスを編成し、フロントに応答を返します。ユーザーのアクションが承認された場合、レスポンスにレシートを含めることでプロトコルに登録されたことを証明します。
Sequencerにて承認された各アクションは自動インクリメントされたIDとともに保存されます。これは、誰でもスナップショットで行われたアクションの履歴全体を正しい順序で再生できることを意味します。
後述するデータ層への登録も、このSequencerが担っています。
GitHub:https://github.com/snapshot-labs/snapshot-sequencer
Score API
Score APIはSequencerからのリクエストをもとに、特定のブロックの高さにおける個々のアドレスの投票力を計算して返します。これはEVM archive nodeまたはsubgraphから直接取得したオンチェーンデータと、選択した投票戦略によって計算されます。
GitHub:https://github.com/snapshot-labs/score-api
3. データ層
スペース設定やプロポーザル、投票などを永続化しておくための層です。
ロジック層へデータを返すためのMysqlと、データをオープンなものにするためのIPFSの2つで構成されています。
MySQLのスキーマはこちらで定義されています。spacesを除いた各テーブルにはipfs VARCHAR(64) NOT NULL
カラムが用意されており、ここにCIDが格納されています。
Sequencerのプロセス
Sequencerによるユーザアクションの認証からレシートの発行までの流れを追っていきます。
①投票(Request)
Snapshotから投票すると以下のようなRequestとResponseになります。(投票するプロポーザルは投票戦略はERC20の残高とし、重み付け投票にしています。)
POST https://seq.snapshot.org
Request
{
"address": "0xF272453bf2276D5e063780a6607Fc88832E2d25a",
"sig": "0x8664c2fd6cd80f18f1d48c98d9f01f8c939cff31e3796d1cdefea6581cd725f9120bff8fc73ce1ff41733a91789868cf75d31c877eff3fcacd4de5da47ae02da1c",
"data": {
"domain": {
"name": "snapshot",
"version": "0.1.4"
},
"types": {
"Vote": [
{
"name": "from",
"type": "address"
},
{
"name": "space",
"type": "string"
},
{
"name": "timestamp",
"type": "uint64"
},
{
"name": "proposal",
"type": "bytes32"
},
{
"name": "choice",
"type": "string"
},
{
"name": "reason",
"type": "string"
},
{
"name": "app",
"type": "string"
},
{
"name": "metadata",
"type": "string"
}
]
},
"message": {
"space": "kensh.eth",
"proposal": "0xcd97dd648a77ad98f79e2aff528d2a24fb36f8c5e123f11974d61a83ffa3f33f",
"choice": "{\"1\":40,\"2\":10}",
"app": "snapshot",
"reason": "",
"metadata": "{}",
"from": "0xF272453bf2276D5e063780a6607Fc88832E2d25a",
"timestamp": 1691325333
}
}
}
Response
{
"id": "0x153d65367417cfe291e88c85fe93d72e85c029efd0210353ef227773421bff17",
"ipfs": "bafkreiexzyarm2t3ikewqy2vctvbrdi3lg74ghv6v5qlhpjnd67xeqptwm",
"relayer": {
"address": "0x8BBE4Ac64246d600BC2889ef5d83809D138F03DF",
"receipt": "0x3ce2babcb85a89863c13e7fe0922d87201eea43d762d932752644c905fe766fc3612489a512e7b613b01d05ac6a88952c51b33aaf199e45768cbe6af8fe153eb1b"
}
}
import typedData from './ingestor';
router.post('/', async (req, res) => {
if (process.env.MAINTENANCE) return sendError(res, maintenanceMsg);
try {
const result = await typedData(req);
return res.json(result);
} catch (e) {
log.warn(`[ingestor] msg validation failed (typed data)`, e);
return sendError(res, e);
}
});
typedDataにリクエストを渡して結果を受け取り、そのままjson形式でレスポンスにセットします。
② 署名を検証する
// Check if signature is valid
try {
const isValidSig = await snapshot.utils.verify(body.address, body.sig, body.data, network);
if (!isValidSig) return Promise.reject('wrong signature');
} catch (e) {
capture(e, { context: { address: body.address } });
log.warn(`signature validation failed for ${body.address} ${JSON.stringify(e)}`);
return Promise.reject('signature validation failed');
}
typedData内でいくつかの基本的なバリデーション(jsonのスキーマやタイムスタンプの検証など)をした後、署名が正しいか検証します。
③④ proposalを取得して投票を検証する
import hashTypes from '@snapshot-labs/snapshot.js/src/sign/types.json';
let type = hashTypes[hash]; // 投票の場合はtype="vote"
let context;
try {
context = await writer[type].verify(legacyBody);
} catch (e) {
}
各アクション毎に専用に定義されているverifyを実行しています。今回の場合はsnapshot-sequencer/src/writer/vote.ts
に定義されているverifyを実行します。
export async function verify(body): Promise<any> {
const msg = jsonParse(body.msg);
// proposal取得
const proposal = await getProposal(msg.space, msg.payload.proposal);
if (!proposal) return Promise.reject('unknown proposal');
ここでは以下のようなチェックが行われていました。
- JSONスキーマのチェック
- proposalと投票の整合性をチェック
- 投票情報のタイムスタンプが投票期間内にあること/投票提出が期間内にあることをチェック
- 選択した投票のチェック
⑤ 投票力を取得する
// 投票力の取得
let vp: any = {};
try {
vp = await snapshot.utils.getVp(
body.address,
proposal.network,
proposal.strategies,
proposal.snapshot,
msg.space,
proposal.delegation === 1,
{}
);
if (vp.vp === 0) return Promise.reject('no voting power');
}
return { proposal, vp };
proposalとvp(投票力)を返します。snapshot.utils.getVp
はscore-api(https://score.snapshot.org
)へ問い合わせて投票力を取得しています。(これはフロントでも全く同じリクエストが叩かれていました。)
また、getVpの引数として指定されているproposal.snapshot
はブロック高が格納されています。このブロック高時点で投票力を算出し、投票の権限をチェックします。proposal作成時に、このブロック高は設定され、現在のブロック高よりも低ければSequencerはこのブロック高を保存します。
❻ 投票情報のJSONをIPFSへ保存
import { pin } from '@snapshot-labs/pineapple';
let pinned;
let receipt;
try {
const { address, sig, ...restBody } = body;
const ipfsBody = {
address,
sig,
hash: id,
...restBody
};
[pinned, receipt] = await Promise.all([pin(ipfsBody), issueReceipt(body.sig)]);
} catch (e) {
capture(e);
return Promise.reject('pinning failed');
}
pineappleというライブラリを使用して投票情報をipfsへ保存します。
pineappleは複数のIPFSピンサービスにJSONを同時にアップロードし、サービスの1つが成功するとすぐに応答を返します。またアップロードしたJSONはAWS S3にもアップロードされます。
⑦ レシートを発行
import { Wallet } from '@ethersproject/wallet';
const privateKey = process.env.RELAYER_PK ?? '';
const wallet = new Wallet(privateKey);
export async function issueReceipt(id) {
return await wallet.signMessage(id);
}
export default wallet;
秘密鍵をWalletインスタンスに注入して署名をした結果がそのままレシートになります。
⑧ 投票情報をDBへ保存
voteテーブルへ投票情報が保存されます。一度、プロポーザルに対して投票している場合は、UPDATEが実行、まだ一度投票していない場合はINSERTが走ります。スキーマ情報は以下です。
CREATE TABLE votes (
id VARCHAR(66) NOT NULL,
ipfs VARCHAR(64) NOT NULL,
voter VARCHAR(64) NOT NULL,
created INT(11) NOT NULL,
space VARCHAR(64) NOT NULL,
proposal VARCHAR(66) NOT NULL,
choice JSON NOT NULL,
metadata JSON NOT NULL,
reason TEXT NOT NULL,
app VARCHAR(24) NOT NULL,
vp DECIMAL(64,30) NOT NULL,
vp_by_strategy JSON NOT NULL,
vp_state VARCHAR(24) NOT NULL,
cb INT(11) NOT NULL,
PRIMARY KEY (voter, space, proposal),
UNIQUE KEY id (id),
INDEX ipfs (ipfs),
INDEX voter (voter),
INDEX created (created),
INDEX space (space),
INDEX proposal (proposal),
INDEX app (app),
INDEX vp (vp),
INDEX vp_state (vp_state),
INDEX cb (cb)
);
⑨ 署名メッセージをDBへ保存
署名した内容をmessages
テーブルに保存します。このテーブルは投票に関わらず、ユーザの起こしたアクションによる署名の全てが保存されます。
全ての署名がこのテーブルにAUTO_INCREMENT
で挿入され、更新はされません。
つまり、messagesテーブルをもとに一番最初から実行していくことで特定時刻までのSnapshotを復元することが可能になります。
await storeMsg(
id, // POSTリクエストボディのハッシュ値
ipfs,
body.address,
msg.version,
msg.timestamp,
msg.space || '',
msg.type,
body.sig,
receipt
);
import db from './mysql';
export async function storeMsg(id, ipfs, address, version, timestamp, space, type, sig, receipt) {
const query = 'INSERT IGNORE INTO messages SET ?';
await db.queryAsync(query, [
{
id,
ipfs,
address,
version,
timestamp,
space,
type,
sig,
receipt
}
]);
}
CREATE TABLE messages (
mci INT NOT NULL AUTO_INCREMENT,
id VARCHAR(66) NOT NULL,
ipfs VARCHAR(64) NOT NULL,
address VARCHAR(64) NOT NULL,
version VARCHAR(6) NOT NULL,
timestamp BIGINT NOT NULL,
space VARCHAR(64),
type VARCHAR(24) NOT NULL,
sig VARCHAR(256) NOT NULL,
receipt VARCHAR(256) NOT NULL,
PRIMARY KEY (id),
INDEX mci (mci),
INDEX ipfs (ipfs),
INDEX address (address),
INDEX version (version),
INDEX timestamp (timestamp),
INDEX space (space),
INDEX type (type),
INDEX receipt (receipt)
);
つまり・・・?
ユーザのアクションを逐一IPFSへ保存することである程度検証可能性を実現しています。
投票結果のIPFSの内容が正しいものかどうかは、Sequencerの発行したレシートかどうかを見ることで検証可能です。そのIPFSにレシートがない、またはレシートの発行アドレスがSequencerのものではない場合、そのIPFSは虚偽なものであると判断できます。
また、投票力や権限を持っているかどうかは、プロポーザルのブロック高を知ることで検証可能になります。
IPFSのCIDはSnapshotがホストしているDB(MySQL)にインデックスされているため、別のIPFSのCIDに更新することで改竄は可能になります。ただし、改竄した場合、元々存在していた更新前のレコードのIPFSは残っている可能性もあるため、改竄を検出することが可能です。
「何にどのくらいの投票が集まったのか」を検証するには、Messagesテーブルに保存されているIPFSを拾って投票数を検証することは可能です。ただし前述した通り、DBを更新することで改竄される可能性は0ではないです。
今回投票にフォーカスしましたが、全ての変更には署名とIPFSへの保存が行われるため、プロポーザルや投票戦略の変更など、他の部分に関してもある程度検証可能であるといえます。
最後に
そのSequencerの認証が正しいかは、プロポーザルのブロック高から検証可能であり、改竄の余地はあるものの、改竄自体を検出可能であることがわかりました。
IPFSのCIDはSnapshotがホストしているDB(MySQL)にインデックスされているため、別のIPFSのCIDに更新することで改竄は可能になります。
フラッシュアイディアではありますが、L2Rollupのように、ユーザとは非同期にRollupしてブロックチェーンに保存することで、改竄をより難しくすることもできそうです。(今後のハッカソンのアイディアが1つ増えました☺️)
Discussion