JavaScript/TypeScript WebAssembly bindings for the Parity Verifiable crate.
This library enables anonymous membership proofs using ring VRFs (Verifiable Random Functions) on the Bandersnatch elliptic curve. A member of a group can prove they belong to that group without revealing which member they are, while generating a deterministic, context-specific pseudonymous alias that is unlinkable across different contexts.
- Installation
- Quick Start
- Concepts
- Architecture
- API Reference
- Data Encoding
- TypeScript Types
- Platform Support
- Development
- License
npm install verifiablejsThe package is published on npm as verifiablejs.
import {
member_from_entropy,
one_shot,
sign,
validate,
validate_with_commitment,
verify_signature,
} from 'verifiablejs/nodejs' // or 'verifiablejs/bundler' for browsers
// 1. Create members
const members = []
for (let i = 0; i < 10; i++) {
members.push(member_from_entropy(new Uint8Array(32).fill(i)))
}
// 2. SCALE-encode the members list
const encodedMembers = encodeMembers(members) // see Data Encoding section
// 3. Create an anonymous ring proof
const proverEntropy = new Uint8Array(32).fill(5) // member at index 5
const context = new TextEncoder().encode('my-app')
const message = new TextEncoder().encode('hello')
const RING_EXPONENT = 9 // R2e9 — capacity 255. Matches `pallet-members` on chain.
const result = one_shot(RING_EXPONENT, proverEntropy, encodedMembers, context, message)
// result.proof - the ring proof (pass this to a verifier)
// result.alias - your pseudonymous alias in this context
// result.member - your public key
// 4a. Verify the proof from the raw member list
const alias = validate(RING_EXPONENT, result.proof, encodedMembers, context, message)
// alias matches result.alias - proves someone in the ring sent the message
// 4b. Or verify from a pre-built ring commitment (e.g. fetched from `pallet-members`)
// const commitment = /* 768 bytes from api.query.members.root(collectionId, ringIndex) */;
// const alias = validate_with_commitment(RING_EXPONENT, result.proof, commitment, context, message);
// 5. Non-anonymous signatures
const signature = sign(proverEntropy, message)
const valid = verify_signature(signature, message, result.member)A ring proof lets a member of a known group prove membership without revealing their identity. Given a list of public keys (the "ring"), a prover generates a proof that convinces a verifier "one of these keys signed this", without revealing which one.
When creating a ring proof, the prover also generates a context-specific alias - a 32-byte pseudonymous identifier. The same member always produces the same alias for the same context, but aliases across different contexts are unlinkable. This enables:
- Pseudonymous voting: same alias in "election-2024" context prevents double-voting
- Anonymous reputation: consistent identity within a context, no cross-context tracking
All secret keys are derived from a 32-byte entropy value. The same entropy always produces the same secret key and public key (member). Entropy should be generated from a cryptographically secure random source and stored securely.
Ring operations require a ring_exponent parameter that controls the maximum number of members the ring can support. Values match the on-chain RingExponent enum used by pallet-members / pallet-chunks-manager.
Capacity formula: 2^x − 257.
ring_exponent |
Chain enum | Max Members | Use Case |
|---|---|---|---|
9 |
R2e9 |
255 | Testing, small groups |
10 |
R2e10 |
767 | Medium groups |
14 |
R2e14 |
16,127 | Large groups |
Choose the smallest ring size that fits your ring. Larger sizes increase proof generation and verification time.
Internally the library maps ring_exponent to the verifiable crate's FFT RingDomainSize (9 → Domain11, 10 → Domain12, 14 → Domain16); you never need to pass the FFT domain number directly.
A single proof can cover multiple contexts simultaneously. Instead of generating separate proofs for each context, create_multi_context produces one proof with one alias per context. This is more efficient and proves that the same (anonymous) member is acting across all contexts. Up to 16 contexts per proof are supported.
A ring is a set of public keys (members) that collectively form the anonymity set for proof generation. When a member creates a ring proof, the verifier learns that some member of the ring created the proof, but not which member. The larger the ring, the greater the anonymity.
In practice, a ring is maintained as an ordered list of Bandersnatch public keys. To generate or verify a proof, both parties need access to the same ring (the same set of members in the same order).
This library uses ring VRFs (Verifiable Random Functions) built on:
- Bandersnatch curve: An elliptic curve defined over the BLS12-381 scalar field, designed for efficient VRF operations in the Polkadot ecosystem
- Polynomial Commitment Scheme (PCS): Uses KZG-style commitments for efficient ring membership proofs
- Structured Reference String (SRS): Pre-computed cryptographic parameters from a trusted setup ceremony, required for proof generation and verification
Ring operations require pre-computed cryptographic parameters (SRS data and builder parameters). All ring data for all three domain sizes is compiled directly into the WASM binary - you do not need to load, fetch, or ship any additional data files.
The embedded data includes:
| Component | R2e9 | R2e10 | R2e14 |
|---|---|---|---|
| Builder params | 49 KB | 98 KB | 1.6 MB |
| Empty commitment | 848 B | 848 B | 848 B |
| SRS (shared) | 4.7 MB (shared across all ring exponents) |
This results in a total WASM binary size of approximately 7.3 MB. All domain sizes are fully functional out of the box.
A ring commitment (MembersCommitment, 768 bytes) is a compact cryptographic digest of a ring's member list. It is used during proof verification instead of the full member list. Building a commitment involves:
start_members(capacity)- initialize builder for a given domain sizepush_members(...)- add members and SRS lookup datafinish_members(...)- finalize into the 768-byte commitment
The members_root() JS function performs all three steps.
Derives a public key (member) from 32 bytes of entropy.
const entropy = new Uint8Array(32)
crypto.getRandomValues(entropy)
const member = member_from_entropy(entropy)
// member is a 32-byte Uint8Array (Bandersnatch public key)Checks whether a 32-byte value is a valid Bandersnatch public key.
const member = member_from_entropy(entropy)
is_member_valid(member) // true
is_member_valid(new Uint8Array(32).fill(0xff)) // falseCreates a ring proof in a single call. This is the primary function for proof generation.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (R2e9 / R2e10 / R2e14) |
entropy |
Uint8Array |
32-byte entropy of the prover |
members |
Uint8Array |
SCALE-encoded Vec<Member> |
context |
Uint8Array |
Context identifier (arbitrary bytes) |
message |
Uint8Array |
Message to bind to the proof (arbitrary bytes) |
Returns: OneShotResult
const result = one_shot(9, proverEntropy, encodedMembers, context, message)
console.log(result.proof) // Uint8Array - the ring proof (SCALE-encoded)
console.log(result.alias) // Uint8Array - 32-byte context-specific alias
console.log(result.member) // Uint8Array - 32-byte prover public key
console.log(result.members) // Uint8Array - SCALE-encoded members (echo)
console.log(result.context) // Uint8Array - context (echo)
console.log(result.message) // Uint8Array - message (echo)Throws: If entropy is invalid, the prover is not in the members list, or proof generation fails.
Validates a ring proof and extracts the prover's alias. This is the primary function for proof verification.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (must match proof creation) |
proof |
Uint8Array |
SCALE-encoded proof from one_shot |
members |
Uint8Array |
SCALE-encoded Vec<Member> |
context |
Uint8Array |
Context identifier (must match proof creation) |
message |
Uint8Array |
Message (must match proof creation) |
Returns: Uint8Array - SCALE-encoded 32-byte alias
const alias = validate(9, result.proof, encodedMembers, context, message)
// alias matches result.alias from one_shotThrows: If the proof is invalid or cannot be decoded.
Validates a ring proof against a pre-built 768-byte MembersCommitment (ring root). Recommended for chain-adjacent frontends: fetch the root via RPC (pallet-members::Root) and pass it directly — saves the commitment-construction step validate performs internally from the member list.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (must match proof creation) |
proof |
Uint8Array |
SCALE-encoded proof |
commitment |
Uint8Array |
768-byte SCALE-encoded MembersCommitment |
context |
Uint8Array |
Context identifier |
message |
Uint8Array |
Message |
Returns: Uint8Array - SCALE-encoded 32-byte alias.
const commitment = members_root(9, encodedMembers) // or fetch from chain
const alias = validate_with_commitment(9, result.proof, commitment, context, message)Throws: If the commitment is malformed or the proof is invalid.
Checks whether a ring proof is valid for a given alias, without extracting the alias. Useful when you already know the expected alias and just want a boolean check.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (R2e9 / R2e10 / R2e14) |
proof |
Uint8Array |
SCALE-encoded proof |
members |
Uint8Array |
SCALE-encoded Vec<Member> |
context |
Uint8Array |
Context identifier |
alias |
Uint8Array |
Expected 32-byte alias to check against |
message |
Uint8Array |
Message |
Returns: boolean
const valid = is_valid(9, result.proof, encodedMembers, context, result.alias, message)
// true
const invalid = is_valid(9, result.proof, encodedMembers, context, new Uint8Array(32), message)
// false - wrong aliasCreates a single ring proof that covers multiple contexts simultaneously. Each context produces its own unlinkable alias.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (R2e9 / R2e10 / R2e14) |
entropy |
Uint8Array |
32-byte entropy of the prover |
members |
Uint8Array |
SCALE-encoded Vec<Member> |
contexts |
Uint8Array |
SCALE-encoded Vec<Vec<u8>> of context identifiers |
message |
Uint8Array |
Message to bind to the proof |
Returns: MultiContextResult
// SCALE-encode contexts
const contexts = scaleEncodeVecVecU8([
new TextEncoder().encode('voting'),
new TextEncoder().encode('reputation'),
])
const result = create_multi_context(9, entropy, encodedMembers, contexts, message)
console.log(result.proof) // Uint8Array - single proof covering both contexts
console.log(result.aliases) // Uint8Array - SCALE-encoded Vec<Alias> (one per context)Validates a multi-context proof and extracts all aliases.
Returns: Uint8Array - SCALE-encoded Vec<Alias> (one 32-byte alias per context)
const aliases = validate_multi_context(9, result.proof, encodedMembers, contexts, message)
// SCALE-encoded Vec<[u8; 32]>Checks whether a multi-context proof is valid for the given aliases.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (R2e9 / R2e10 / R2e14) |
proof |
Uint8Array |
SCALE-encoded proof |
members |
Uint8Array |
SCALE-encoded Vec<Member> |
contexts |
Uint8Array |
SCALE-encoded Vec<Vec<u8>> |
aliases |
Uint8Array |
SCALE-encoded Vec<Alias> to check against |
message |
Uint8Array |
Message |
Returns: boolean
Efficiently validates multiple proofs against the same member set in a single call. More efficient than validating each proof individually because the ring commitment is built only once.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (R2e9 / R2e10 / R2e14) |
members |
Uint8Array |
SCALE-encoded Vec<Member> |
proof_items |
Uint8Array |
SCALE-encoded Vec<(Proof, Vec<u8>, Vec<u8>)> |
Each tuple in proof_items is (proof_bytes, context_bytes, message_bytes), SCALE-encoded.
Returns: Uint8Array - SCALE-encoded Vec<Alias> (one alias per validated proof)
Throws: If any proof in the batch is invalid.
Computes the deterministic alias for a given entropy and context, without creating a ring proof. This is useful for:
- Precomputing what alias a member will have in a given context
- Looking up a member's alias without needing the full member list
The alias returned matches what one_shot or create_multi_context would produce for the same entropy and context.
const alias = alias_in_context(entropy, context)
// 32-byte SCALE-encoded alias
// This matches the alias from a ring proof with the same entropy + context:
const result = one_shot(9, entropy, encodedMembers, context, message)
// alias === result.aliasNon-anonymous signatures that are directly attributable to a specific member. These are standard signatures, not ring proofs.
Signs a message using the secret key derived from entropy.
Returns: Uint8Array - SCALE-encoded signature
const signature = sign(entropy, message)Verifies a signature against a message and the signer's public key.
const member = member_from_entropy(entropy)
const signature = sign(entropy, message)
verify_signature(signature, message, member) // true
verify_signature(signature, wrongMessage, member) // false
verify_signature(signature, message, wrongMember) // falseThese functions precompute ring commitments for use in chain storage or other scenarios where the commitment is built ahead of time.
Computes the finalized ring commitment (MembersCommitment) from a SCALE-encoded member list. This is the compact representation used for on-chain storage and proof verification.
Returns: Uint8Array - 768-byte commitment
const commitment = members_root(9, encodedMembers)
// 768 bytesComputes the intermediate ring builder state (MembersSet) from a SCALE-encoded member list. This is the state before finalization, useful for chain genesis or incremental member addition.
Returns: Uint8Array - 848-byte intermediate
const intermediate = members_intermediate(9, encodedMembers)
// 848 bytesAll structured data is exchanged using SCALE codec encoding, the standard binary encoding used in the Substrate/Polkadot ecosystem.
Members are passed as a SCALE-encoded Vec<Member> where each Member is a fixed 32-byte public key. The encoding is a compact-encoded length prefix followed by the concatenated member bytes.
/**
* SCALE-encode an array of 32-byte members into Vec<Member>.
* Each member is a fixed 32-byte Bandersnatch public key (no per-element length prefix).
*/
function encodeMembers(members: Uint8Array[]): Uint8Array {
const length = members.length
let compactLength: Uint8Array
if (length < 64) {
// Single-byte mode: length << 2
compactLength = new Uint8Array([length << 2])
} else if (length < 16384) {
// Two-byte mode: (length << 2) | 0b01
compactLength = new Uint8Array([
((length & 0x3f) << 2) | 0b01,
(length >> 6) & 0xff,
])
} else {
throw new Error('Too many members for compact encoding')
}
// Concatenate: [compact_length, member_0, member_1, ...]
const result = new Uint8Array(compactLength.length + length * 32)
result.set(compactLength, 0)
let offset = compactLength.length
for (const member of members) {
result.set(member, offset)
offset += 32
}
return result
}If you are already using the Polkadot.js ecosystem, you can use @polkadot/util:
import { u8aConcat } from '@polkadot/util'
function encodeMembers(members: Uint8Array[]): Uint8Array {
const length = members.length
const compactLength = length < 64
? new Uint8Array([length << 2])
: new Uint8Array([((length & 0x3f) << 2) | 0b01, (length >> 6) & 0xff])
return u8aConcat(compactLength, ...members)
}Multi-context functions accept a SCALE-encoded Vec<Vec<u8>>. Each inner Vec<u8> is a compact-length-prefixed byte string.
/**
* SCALE-encode an array of byte arrays into Vec<Vec<u8>>.
*/
function encodeVecVecU8(items: Uint8Array[]): Uint8Array {
const parts: Uint8Array[] = []
// Outer compact length
parts.push(compactEncode(items.length))
// Each inner Vec<u8>: compact_length + bytes
for (const item of items) {
parts.push(compactEncode(item.length))
parts.push(item)
}
// Concatenate all parts
const totalLength = parts.reduce((sum, p) => sum + p.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const part of parts) {
result.set(part, offset)
offset += part.length
}
return result
}
function compactEncode(value: number): Uint8Array {
if (value < 64) {
return new Uint8Array([value << 2])
} else if (value < 16384) {
return new Uint8Array([
((value & 0x3f) << 2) | 0b01,
(value >> 6) & 0xff,
])
}
throw new Error('Value too large for compact encoding')
}For complex encoding needs, consider using @polkadot/types:
import { Bytes, TypeRegistry, Vec } from '@polkadot/types'
const registry = new TypeRegistry()
// Encode Vec<Vec<u8>>
const contexts = new Vec(registry, Bytes, [
new TextEncoder().encode('context-1'),
new TextEncoder().encode('context-2'),
])
const encoded = contexts.toU8a()/** On-chain `RingExponent`. Capacity formula: 2^x − 257. */
type RingExponent = 9 | 10 | 14
/** Result from one_shot() proof creation. */
interface OneShotResult {
proof: Uint8Array // SCALE-encoded ring proof
alias: Uint8Array // 32-byte context-specific alias
member: Uint8Array // 32-byte prover public key
members: Uint8Array // SCALE-encoded members list (echo)
context: Uint8Array // Context bytes (echo)
message: Uint8Array // Message bytes (echo)
}
/** Result from create_multi_context() proof creation. */
interface MultiContextResult {
proof: Uint8Array // SCALE-encoded ring proof
aliases: Uint8Array // SCALE-encoded Vec<Alias>
member: Uint8Array // 32-byte prover public key
members: Uint8Array // SCALE-encoded members list (echo)
contexts: Uint8Array // SCALE-encoded contexts (echo)
message: Uint8Array // Message bytes (echo)
}The package provides two build targets:
For Webpack, Vite, Rollup, and other bundlers:
import { one_shot, validate } from 'verifiablejs/bundler'Requires a bundler that supports WebAssembly ESM integration. For Vite, use the vite-plugin-wasm and vite-plugin-top-level-await plugins.
import { one_shot, validate } from 'verifiablejs/nodejs'Works with Node.js 18+ and Bun.
verifiablejs/
packages/verifiablejs/ # Main WASM package (Rust + wasm-bindgen, published to npm)
playground/vite/ # Browser example (Vite)
playground/bun/ # Node.js/Bun example
- Rust with
wasm32-unknown-unknowntarget (rustup target add wasm32-unknown-unknown) - wasm-pack (v0.13+)
- pnpm (v10+)
- Node.js 18+
# Install dependencies
pnpm install
# Build the WASM package (both bundler and Node.js targets)
pnpm build
# Run tests
pnpm test
# Run the Vite playground (browser)
pnpm dev:vite
# Run the Bun/Node playground
pnpm dev:bunThis project uses Changesets with automated CI/CD:
- Create a changeset (
pnpm changeset) and push/merge tomain - CI automatically creates a "chore: version packages" PR (bumps version, updates CHANGELOG)
- Merge the version PR
- Create a GitHub Release with tag
vX.Y.Zto trigger npm publish
Licensed under GPL-3.0-or-later WITH Classpath-exception-2.0