Skip to content

Sealed-voting flow

Guide · end-to-end lifecycle

This guide walks the complete ShadowKit lifecycle with the real SDK calls. It mirrors the live testnet demos — GovVault at CDYN…WTX5.

  1. Snapshot eligible holders — build a Poseidon Merkle tree; its root is registered on GovVault at init.

  2. Create a proposalcreate_proposal(action_spec, cap, deadline) returns a proposal id; the deadline fixes the tlock round.

  3. Prove + seal a votegenerateVoteProof: Groth16 proof + tlock-sealed (direction, weight). The witness never leaves the browser.

  4. Cast on-chaincast_vote verifies the proof, checks the nullifier + binding + root, and stores the sealed ciphertext. No tally is exposed.

  5. Sealed until closevotes_cast is public; weighted_yes/no stay None. The tally is cryptographically unknowable until the drand round.

  6. Close & reveal — after the deadline, buildRevealArgs tlock-decrypts every vote; close_and_reveal re-aggregates on-chain → Approved | Rejected.

  7. Agent executes — on approval, the bounded agent pays for data, plans, and submits a policy-gated swap; mark_executed makes it single-shot.

import { buildSnapshot } from "@shadowkit/snapshot-tool";
import { bindings } from "@shadowkit/shared";
const snapshot = await buildSnapshot(holders); // depth 20 (== circuit)
// GovVault.init(admin, verifierId, snapshot.rootBe32Hex, usdcId, quorumCfg)
const proposalId = await govVault.create_proposal({
action_spec, cap, deadline, // deadline → tlock round
});

The proof and the sealed ciphertext are produced together so the proof attests the ciphertext is well-formed (public signal #4).

import { generateVoteProof } from "@shadowkit/zk-prover";
const { merklePath, pathIndices } = snapshot.getPath(myLeafIndex);
const { proof, publicSignals, sealedCiphertext } = await generateVoteProof(
{ secret, merklePath, pathIndices, weight, proposalId: String(proposalId),
direction: 1, merkleRoot: snapshot.root },
{ wasmPath: "/zk/vote.wasm", zkeyPath: "/zk/vote_final.zkey" },
deadlineUnixSeconds,
);
await govVault.cast_vote(proposalId, proof, publicSignals, sealedCiphertext);

The running tally is unknowable: weighted_yes / weighted_no are None in ProposalView, and the votes themselves are tlock-encrypted to a drand round that hasn’t happened yet. Only votes_cast (participation) is public.

import { buildRevealArgs } from "@shadowkit/tally-reveal";
// After the deadline (drand round reached):
const args = await buildRevealArgs(proposalId, sealedVotes); // real tlock decrypt
await govVault.close_and_reveal(
args.proposalId, args.revealedYesW, args.revealedNoW, args.decryptions,
);
// The chain re-aggregates the decryptions against the committed ciphertexts:
// RevealMismatch if any decryption doesn't bind to its stored ciphertext or the sums disagree.
import { AgentRunner } from "@shadowkit/agent";
const runner = new AgentRunner(agentConfig); // session key + Gemini key = server secrets
const { txHash } = await runner.run(proposalId, onLog);
// watch(approved) → x402 pay data → LLM plan (≤ cap) → AgentPolicy.enforce → swap → mark_executed