Snapshot Integration: On-Chain Voting Implemented
Snapshot is the de facto standard for gasless voting in DAOs. Votes are captured off-chain via EIP-712 signatures, while voting power is based on on-chain data at a defined block number. For a community platform in the DAO space, we implemented a complete Snapshot integration. This article describes the technical details.
Architecture Overview
The integration consists of three layers: a GraphQL client for the Snapshot API, a webhook listener for real-time updates, and a strategy resolver that calculates voting weights from on-chain data.
Snapshot operates a hub server that manages proposals and votes. Communication happens via a GraphQL API. Votes are sent to the hub as signed messages (EIP-712 Typed Data). The hub validates the signature and stores the vote. Voting power is calculated at proposal close based on the chosen strategy.
GraphQL Client: Querying Proposals and Votes
The first step is querying active proposals. Snapshot's GraphQL API is available at https://hub.snapshot.org/graphql.
// 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;
}
For querying votes per proposal, we extend the 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 Signatures: Creating Votes
Votes on Snapshot are signed messages following the EIP-712 standard. The user signs a typed message with their wallet without executing an on-chain transaction. This means: no gas costs.
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: Calculating Voting Power
Voting power in Snapshot is defined by strategies. A strategy is essentially a function that calculates the voting power for a list of addresses. The most common strategies read ERC-20 token balances or NFT holdings.
For DAOs with more complex requirements, custom strategies can be written. The following example shows a strategy that combines token balance with delegation:
// 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 for Real-Time Updates
Polling the Snapshot API is inefficient. Instead, we use webhooks to be notified about new proposals and submitted votes.
// 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');
});
The handleNewProposal handler synchronizes the proposal into our local database and notifies relevant platform users. handleProposalEnd fetches the final scores and updates the status.
On-Chain Verification
For DAOs that want to enforce Snapshot vote results on-chain, an additional step is needed. Snapshot itself does not execute on-chain transactions. Tools like Snapshot X or Reality.eth enable translating off-chain results into on-chain actions.
A simplified approach we use in practice: a multisig-controlled contract that accepts proposal results as parameters and triggers corresponding actions:
// 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))
);
}
}
Conclusion
The Snapshot integration consists of clearly separated components: a GraphQL client for data queries, EIP-712 signatures for gasless voting, strategy functions for flexible voting power calculation, and webhooks for real-time updates. The biggest challenge in practice is not the API integration itself but the correct synchronization between off-chain votes and on-chain state, particularly with delegation.
All code in this article is simplified. A production-ready implementation additionally needs error handling, rate limiting, caching, and comprehensive tests. But the architecture holds.