Snapshot-Integration: On-Chain-Voting technisch umgesetzt
Snapshot ist der De-facto-Standard für gasfreies Voting in DAOs. Die Stimmen werden off-chain per EIP-712-Signaturen erfasst, die Stimmgewichtung basiert auf on-chain Daten zu einem definierten Block-Zeitpunkt. Für eine Community-Plattform im DAO-Bereich haben wir eine vollständige Snapshot-Integration implementiert. Dieser Artikel beschreibt die technischen Details.
Architektur-Überblick
Die Integration besteht aus drei Schichten: einem GraphQL-Client für die Snapshot-API, einem Webhook-Listener für Echtzeit-Updates und einem Strategy-Resolver, der Stimmgewichte aus On-Chain-Daten berechnet.
Snapshot betreibt einen Hub-Server, der Proposals und Votes verwaltet. Die Kommunikation erfolgt über eine GraphQL-API. Votes werden als signierte Nachrichten (EIP-712 Typed Data) an den Hub gesendet. Der Hub validiert die Signatur und speichert den Vote. Die Stimmgewichtung wird bei Abschluss des Proposals anhand der gewählten Strategy berechnet.
GraphQL-Client: Proposals und Votes abfragen
Der erste Schritt ist das Abfragen aktiver Proposals. Snapshots GraphQL-API ist unter https://hub.snapshot.org/graphql erreichbar.
// snapshot-client.ts
import { request, gql } from 'graphql-request';
const SNAPSHOT_HUB = 'https://hub.snapshot.org/graphql';
interface Proposal {
id: string;
title: string;
body: string;
choices: string[];
start: number;
end: number;
snapshot: string;
state: 'active' | 'closed' | 'pending';
scores: number[];
scores_total: number;
}
export async function getActiveProposals(
space: string
): Promise<Proposal[]> {
const query = gql`
query Proposals($space: String!) {
proposals(
where: { space: $space, state: "active" }
orderBy: "created"
orderDirection: desc
) {
id
title
body
choices
start
end
snapshot
state
scores
scores_total
}
}
`;
const data = await request(SNAPSHOT_HUB, query, { space });
return data.proposals;
}
Für die Vote-Abfrage pro Proposal erweitern wir den Client:
export async function getVotes(
proposalId: string
): Promise<Vote[]> {
const query = gql`
query Votes($proposalId: String!) {
votes(
where: { proposal: $proposalId }
orderBy: "vp"
orderDirection: desc
first: 1000
) {
voter
choice
vp
created
}
}
`;
const data = await request(SNAPSHOT_HUB, query, {
proposalId,
});
return data.votes;
}
EIP-712 Signaturen: Vote-Erstellung
Votes bei Snapshot sind signierte Nachrichten nach dem EIP-712-Standard. Der Benutzer signiert eine typisierte Nachricht mit seiner Wallet, ohne eine on-chain Transaktion auszuführen. Das bedeutet: keine Gaskosten.
import { ethers } from 'ethers';
const domain = {
name: 'snapshot',
version: '0.1.4',
};
const voteTypes = {
Vote: [
{ name: 'from', type: 'address' },
{ name: 'space', type: 'string' },
{ name: 'timestamp', type: 'uint64' },
{ name: 'proposal', type: 'bytes32' },
{ name: 'choice', type: 'uint32' },
{ name: 'reason', type: 'string' },
{ name: 'app', type: 'string' },
{ name: 'metadata', type: 'string' },
],
};
export async function castVote(
signer: ethers.Signer,
space: string,
proposalId: string,
choice: number,
reason: string = ''
): Promise<string> {
const address = await signer.getAddress();
const timestamp = Math.floor(Date.now() / 1000);
const message = {
from: address,
space,
timestamp,
proposal: proposalId,
choice,
reason,
app: 'jts-platform',
metadata: '{}',
};
const signature = await signer.signTypedData(
domain,
voteTypes,
message
);
// Submit to Snapshot Hub
const response = await fetch(
'https://seq.snapshot.org/',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address,
sig: signature,
data: { domain, types: voteTypes, message },
}),
}
);
const result = await response.json();
return result.id;
}
Voting Strategies: Stimmgewichtung
Die Stimmgewichtung bei Snapshot wird durch sogenannte Strategies definiert. Eine Strategy ist im Kern eine Funktion, die für eine Liste von Adressen die jeweilige Stimmkraft berechnet. Die gängigsten Strategies lesen ERC-20-Token-Balances oder NFT-Bestände aus.
Für DAOs mit komplexeren Anforderungen können Custom Strategies geschrieben werden. Das folgende Beispiel zeigt eine Strategy, die Token-Balance und Delegation kombiniert:
// strategies/delegated-balance.ts
import { multicall } from '@snapshot-labs/snapshot.js/src/utils';
interface StrategyOptions {
address: string; // Token contract
decimals: number;
delegationRegistry: string;
}
export async function strategy(
space: string,
network: string,
provider: ethers.Provider,
addresses: string[],
options: StrategyOptions,
snapshot: number
): Promise<Record<string, number>> {
const blockTag = snapshot;
// 1. Get token balances
const balanceCalls = addresses.map((addr) => ({
target: options.address,
callData: tokenInterface.encodeFunctionData(
'balanceOf',
[addr]
),
}));
const balances = await multicall(
network,
provider,
tokenAbi,
balanceCalls,
{ blockTag }
);
// 2. Get delegated votes
const delegationCalls = addresses.map((addr) => ({
target: options.delegationRegistry,
callData: delegationInterface.encodeFunctionData(
'getVotingPower',
[addr]
),
}));
const delegations = await multicall(
network,
provider,
delegationAbi,
delegationCalls,
{ blockTag }
);
// 3. Combine: own balance + delegated power
const scores: Record<string, number> = {};
for (let i = 0; i < addresses.length; i++) {
const balance = parseFloat(
ethers.formatUnits(balances[i], options.decimals)
);
const delegated = parseFloat(
ethers.formatUnits(delegations[i], options.decimals)
);
scores[addresses[i]] = balance + delegated;
}
return scores;
}
Webhook-Integration für Echtzeit-Updates
Polling der Snapshot-API ist ineffizient. Stattdessen nutzen wir Webhooks, um über neue Proposals und abgegebene Votes informiert zu werden.
// webhook-handler.ts
import express from 'express';
import { verifyWebhookSignature } from './utils/crypto';
const app = express();
app.post('/webhooks/snapshot', async (req, res) => {
const signature = req.headers['x-snapshot-signature'];
if (!verifyWebhookSignature(req.body, signature)) {
return res.status(401).send('Invalid signature');
}
const { event, space, id } = req.body;
switch (event) {
case 'proposal/created':
await handleNewProposal(space, id);
break;
case 'proposal/end':
await handleProposalEnd(space, id);
break;
case 'vote/created':
await handleNewVote(space, id);
break;
}
res.status(200).send('OK');
});
Der handleNewProposal-Handler synchronisiert das Proposal in unsere lokale Datenbank und benachrichtigt die relevanten Nutzer der Plattform. handleProposalEnd ruft die finalen Scores ab und aktualisiert den Status.
On-Chain-Verifizierung
Für DAOs, die die Ergebnisse von Snapshot-Votes on-chain durchsetzen wollen, braucht es einen zusätzlichen Schritt. Snapshot selbst führt keine on-chain Transaktionen aus. Tools wie Snapshot X oder Reality.eth ermöglichen es, Off-Chain-Ergebnisse in on-chain Aktionen umzusetzen.
Ein vereinfachter Ansatz, den wir in der Praxis einsetzen: Ein multisig-kontrollierter Contract, der Proposal-Ergebnisse als Parameter entgegennimmt und entsprechende Aktionen auslöst:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract SnapshotExecutor {
address public multisig;
mapping(bytes32 => bool) public executed;
event ProposalExecuted(
bytes32 indexed proposalId,
bytes32 indexed actionHash
);
modifier onlyMultisig() {
require(msg.sender == multisig, "Not authorized");
_;
}
function executeProposal(
bytes32 proposalId,
address target,
bytes calldata data
) external onlyMultisig {
require(!executed[proposalId], "Already executed");
executed[proposalId] = true;
(bool success, ) = target.call(data);
require(success, "Execution failed");
emit ProposalExecuted(
proposalId,
keccak256(abi.encodePacked(target, data))
);
}
}
Fazit
Die Snapshot-Integration besteht aus klar getrennten Komponenten: GraphQL-Client für Datenabfragen, EIP-712-Signaturen für gasfreies Voting, Strategy-Funktionen für flexible Stimmgewichtung und Webhooks für Echtzeit-Updates. Die größte Herausforderung in der Praxis ist nicht die API-Anbindung selbst, sondern die korrekte Synchronisation zwischen Off-Chain-Votes und On-Chain-State, insbesondere bei der Delegation.
Der gesamte Code in diesem Artikel ist vereinfacht dargestellt. Eine produktionsreife Implementierung benötigt zusätzlich Fehlerbehandlung, Rate Limiting, Caching und umfassende Tests. Aber die Architektur steht.