Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions tutorials/anonymous-membership-proofs/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// This file is part of contributor-hub.
// Copyright (C) 2025 Midnight Foundation
// SPDX-License-Identifier: Apache-2.0
// Licensed under the Apache License, Version 2.0 (the "License");
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

pragma language_version >= 0.20;

import CompactStandardLibrary;

export ledger root: Bytes<32>;
export ledger adminCommitment: Bytes<32>;
export ledger acceptedCount: Counter;
export ledger nullifiers: Map<Bytes<32>, Boolean>;

witness adminSecret(): Bytes<32>;
witness memberLeaf(): Bytes<32>;
witness memberIndex(): Uint<32>;
witness memberPath(): Vector<20, Bytes<32>>;

circuit hashLeafSecret(secret: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "midnight:anonymous-membership:leaf:v1"),
secret
]);
}

circuit hashAdmin(secret: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([
pad(32, "midnight:anonymous-membership:admin:v1"),
secret
]);
}

circuit hashNullifier(scope: Bytes<32>, secret: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([
pad(32, "midnight:anonymous-membership:nullifier:v1"),
scope,
secret
]);
}

circuit hashNode(left: Bytes<32>, right: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([
pad(32, "midnight:anonymous-membership:node:v1"),
left,
right
]);
}

circuit verifyDepth20Path(
leaf: Bytes<32>,
index: Uint<32>,
path: Vector<20, Bytes<32>>,
expectedRoot: Bytes<32>
): Boolean {
let current = leaf;
let cursor = index;

for (let level = 0; level < 20; level = level + 1) {
const sibling = path[level];
if (cursor % 2 == 0) {
current = hashNode(current, sibling);
} else {
current = hashNode(sibling, current);
}
cursor = cursor / 2;
}

return current == expectedRoot;
}

constructor(initialRoot: Bytes<32>) {
adminCommitment = disclose(hashAdmin(adminSecret()));
root = disclose(initialRoot);
}

export circuit pushRoot(newRoot: Bytes<32>): [] {
assert(hashAdmin(adminSecret()) == adminCommitment, "only admin can update root");
root = disclose(newRoot);
}

export circuit proveMembership(scope: Bytes<32>, memberSecret: Bytes<32>): Bytes<32> {
const leaf = hashLeafSecret(memberSecret);
assert(leaf == memberLeaf(), "leaf does not match member secret");

const nullifier = hashNullifier(scope, memberSecret);
assert(nullifiers.lookup(nullifier) != true, "nullifier already used");

const verified = verifyDepth20Path(memberLeaf(), memberIndex(), memberPath(), root);
assert(verified, "invalid membership proof");

nullifiers.insert(nullifier, true);
acceptedCount.increment(1);
return disclose(nullifier);
}
13 changes: 13 additions & 0 deletions tutorials/anonymous-membership-proofs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "midnight-anonymous-membership-proofs",
"version": "1.0.0",
"private": true,
"description": "Sparse Merkle allowlist tutorial and tested reference implementation for Midnight anonymous membership proofs.",
"type": "module",
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=20"
}
}
67 changes: 67 additions & 0 deletions tutorials/anonymous-membership-proofs/src/allowlistContract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// This file is part of contributor-hub.
// Copyright (C) 2025 Midnight Foundation
// SPDX-License-Identifier: Apache-2.0
// Licensed under the Apache License, Version 2.0 (the "License");
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { adminCommitment, assertHex32 } from './crypto.js';
import { DEFAULT_DEPTH, zeroHashes, verifySparseMerkleProof } from './sparseMerkleTree.js';

export class AnonymousMembershipContract {
constructor({ adminSecret, depth = DEFAULT_DEPTH } = {}) {
if (!adminSecret) {
throw new TypeError('adminSecret is required');
}
this.depth = depth;
this.admin = adminCommitment(adminSecret);
this.currentRoot = zeroHashes(depth)[depth];
this.usedNullifiers = new Set();
this.accepted = 0;
}

assertAdmin(adminSecret) {
if (adminCommitment(adminSecret) !== this.admin) {
throw new Error('only admin can update the root');
}
}

pushRoot({ adminSecret, newRoot }) {
this.assertAdmin(adminSecret);
assertHex32(newRoot, 'newRoot');
this.currentRoot = newRoot;
return this.currentRoot;
}

proveMembership({ leaf, index, path, nullifier }) {
assertHex32(nullifier, 'nullifier');
if (this.usedNullifiers.has(nullifier)) {
throw new Error('nullifier already used');
}

const verified = verifySparseMerkleProof({
leaf,
index,
path,
root: this.currentRoot,
depth: this.depth
});

if (!verified) {
throw new Error('invalid membership proof');
}

this.usedNullifiers.add(nullifier);
this.accepted += 1;
return { accepted: true, acceptedCount: this.accepted };
}
}

62 changes: 62 additions & 0 deletions tutorials/anonymous-membership-proofs/src/crypto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// This file is part of contributor-hub.
// Copyright (C) 2025 Midnight Foundation
// SPDX-License-Identifier: Apache-2.0
// Licensed under the Apache License, Version 2.0 (the "License");
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { createHash, randomBytes } from 'node:crypto';

