Skip to main content
6 min read R.S.

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.