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 {
encode_members,
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 = encode_members(members) // Member[] → Vec<Member>
// 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 from a pre-built ring commitment — RECOMMENDED for chain-adjacent apps.
// Fetch the finalized 288-byte commitment (the ring "root") from the chain and pass
// it directly. This skips the commitment-construction step, which is the expensive
// part of verification (see `validate` vs `validate_with_commitment` below).
const ringRoot = await api.query.members.root(collectionId, ringIndex) // pallet-members
const commitment = ringRoot.unwrap().root.toU8a() // NB: the `.root` field, not `.intermediate`
const alias = validate_with_commitment(RING_EXPONENT, result.proof, commitment, context, message)
// 4b. Or verify from the raw member list — convenient when you don't have the commitment,
// but it rebuilds the commitment on every call (see cost note below).
// const alias = validate(RING_EXPONENT, result.proof, encodedMembers, context, message)
// alias matches result.alias - proves someone in the ring sent the 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 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, 288 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 288-byte commitment
The members_root() JS function performs all three steps. This computation is not cheap — its cost grows with ring size — so build a commitment once and reuse it rather than recomputing per verification. On chains running pallet-members the finalized commitment is already stored on-chain and can be fetched directly (see validate_with_commitment).
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)) // falseSCALE-encodes an array of 32-byte member public keys into the Vec<Member> shape that every ring function (one_shot, validate, is_valid, batch_validate, members_root, members_intermediate) expects for its members parameter. This is the inverse of the decoding those functions do internally — use it instead of hand-rolling the SCALE encoding (see Data Encoding for the manual equivalent).
const members = [member_from_entropy(entropyA), member_from_entropy(entropyB)]
const encodedMembers = encode_members(members)
const result = one_shot(9, proverEntropy, encodedMembers, context, message)Throws: If any element is not exactly 32 bytes, or is not a valid Bandersnatch public key.
Creates 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, building the ring commitment internally from the member list on every call.
Cost note: building the commitment is the expensive part of verification (it grows with ring size). If you already have — or can fetch — the finalized commitment, prefer
validate_with_commitmentand avoid recomputing it per proof.
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 288-byte MembersCommitment (the ring "root"). This is the recommended verification path whenever you already have the commitment or can fetch it: it skips the commitment-construction step validate performs internally, which is the expensive part of verification.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ring_exponent |
9 | 10 | 14 |
Ring exponent (must match proof creation) |
proof |
Uint8Array |
SCALE-encoded proof |
commitment |
Uint8Array |
288-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 (see below)
const alias = validate_with_commitment(9, result.proof, commitment, context, message)Throws: If the commitment is malformed or the proof is invalid.
On chains running pallet-members (e.g. the Individuality people chain), the finalized commitment is stored on-chain — no runtime API needed, just a state query. There is no need to rebuild it locally from the member list.
- People / source chain —
Members.Root(collectionId, ringIndex)(a double map) returns aRingRoot { root, revision, intermediate }struct. Read therootfield — it is the finalized 288-byteMembersCommitment(the runtime already ranfinish_members). Do not useintermediate; that is the unfinished accumulator kept for adding more members and is not valid for verification. - Consumer / asset-hub chain —
MembersSubscriber.RingRoots(collectionId, ringIndex)returns a bounded list of recentRingCommitmentRecords (mirrored over XCM). Take the most recent entry'srootfield.
// @polkadot/api against a pallet-members chain
const ringRoot = await api.query.members.root(collectionId, ringIndex)
const commitment = ringRoot.unwrap().root.toU8a() // finalized 288-byte MembersCommitment
const alias = validate_with_commitment(9, proof, commitment, context, message)Byte-compatibility: the on-chain commitment must come from a
verifiablerevision whoseMembersCommitmentencoding matches the one this package was built against. The format has been stable, but ifvalidate_with_commitmentrejects an on-chain root, check that the chain'sverifiablerev and this package's are compatible.
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 aliasEfficiently 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 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.
This computation is non-trivial and scales with ring size. Prefer fetching the commitment from chain (see
validate_with_commitment) when one is already stored there; usemembers_rootwhen you need to build it yourself (e.g. off-chain or for genesis).
Returns: Uint8Array - 288-byte commitment
const commitment = members_root(9, encodedMembers)
// 288 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.
The library ships this as a built-in — prefer it over hand-rolling the encoding:
import { encode_members } from 'verifiablejs/nodejs'
// members: Uint8Array[] of 32-byte Bandersnatch public keys
const encodedMembers = encode_members(members)
// Throws if any element is not exactly 32 bytes or is not a valid member key.If you would rather not call into the WASM module, the equivalent encoding by hand is:
/**
* 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)
}For complex encoding needs, consider using @polkadot/types:
import { TypeRegistry, Vec } from '@polkadot/types'
const registry = new TypeRegistry()
// Encode Vec<Member> (each Member is a fixed 32-byte key)
const members = new Vec(registry, '[u8;32]', [memberA, memberB])
const encoded = members.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)
}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