npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@shutter-network/urban-verified-crypto

v0.1.1

Published

TypeScript SDK for client-side encrypted voting on the Shutter Network: linearly homomorphic threshold ElGamal over BLS12-381 with vote-validity and decryption-share zero-knowledge proofs.

Readme

Shutter Voting SDK

TypeScript SDK for client-side encrypted voting on the Shutter Network. Implements linearly homomorphic threshold ElGamal over BLS12-381 with zero-knowledge proofs of vote validity and correct partial decryption, per the Munich Personalratswahl cryptographic protocol specification.

Forked from @shutter-network/shutter-sdk; shares the BLST WASM layer.


Table of contents


What this SDK is (and isn't)

In scope

  • ElGamal encryption in G₂ on BLS12-381, with homomorphic addition, scalar multiplication, and a canonical sum.
  • Schnorr signatures in G₁ for voter-to-ballot binding.
  • A Fiat–Shamir Transcript type with Merlin-style challenge fold-back.
  • Zero-knowledge proof constructors for voters — OR-composition (range / bit) and budget (exact / at-most) — plus a single verifyBallot entry point that composes every matching verifier for Vote Proxy and auditor roles.
  • Wire encoders for the opaque bytes fields the on-chain contract leaves unspecified (zkProof, Schnorr signature, decryption-share DLEQ), and a matching decodeDLEQ for tally / auditor roles that consume on-chain decryption shares.
  • One keyper primitive — partialDecrypt — plus share verification, Lagrange combination, and baby-step-giant-step (BSGS) discrete-log recovery for the final tally.

Out of scope

  • Distributed key generation (DKG), keyper key storage, keyper networking/orchestration.
  • Contract-struct types or any ABI layer — the consumer owns their Ballot / ElectionConfig / DecryptionShare shapes and destructures them into the primitive-typed inputs the SDK expects.
  • Voter / Keyper classes or service wrappers. The SDK exposes plain ballot-construction and verification functions; callers compose what they need.
  • Scalar / field arithmetic, hash-to-scalar, and bare Chaum–Pedersen DLEQ primitives. These are implementation details of encrypt / proveOR / partialDecrypt / verifyBallot; the SDK is organised around ballot and proof operations, not their scalar building blocks.
  • WR-Server attestation verification — you inject a WRAttestationVerifier closure into verifyBallot.

Install & setup

npm install @shutter-network/urban-verified-crypto viem

viem is used for keccak256. The SDK depends on a BLST WASM build; it must be initialised once at startup before any curve or proof call:

import { initCurves } from '@shutter-network/urban-verified-crypto';

await initCurves();

Asset placement (browser)

If you use this SDK in a browser, blst.js and blst.wasm must be reachable at /blst.js and /blst.wasm. Both files ship in dist/.

my-app/
├── public/
│   ├── blst.js
│   └── blst.wasm

Vite

export default defineConfig({
  optimizeDeps: {
    exclude: ['@shutter-network/urban-verified-crypto'],
  },
});

High-level flow

A Munich-style ballot passes through these stages:

  1. Voter (browser). For each of candidates, pick a vote v_j ∈ {0,…,B}, encrypt it under the master public key mpk, then produce a BallotValidityProof (per-candidate range proof + aggregate budget proof) and sign a canonical preimage with Schnorr.
  2. Vote Proxy / auditor. Call verifyBallot, which decodes the proof, runs every range proof, aggregates the ciphertexts, verifies the budget proof on the sum, and checks the Schnorr signature.
  3. Tally Aggregator. Homomorphically sum active per-voter ciphertexts per candidate.
  4. Keypers (≥ t+1). Each keyper calls partialDecrypt(ctSum, …) to publish an on-chain decryption share with a DLEQ proof binding the share to their committee public key.
  5. Tally Aggregator / auditor. verifyDecryptionShare each share, combineShares via Lagrange interpolation, then recoverDiscreteLog (BSGS in G₂) to obtain the plaintext candidate totals.

API surface

All exports below come from the package root:

import { /* … */ } from '@shutter-network/urban-verified-crypto';

Curve primitives

Typed G₁ / G₂ wrappers around BLS12-381. Subgroup checks run on every fromBytes so downstream code can trust any point it holds. Scalars pass through the API as plain bigints — scalar arithmetic, hash-to-scalar, and domain-separation are handled internally by encrypt, proveOR, partialDecrypt, and the other operations below.

class G1Point {
  static generator(): G1Point;
  static fromBytes(bytes: Uint8Array): G1Point; // 48-byte compressed; runs subgroup check
  toBytes(): Uint8Array; // 48 bytes
  add(other: G1Point): G1Point;
  mul(scalar: bigint): G1Point;
  equals(other: G1Point): boolean;
}

class G2Point {
  static generator(): G2Point;
  static fromBytes(bytes: Uint8Array): G2Point; // 96-byte compressed; runs subgroup check
  toBytes(): Uint8Array; // 96 bytes
  add(other: G2Point): G2Point;
  mul(scalar: bigint): G2Point;
  equals(other: G2Point): boolean;
}

const G1_BYTES = 48;
const G2_BYTES = 96;

Fiat–Shamir transcript

Merlin-style, length-prefixed, with automatic challenge fold-back — the transcript is the single source of truth for every challenge in every proof.

class Transcript {
  constructor(label: string);
  append(tag: string, bytes: Uint8Array): void;
  appendScalar(tag: string, x: bigint): void;
  appendPoint(tag: string, p: G1Point | G2Point): void;
  challenge(tag: string): bigint; // folds the challenge back into the transcript
  clone(): Transcript;
}

ElGamal encryption & homomorphic ops

Linearly homomorphic threshold ElGamal in G₂:

C1 = r · P₂
C2 = r · mpk + m · P₂
interface Ciphertext {
  c1: G2Point;
  c2: G2Point;
}

function encrypt(m: bigint, mpk: G2Point, r?: bigint): { ct: Ciphertext; r: bigint };
function addCt(a: Ciphertext, b: Ciphertext): Ciphertext;
function scalarMulCt(a: Ciphertext, k: bigint): Ciphertext;
function sumCts(cts: readonly Ciphertext[]): Ciphertext;

r is optional; omitting it draws a fresh scalar. The returned r is the randomness the prover re-uses as the witness for range and budget proofs.

Schnorr signatures

Standard single-point Schnorr over G₁. Used to bind a ballot to a voter's ephemeral verification key vk = sk · P₁.

interface SchnorrSig {
  R: G1Point;
  s: bigint;
}

function schnorrKeygen(): { sk: bigint; vk: G1Point };
function schnorrSign(sk: bigint, vk: G1Point, msg: Uint8Array, k?: bigint): SchnorrSig;
function schnorrVerify(vk: G1Point, msg: Uint8Array, sig: SchnorrSig): boolean;

The signed msg is the keccak256 of the bytes returned by canonicalBallotMessage — don't assemble the preimage by hand.

Zero-knowledge proof constructors

Voter-side prover functions. Every prover seeds a shared Transcript with public inputs; the same transcript is re-used across the per-candidate range / bit proofs and the aggregate budget proof so they compose into a single BallotValidityProof. Proofs are deterministic given injected commitment randomness (see the optional commit params), which keeps test vectors and fuzzing tractable.

Verifier-side roles (Vote Proxy, auditor) do not invoke these per-proof — they call verifyBallot, which re-seeds the transcript the same way and runs every sub-proof in one pass. Keyper decryption shares are verified via verifyDecryptionShare.

interface DLEQProof  { e: bigint; z: bigint; }
interface ORProof    { branches: { a1: G2Point; a2: G2Point; e: bigint; z: bigint; }[]; }

type BudgetProof =
  | { mode: 'exact'; proof: DLEQProof }
  | { mode: 'atMost'; proof: ORProof };

OR composition — proves a ciphertext (C1, C2) encrypts one of a fixed candidate set without revealing which. Used for both Variant A range proofs (candidate set {0,…,B}) and Variant B bit proofs (candidate set {0,1}).

interface ORStatement { ct: Ciphertext; mpk: G2Point; candidates: readonly bigint[]; }
interface ORWitness   { r: bigint; trueIndex: number; }
interface ORCommitments { w?: bigint; simulated?: ReadonlyArray<{ e: bigint; z: bigint } | undefined>; }

function proveOR(stmt: ORStatement, witness: ORWitness, t: Transcript, commit?: ORCommitments): ORProof;

Budget proofs — bind the aggregate ciphertext cΣ = Σ_j c_j to a budget B. The mode byte is bound into the transcript, so an exact proof cannot be reinterpreted as an atMost proof even when V = B.

interface BudgetStatement    { ctSum: Ciphertext; mpk: G2Point; budget: bigint; }
interface ExactBudgetWitness { rSum: bigint; }
interface AtMostBudgetWitness { rSum: bigint; V: bigint; }

function proveBudgetExact (stmt: BudgetStatement, w: ExactBudgetWitness,  t: Transcript, commit?: { w?: bigint }): BudgetProof;
function proveBudgetAtMost(stmt: BudgetStatement, w: AtMostBudgetWitness, t: Transcript, commit?: ORCommitments): BudgetProof;

Ballot validity proofs

A BallotValidityProof bundles every per-candidate range / bit proof and the aggregate budget proof into one object, wire-encodable as a single bytes field on the ballot.

interface BallotValidityProof {
  version: number;         // 0x01
  variant: 'A' | 'B';
  rangeOrBit: ORProof[];   // Variant A: ℓ proofs each with B+1 branches.
                           // Variant B: ℓ·d bit proofs each with 2 branches.
  budget: BudgetProof;
}

See Variants A and B for when to pick each.

Ballot-level verification

verifyBallot is the one-call entry point for Vote Proxy / auditor roles. It decodes the zkProof, validates every sub-proof, checks the homomorphic sum against the budget, verifies the Schnorr signature, and invokes a caller-supplied WR-Server attestation verifier.

interface BallotInputs {
  electionId: Uint8Array;                              // bytes32
  pseudonym:  Uint8Array;                              // bytes32 nym_i
  vk:         Uint8Array;                              // 48-byte compressed G₁
  ciphertexts: ReadonlyArray<readonly [Uint8Array, Uint8Array]>; // each pair = (C1, C2), 96 bytes each
  zkProof:        Uint8Array;                          // encodeBallotValidityProof output
  voterSignature: Uint8Array;                          // encodeSchnorr output (80 bytes)
  wrAttestation:  Uint8Array;                          // opaque σ_WR — handed to your verifier
}

interface BallotVerifyParams {
  numCandidates: number;                               // ℓ
  budget:        number;                               // B
  mode:    'exact' | 'atMost';
  variant: 'A' | 'B';
  d?: number;                                          // Variant B only: ⌈log2(B+1)⌉
}

type WRAttestationVerifier = (
  electionId: Uint8Array,
  pseudonym:  Uint8Array,
  vk:         Uint8Array,
  attestation: Uint8Array,
) => boolean;

type VerifyResult = { ok: true } | { ok: false; reason: string };

function verifyBallot(
  inputs: BallotInputs,
  params: BallotVerifyParams,
  mpk: G2Point,
  verifyWRAttestation: WRAttestationVerifier,
): VerifyResult;

// Canonical Schnorr preimage — use this on both the signer and verifier sides.
function canonicalBallotMessage(args: {
  electionId: Uint8Array;
  pseudonym:  Uint8Array;
  ciphertexts: ReadonlyArray<readonly [Uint8Array, Uint8Array]>;
  zkProof:    Uint8Array;
}): Uint8Array;

// Shared transcript seeding used by both prover and verifier.
function seedBallotTranscript(
  electionId: Uint8Array,
  mpk: G2Point,
  vk: G1Point,
  ciphertexts: readonly Ciphertext[],
  params: BallotVerifyParams,
): Transcript;

// Candidate set for a Variant A range proof: [0n, 1n, …, Bn].
function rangeCandidates(budget: number): bigint[];

Destructure your own Ballot struct (from your ABI / contract layer) into BallotInputs; the SDK does not own any contract-shaped type. Passing the raw byte fields lets the consumer pick any serializer (ethers, viem, abitype) without coupling to ours.

Wire codecs

The bytes fields the on-chain contract leaves opaque. Voters, keypers, and any other producer role encode once on the way onto the wire; verifyBallot handles every ballot-side decode internally, so the only decoder the public surface exposes is decodeDLEQ — needed by the Tally Aggregator and auditors to consume keyper decryption-share proofs that they did not themselves construct.

function encodeBallotValidityProof(p: BallotValidityProof): Uint8Array;

function encodeDLEQ(p: DLEQProof): Uint8Array; // 64 bytes: 32-BE e ‖ 32-BE z
function decodeDLEQ(b: Uint8Array): DLEQProof; // strict: wrong length throws

function encodeSchnorr(sig: SchnorrSig): Uint8Array; // 80 bytes: 48-byte R ‖ 32-BE s

Keyper partial decryption

The keyper's entire import surface. DKG, key storage, and share transport remain keyper-infrastructure concerns.

interface PartialDecryption {
  sigma: G2Point;    // σ_{k,j} = msk_k · C1
  proof: DLEQProof;  // DLEQ tying σ to committeePK = msk_k · P₂
  keyperIndex: number;
}

function partialDecrypt(
  ctSum: Ciphertext,
  msk_k: bigint,
  mpk_k: G2Point,
  keyperIndex: number,
  t: Transcript,
): PartialDecryption;

function verifyDecryptionShare(
  ctSum: Ciphertext,
  share: PartialDecryption,
  committeePK: G2Point,
  t: Transcript,
): boolean;

Aggregation & tally recovery

// Lagrange-combine any t+1 verified shares → τ = C2 − σ.
function combineShares(
  shares: PartialDecryption[],
  evaluationPoints: bigint[],
  ctSum: Ciphertext,
): G2Point;

// Baby-step-giant-step in G₂: find T such that τ = T · P₂.
// Runtime & memory O(√upperBound). Munich-scale: ~10^5 upper bound → sub-second.
function recoverDiscreteLog(tau: G2Point, upperBound: bigint): bigint;

// Hoist the baby-step table when recovering many plaintexts against the
// same bound (the Tally Aggregator's hot path: one table, ℓ candidates).
interface BabyStepTable { /* opaque; reuse as-is */ }
function buildBabyStepTable(upperBound: bigint): BabyStepTable;
function recoverDiscreteLogWithTable(tau: G2Point, table: BabyStepTable): bigint;

Variants A and B

Two range-proof shapes are supported, picked at election-config time:

| Variant | Per-candidate proof | Branches | Ballot proof size | When to pick | |---------|----------------------------|----------|-------------------|---------------------------------------------------------------| | A | (B+1)-branch OR over {0,…,B} | B+1 | ℓ · (B+1) OR branches | Small budgets B (Munich default). | | B | d bit-proofs over {0,1}, where d = ⌈log2(B+1)⌉ | 2 | ℓ · d OR branches | Large budgets where (B+1) > d, i.e. B ≥ 3 or so. |

Both variants are fully wired end-to-end — prover, verifier, codec, and benchmarks. seedBallotTranscript binds variant and (for B) d into the transcript so an A-ballot cannot be re-interpreted as a B-ballot at the same parameters.


Security notes

  • Always call initCurves() before anything else. Every point and proof path assumes the WASM layer is live.
  • Subgroup checks are automatic. G1Point.fromBytes / G2Point.fromBytes reject non-subgroup points, so verifyBallot gets them for free when decoding vk and ciphertexts.
  • Don't reconstruct the Schnorr preimage by hand. Always call canonicalBallotMessage on both the signer and verifier side. Any drift silently invalidates every ballot.
  • Transcript binding is load-bearing. seedBallotTranscript binds vk, electionId, mpk, variant / mode / budget, and every ciphertext. Skipping any of these enables cross-ballot replay.
  • The SDK never sees your contract structs. Destructure your own Ballot and ElectionConfig into the primitive shapes. No contract-struct mirror lives here by design (see D-5 in the dev plan).
  • WR-Server attestation is out of scope. Inject a WRAttestationVerifier closure — the SDK never tries to guess your attestation scheme.

Testing & building

npm test              # jest — unit, property-based, end-to-end, + vector re-verify
npm run bench         # jest w/ --expose-gc over benchmarks/*.bench.ts
npm run gen-vectors   # regenerate tests/vectors/**/*.json deterministically
npm run build         # tsup + copies blst.wasm into dist/

Benchmarks under benchmarks/: primitives.bench.ts, ballot-variant-{a,b}.bench.ts, decrypt.bench.ts, e2e.bench.ts. The full-scale HL_ARC p=100 e2e is marked describe.skip pending a blst WASM rebuild with ALLOW_MEMORY_GROWTH (see inline comment in benchmarks/e2e.bench.ts).

Cross-impl test vectors under tests/vectors/: JSON per primitive (encrypt, DLEQ, OR, budget, Schnorr, decrypt-share, ballot, tally), consumed by tests/voting.vectors.test.ts and intended for an independent re-verifier in another language. Schema in tests/vectors/_schema.ts; generator in scripts/gen-vectors.ts.


References

  • Munich Personalratswahl cryptographic protocol specification (v0.3).
  • Potential Extensions document (Variant B, binary decomposition, WR-Server integration).
  • docs/development-plan.md — phase-by-phase implementation plan, deviations, and rationale.