Zum Hauptinhalt springen
6 Min. Lesezeit R.S.

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.