const HEX_32 = /^[0-9a-f]{64}$/u;

export function randomHex32() {
return randomBytes(32).toString('hex');
}

export function assertHex32(value, name = 'value') {
if (typeof value !== 'string' || !HEX_32.test(value)) {
throw new TypeError(`${name} must be a 32-byte lowercase hex string`);
}
}

export function sha256Hex(...parts) {
const hash = createHash('sha256');
for (const part of parts) {
if (typeof part === 'string') {
hash.update(part);
} else if (part instanceof Uint8Array) {
hash.update(part);
} else {
hash.update(JSON.stringify(part));
}
hash.update('\0');
}
return hash.digest('hex');
}

export function memberLeaf(secret) {
assertHex32(secret, 'secret');
return sha256Hex('midnight:anonymous-membership:leaf:v1', secret);
}

export function memberNullifier(secret, scope) {
assertHex32(secret, 'secret');
if (typeof scope !== 'string' || scope.length === 0) {
throw new TypeError('scope must be a non-empty string');
}
return sha256Hex('midnight:anonymous-membership:nullifier:v1', scope, secret);
}

export function adminCommitment(secret) {
assertHex32(secret, 'secret');
return sha256Hex('midnight:anonymous-membership:admin:v1', secret);
}

122 changes: 122 additions & 0 deletions tutorials/anonymous-membership-proofs/src/sparseMerkleTree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// This file is part of contributor-hub.
// Copyright (C) 2025 Midnight Foundation
// SPDX-License-Identifier: Apache-2.0
// Licensed under the Apache License, Version 2.0 (the "License");
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { assertHex32, sha256Hex } from './crypto.js';

export const DEFAULT_DEPTH = 20;

function assertDepth(depth) {
if (!Number.isInteger(depth) || depth < 1 || depth > 32) {
throw new RangeError('depth must be an integer between 1 and 32');
}
}

function assertIndex(index, depth) {
if (!Number.isInteger(index) || index < 0 || index >= 2 ** depth) {
throw new RangeError(`index must be an integer in [0, ${2 ** depth})`);
}
}

export function hashNode(left, right) {
assertHex32(left, 'left');
assertHex32(right, 'right');
return sha256Hex('midnight:anonymous-membership:node:v1', left, right);
}

export function zeroHashes(depth = DEFAULT_DEPTH) {
assertDepth(depth);
const zeros = [sha256Hex('midnight:anonymous-membership:zero:v1')];
for (let level = 0; level < depth; level += 1) {
zeros.push(hashNode(zeros[level], zeros[level]));
}
return zeros;
}

export class SparseMerkleTree {
constructor(depth = DEFAULT_DEPTH) {
assertDepth(depth);
this.depth = depth;
this.zeros = zeroHashes(depth);
this.leaves = new Map();
}

set(index, leaf) {
assertIndex(index, this.depth);
assertHex32(leaf, 'leaf');
this.leaves.set(index, leaf);
}

root() {
let level = new Map(this.leaves);
for (let height = 0; height < this.depth; height += 1) {
const parents = new Map();
const parentIndexes = new Set([...level.keys()].map((index) => Math.floor(index / 2)));
for (const parentIndex of parentIndexes) {
const left = level.get(parentIndex * 2) ?? this.zeros[height];
const right = level.get(parentIndex * 2 + 1) ?? this.zeros[height];
parents.set(parentIndex, hashNode(left, right));
}
level = parents;
}
return level.get(0) ?? this.zeros[this.depth];
}

proof(index) {
assertIndex(index, this.depth);
let cursor = index;
let level = new Map(this.leaves);
const siblings = [];

for (let height = 0; height < this.depth; height += 1) {
const siblingIndex = cursor % 2 === 0 ? cursor + 1 : cursor - 1;
siblings.push(level.get(siblingIndex) ?? this.zeros[height]);

const parents = new Map();
const parentIndexes = new Set([...level.keys()].map((leafIndex) => Math.floor(leafIndex / 2)));
for (const parentIndex of parentIndexes) {
const left = level.get(parentIndex * 2) ?? this.zeros[height];
const right = level.get(parentIndex * 2 + 1) ?? this.zeros[height];
parents.set(parentIndex, hashNode(left, right));
}

cursor = Math.floor(cursor / 2);
level = parents;
}

return siblings;
}
}

export function verifySparseMerkleProof({ leaf, index, path, root, depth = DEFAULT_DEPTH }) {
assertDepth(depth);
assertIndex(index, depth);
assertHex32(leaf, 'leaf');
assertHex32(root, 'root');
if (!Array.isArray(path) || path.length !== depth) {
throw new TypeError(`path must contain exactly ${depth} sibling hashes`);
}

let current = leaf;
let cursor = index;
for (let height = 0; height < depth; height += 1) {
const sibling = path[height];
assertHex32(sibling, `path[${height}]`);
current = cursor % 2 === 0 ? hashNode(current, sibling) : hashNode(sibling, current);
cursor = Math.floor(cursor / 2);
}

return current === root;
}

Loading