feat: @waku/rln (#2244)

* chore: setup rln as a new package

* chore: migrate src

* fix: wasm loading, tests, config

* chore: fix Karma CI

* fix: bundler

* chore: copy dist resources

* chore(rln): enable all tests

* chore: increase karma timeouts
This commit is contained in:
Danish Arora 2025-02-11 15:28:00 +05:30 committed by GitHub
parent becb46f3a5
commit 0a0a92bccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 5174 additions and 5 deletions

View File

@ -7,6 +7,8 @@
"Addrs", "Addrs",
"ahadns", "ahadns",
"Alives", "Alives",
"alphabeta",
"Arraylike",
"asym", "asym",
"autoshard", "autoshard",
"autosharding", "autosharding",
@ -17,9 +19,11 @@
"bufbuild", "bufbuild",
"chainsafe", "chainsafe",
"cimg", "cimg",
"cipherparams",
"ciphertext", "ciphertext",
"circleci", "circleci",
"codecov", "codecov",
"codegen",
"commitlint", "commitlint",
"dependabot", "dependabot",
"dialable", "dialable",
@ -57,6 +61,7 @@
"iwant", "iwant",
"jdev", "jdev",
"jswaku", "jswaku",
"kdfparams",
"keccak", "keccak",
"keypair", "keypair",
"lastpub", "lastpub",
@ -87,6 +92,7 @@
"proto", "proto",
"protobuf", "protobuf",
"protoc", "protoc",
"proxiable",
"reactjs", "reactjs",
"recid", "recid",
"rlnrelay", "rlnrelay",
@ -116,16 +122,18 @@
"upgrader", "upgrader",
"vacp", "vacp",
"varint", "varint",
"weboko", "vkey",
"waku", "waku",
"wakuconnect", "wakuconnect",
"wakunode", "wakunode",
"wakuorg", "wakuorg",
"wakuv", "wakuv",
"webfonts", "webfonts",
"weboko",
"websockets", "websockets",
"wifi", "wifi",
"xsalsa20", "xsalsa20",
"zerokit",
"Привет", "Привет",
"مرحبا" "مرحبا"
], ],

1289
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,8 @@
"packages/browser-tests", "packages/browser-tests",
"packages/build-utils", "packages/build-utils",
"packages/react-native-polyfills", "packages/react-native-polyfills",
"packages/sds" "packages/sds",
"packages/rln"
], ],
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
@ -70,5 +71,8 @@
"*.{ts,js}": [ "*.{ts,js}": [
"eslint --fix" "eslint --fix"
] ]
},
"dependencies": {
"@waku/utils": "^0.0.21"
} }
} }

View File

@ -0,0 +1,7 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: "./tsconfig.dev.json"
},
ignorePatterns: ["src/resources/**/*"]
};

27
packages/rln/.mocharc.cjs Normal file
View File

@ -0,0 +1,27 @@
const config = {
extension: ['ts'],
spec: 'src/**/*.spec.ts',
require: ['ts-node/register', 'isomorphic-fetch'],
loader: 'ts-node/esm',
'node-option': [
'experimental-specifier-resolution=node',
'loader=ts-node/esm'
],
exit: true,
retries: 4
};
if (process.env.CI) {
console.log("Running tests in parallel");
config.parallel = true;
config.jobs = 6;
console.log("Activating allure reporting");
config.reporter = 'mocha-multi-reporters';
config.reporterOptions = {
configFile: '.mocha.reporters.json'
};
} else {
console.log("Running tests serially. To enable parallel execution update mocha config");
}
module.exports = config;

25
packages/rln/README.md Normal file
View File

@ -0,0 +1,25 @@
# @waku/rln
Rate Limiting Nullifier (RLN) implementation for Waku.
## Description
This package provides RLN functionality for the Waku protocol, enabling rate-limiting capabilities while preserving privacy.
## Installation
```bash
npm install @waku/rln
```
## Usage
```typescript
import { RLN } from '@waku/rln';
// Usage examples coming soon
```
## License
MIT OR Apache-2.0

167
packages/rln/karma.conf.cjs Normal file
View File

@ -0,0 +1,167 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
const webpack = require("webpack");
const rootConfig = require("../../karma.conf.cjs");
module.exports = function (config) {
rootConfig(config);
const configuration = {
frameworks: ["mocha", "webpack"],
files: [
{
pattern: "src/**/*.spec.ts",
type: "js"
},
{
pattern: "src/resources/**/*.wasm",
included: false,
served: true,
watched: false,
type: "wasm",
nocache: true
},
{
pattern: "src/resources/**/*.zkey",
included: false,
served: true,
watched: false,
nocache: true
},
{
pattern: "../../node_modules/@waku/zerokit-rln-wasm/*.wasm",
included: false,
served: true,
watched: false,
type: "wasm",
nocache: true
}
],
preprocessors: {
"src/**/*.spec.ts": ["webpack"]
},
client: {
mocha: {
timeout: 180000 // 3 minutes
}
},
browserDisconnectTimeout: 180000, // 3 minutes
browserDisconnectTolerance: 3, // Number of tries before failing
browserNoActivityTimeout: 180000, // 3 minutes
captureTimeout: 300000, // 5 minutes
mime: {
"application/wasm": ["wasm"],
"application/octet-stream": ["zkey"]
},
customHeaders: [
{
match: ".*\\.wasm$",
name: "Content-Type",
value: "application/wasm"
},
{
match: ".*\\.zkey$",
name: "Content-Type",
value: "application/octet-stream"
}
],
proxies: {
"/base/rln_wasm_bg.wasm":
"/absolute" +
path.resolve(
__dirname,
"../../node_modules/@waku/zerokit-rln-wasm/rln_wasm_bg.wasm"
),
"/base/rln.wasm":
"/absolute" + path.resolve(__dirname, "src/resources/rln.wasm"),
"/base/rln_final.zkey":
"/absolute" + path.resolve(__dirname, "src/resources/rln_final.zkey")
},
webpack: {
mode: "development",
experiments: {
asyncWebAssembly: true,
syncWebAssembly: true,
topLevelAwait: true
},
output: {
wasmLoading: "fetch",
path: path.resolve(__dirname, "dist"),
publicPath: "/base/",
clean: true
},
module: {
rules: [
{
test: /\.ts$/,
use: "ts-loader",
exclude: /node_modules/
},
{
test: /\.wasm$/,
type: "asset/resource",
generator: {
filename: "[name][ext]"
}
},
{
test: /\.zkey$/,
type: "asset/resource",
generator: {
filename: "[name][ext]"
}
}
]
},
plugins: [
new webpack.DefinePlugin({
"process.env.CI": process.env.CI || false,
"process.env.DISPLAY": "Browser"
}),
new webpack.ProvidePlugin({
process: "process/browser.js"
})
],
resolve: {
extensions: [".ts", ".js", ".wasm"],
modules: ["node_modules", "../../node_modules"],
alias: {
"@waku/zerokit-rln-wasm": path.resolve(
__dirname,
"../../node_modules/@waku/zerokit-rln-wasm/rln_wasm.js"
)
},
fallback: {
crypto: false,
fs: false,
path: false,
stream: false
}
},
stats: { warnings: false },
devtool: "inline-source-map"
},
reporters: ["progress"],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: process.env.CI ? ["ChromeHeadlessCI"] : ["ChromeHeadless"],
singleRun: true,
concurrency: 1, // Reduce concurrency to avoid memory pressure
browserSocketTimeout: 180000 // 3 minutes
};
config.set(configuration);
};

89
packages/rln/package.json Normal file
View File

@ -0,0 +1,89 @@
{
"name": "@waku/rln",
"version": "0.0.1",
"description": "RLN (Rate Limiting Nullifier) implementation for Waku",
"types": "./dist/index.d.ts",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"type": "module",
"homepage": "https://github.com/waku-org/js-waku/tree/master/packages/rln#readme",
"repository": {
"type": "git",
"url": "https://github.com/waku-org/js-waku.git"
},
"bugs": {
"url": "https://github.com/waku-org/js-waku/issues"
},
"license": "MIT OR Apache-2.0",
"keywords": [
"waku",
"rln",
"rate-limiting",
"privacy",
"web3"
],
"scripts": {
"build": "run-s build:**",
"build:copy": "mkdir -p dist/resources && cp -r src/resources/* dist/resources/",
"build:esm": "tsc",
"build:bundle": "rollup --config rollup.config.js",
"fix": "run-s fix:*",
"fix:lint": "eslint src *.js --fix",
"check": "run-s check:*",
"check:tsc": "tsc -p tsconfig.dev.json",
"check:lint": "eslint \"src/!(resources)/**/*.{ts,js}\" *.js",
"check:spelling": "cspell \"{README.md,src/**/*.ts}\"",
"test": "NODE_ENV=test run-s test:*",
"test:browser": "karma start karma.conf.cjs",
"watch:build": "tsc -p tsconfig.json -w",
"watch:test": "mocha --watch",
"prepublish": "npm run build",
"reset-hard": "git clean -dfx -e .idea && git reset --hard && npm i && npm run build"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^5.0.1",
"@types/chai-spies": "^1.0.6",
"@types/deep-equal-in-any-order": "^1.0.4",
"@types/lodash": "^4.17.15",
"@waku/build-utils": "^1.0.0",
"@waku/message-encryption": "^0.0.31",
"chai": "^5.1.2",
"chai-as-promised": "^8.0.1",
"chai-spies": "^1.1.0",
"chai-subset": "^1.6.0",
"deep-equal-in-any-order": "^2.0.6",
"fast-check": "^3.23.2",
"rollup-plugin-copy": "^3.5.0"
},
"files": [
"dist",
"bundle",
"src/**/*.ts",
"!**/*.spec.*",
"!**/*.json",
"CHANGELOG.md",
"LICENSE",
"README.md"
],
"dependencies": {
"@chainsafe/bls-keystore": "3.0.0",
"@waku/core": "^0.0.33",
"@waku/utils": "^0.0.21",
"@waku/zerokit-rln-wasm": "^0.0.13",
"ethereum-cryptography": "^3.1.0",
"ethers": "^5.7.2",
"lodash": "^4.17.21",
"uuid": "^11.0.5"
}
}

View File

@ -0,0 +1,35 @@
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import { extractExports } from "@waku/build-utils";
import copy from "rollup-plugin-copy";
import * as packageJson from "./package.json" assert { type: "json" };
const input = extractExports(packageJson);
export default {
input,
output: {
dir: "bundle",
format: "esm",
preserveModules: true
},
plugins: [
copy({
targets: [
{
src: ["src/resources/*"],
dest: "bundle/resources"
}
],
copyOnce: true
}),
commonjs(),
json(),
nodeResolve({
browser: true,
preferBuiltins: false
})
]
};

View File

@ -0,0 +1,470 @@
import {
createDecoder,
createEncoder,
DecodedMessage
} from "@waku/core/lib/message/version_0";
import type { IProtoMessage } from "@waku/interfaces";
import {
generatePrivateKey,
generateSymmetricKey,
getPublicKey
} from "@waku/message-encryption";
import {
createDecoder as createAsymDecoder,
createEncoder as createAsymEncoder
} from "@waku/message-encryption/ecies";
import {
createDecoder as createSymDecoder,
createEncoder as createSymEncoder
} from "@waku/message-encryption/symmetric";
import { expect } from "chai";
import {
createRLNDecoder,
createRLNEncoder,
RLNDecoder,
RLNEncoder
} from "./codec.js";
import { createRLN } from "./create.js";
import { RlnMessage } from "./message.js";
import { epochBytesToInt } from "./utils/index.js";
const TestContentTopic = "/test/1/waku-message/utf8";
const EMPTY_PUBSUB_TOPIC = "";
const EMPTY_PROTO_MESSAGE = {
timestamp: undefined,
contentTopic: "",
ephemeral: undefined,
meta: undefined,
rateLimitProof: undefined,
version: undefined
};
describe("RLN codec with version 0", () => {
it("toWire", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const rlnEncoder = createRLNEncoder({
encoder: createEncoder({ contentTopic: TestContentTopic }),
rlnInstance,
index,
credential
});
const rlnDecoder = createRLNDecoder({
rlnInstance,
decoder: createDecoder(TestContentTopic)
});
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
protoResult!
))!;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(0);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
it("toProtoObj", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const rlnEncoder = new RLNEncoder(
createEncoder({ contentTopic: TestContentTopic }),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createDecoder(TestContentTopic)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
proto!
)) as RlnMessage<DecodedMessage>;
expect(msg).to.not.be.undefined;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(0);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
});
describe("RLN codec with version 1", () => {
it("Symmetric, toWire", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const symKey = generateSymmetricKey();
const rlnEncoder = new RLNEncoder(
createSymEncoder({
contentTopic: TestContentTopic,
symKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createSymDecoder(TestContentTopic, symKey)
);
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
protoResult!
))!;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(1);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
it("Symmetric, toProtoObj", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const symKey = generateSymmetricKey();
const rlnEncoder = new RLNEncoder(
createSymEncoder({
contentTopic: TestContentTopic,
symKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createSymDecoder(TestContentTopic, symKey)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
proto!
)) as RlnMessage<DecodedMessage>;
expect(msg).to.not.be.undefined;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(1);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
it("Asymmetric, toWire", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const privateKey = generatePrivateKey();
const publicKey = getPublicKey(privateKey);
const rlnEncoder = new RLNEncoder(
createAsymEncoder({
contentTopic: TestContentTopic,
publicKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createAsymDecoder(TestContentTopic, privateKey)
);
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
protoResult!
))!;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(1);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
it("Asymmetric, toProtoObj", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const privateKey = generatePrivateKey();
const publicKey = getPublicKey(privateKey);
const rlnEncoder = new RLNEncoder(
createAsymEncoder({
contentTopic: TestContentTopic,
publicKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createAsymDecoder(TestContentTopic, privateKey)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
proto!
)) as RlnMessage<DecodedMessage>;
expect(msg).to.not.be.undefined;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(1);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
});
describe("RLN Codec - epoch", () => {
it("toProtoObj", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const rlnEncoder = new RLNEncoder(
createEncoder({ contentTopic: TestContentTopic }),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createDecoder(TestContentTopic)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
proto!
)) as RlnMessage<DecodedMessage>;
const epochBytes = proto!.rateLimitProof!.epoch;
const epoch = epochBytesToInt(epochBytes);
expect(msg).to.not.be.undefined;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch!.toString(10).length).to.eq(9);
expect(msg.epoch).to.eq(epoch);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(0);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
});
describe("RLN codec with version 0 and meta setter", () => {
// Encode the length of the payload
// Not a relevant real life example
const metaSetter = (msg: IProtoMessage & { meta: undefined }): Uint8Array => {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint32(0, msg.payload.length, false);
return new Uint8Array(buffer);
};
it("toWire", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const rlnEncoder = createRLNEncoder({
encoder: createEncoder({ contentTopic: TestContentTopic, metaSetter }),
rlnInstance,
index,
credential
});
const rlnDecoder = createRLNDecoder({
rlnInstance,
decoder: createDecoder(TestContentTopic)
});
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
protoResult!
))!;
const expectedMeta = metaSetter({
...EMPTY_PROTO_MESSAGE,
payload: protoResult!.payload
});
expect(msg!.meta).to.deep.eq(expectedMeta);
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(0);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
it("toProtoObj", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
const index = 0;
const payload = new Uint8Array([1, 2, 3, 4, 5]);
rlnInstance.zerokit.insertMember(credential.IDCommitment);
const rlnEncoder = new RLNEncoder(
createEncoder({ contentTopic: TestContentTopic, metaSetter }),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createDecoder(TestContentTopic)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
EMPTY_PUBSUB_TOPIC,
proto!
)) as RlnMessage<DecodedMessage>;
const expectedMeta = metaSetter({
...EMPTY_PROTO_MESSAGE,
payload: msg!.payload
});
expect(msg!.meta).to.deep.eq(expectedMeta);
expect(msg).to.not.be.undefined;
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(TestContentTopic);
expect(msg.msg.version).to.eq(0);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
});
});

134
packages/rln/src/codec.ts Normal file
View File

@ -0,0 +1,134 @@
import type {
IDecodedMessage,
IDecoder,
IEncoder,
IMessage,
IProtoMessage,
IRateLimitProof
} from "@waku/interfaces";
import { Logger } from "@waku/utils";
import type { IdentityCredential } from "./identity.js";
import { RlnMessage, toRLNSignal } from "./message.js";
import { RLNInstance } from "./rln.js";
const log = new Logger("waku:rln:encoder");
export class RLNEncoder implements IEncoder {
private readonly idSecretHash: Uint8Array;
public constructor(
private readonly encoder: IEncoder,
private readonly rlnInstance: RLNInstance,
private readonly index: number,
identityCredential: IdentityCredential
) {
if (index < 0) throw new Error("Invalid membership index");
this.idSecretHash = identityCredential.IDSecretHash;
}
public async toWire(message: IMessage): Promise<Uint8Array | undefined> {
message.rateLimitProof = await this.generateProof(message);
log.info("Proof generated", message.rateLimitProof);
return this.encoder.toWire(message);
}
public async toProtoObj(
message: IMessage
): Promise<IProtoMessage | undefined> {
const protoMessage = await this.encoder.toProtoObj(message);
if (!protoMessage) return;
protoMessage.contentTopic = this.contentTopic;
protoMessage.rateLimitProof = await this.generateProof(message);
log.info("Proof generated", protoMessage.rateLimitProof);
return protoMessage;
}
private async generateProof(message: IMessage): Promise<IRateLimitProof> {
const signal = toRLNSignal(this.contentTopic, message);
const proof = await this.rlnInstance.zerokit.generateRLNProof(
signal,
this.index,
message.timestamp,
this.idSecretHash
);
return proof;
}
public get pubsubTopic(): string {
return this.encoder.pubsubTopic;
}
public get contentTopic(): string {
return this.encoder.contentTopic;
}
public get ephemeral(): boolean {
return this.encoder.ephemeral;
}
}
type RLNEncoderOptions = {
encoder: IEncoder;
rlnInstance: RLNInstance;
index: number;
credential: IdentityCredential;
};
export const createRLNEncoder = (options: RLNEncoderOptions): RLNEncoder => {
return new RLNEncoder(
options.encoder,
options.rlnInstance,
options.index,
options.credential
);
};
export class RLNDecoder<T extends IDecodedMessage>
implements IDecoder<RlnMessage<T>>
{
public constructor(
private readonly rlnInstance: RLNInstance,
private readonly decoder: IDecoder<T>
) {}
public get pubsubTopic(): string {
return this.decoder.pubsubTopic;
}
public get contentTopic(): string {
return this.decoder.contentTopic;
}
public fromWireToProtoObj(
bytes: Uint8Array
): Promise<IProtoMessage | undefined> {
const protoMessage = this.decoder.fromWireToProtoObj(bytes);
log.info("Message decoded", protoMessage);
return Promise.resolve(protoMessage);
}
public async fromProtoObj(
pubsubTopic: string,
proto: IProtoMessage
): Promise<RlnMessage<T> | undefined> {
const msg: T | undefined = await this.decoder.fromProtoObj(
pubsubTopic,
proto
);
if (!msg) return;
return new RlnMessage(this.rlnInstance, msg, proto.rateLimitProof);
}
}
type RLNDecoderOptions<T extends IDecodedMessage> = {
decoder: IDecoder<T>;
rlnInstance: RLNInstance;
};
export const createRLNDecoder = <T extends IDecodedMessage>(
options: RLNDecoderOptions<T>
): RLNDecoder<T> => {
return new RLNDecoder(options.rlnInstance, options.decoder);
};

View File

@ -0,0 +1,68 @@
// ref https://github.com/waku-org/waku-rln-contract/blob/19fded82bca07e7b535b429dc507cfb83f10dfcf/deployments/sepolia/WakuRlnRegistry_Implementation.json#L3
export const RLN_REGISTRY_ABI = [
"error IncompatibleStorage()",
"error IncompatibleStorageIndex()",
"error NoStorageContractAvailable()",
"error StorageAlreadyExists(address storageAddress)",
"event AdminChanged(address previousAdmin, address newAdmin)",
"event BeaconUpgraded(address indexed beacon)",
"event Initialized(uint8 version)",
"event NewStorageContract(uint16 index, address storageAddress)",
"event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)",
"event Upgraded(address indexed implementation)",
"function forceProgress()",
"function initialize(address _poseidonHasher)",
"function newStorage()",
"function nextStorageIndex() view returns (uint16)",
"function owner() view returns (address)",
"function poseidonHasher() view returns (address)",
"function proxiableUUID() view returns (bytes32)",
"function register(uint16 storageIndex, uint256 commitment)",
"function register(uint256[] commitments)",
"function register(uint16 storageIndex, uint256[] commitments)",
"function registerStorage(address storageAddress)",
"function renounceOwnership()",
"function storages(uint16) view returns (address)",
"function transferOwnership(address newOwner)",
"function upgradeTo(address newImplementation)",
"function upgradeToAndCall(address newImplementation, bytes data) payable",
"function usingStorageIndex() view returns (uint16)"
];
// ref https://github.com/waku-org/waku-rln-contract/blob/19fded82bca07e7b535b429dc507cfb83f10dfcf/deployments/sepolia/WakuRlnStorage_0.json#L3
export const RLN_STORAGE_ABI = [
"constructor(address _poseidonHasher, uint16 _contractIndex)",
"error DuplicateIdCommitment()",
"error FullTree()",
"error InvalidIdCommitment(uint256 idCommitment)",
"error NotImplemented()",
"event MemberRegistered(uint256 idCommitment, uint256 index)",
"event MemberWithdrawn(uint256 idCommitment, uint256 index)",
"event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)",
"function DEPTH() view returns (uint256)",
"function MEMBERSHIP_DEPOSIT() view returns (uint256)",
"function SET_SIZE() view returns (uint256)",
"function contractIndex() view returns (uint16)",
"function deployedBlockNumber() view returns (uint32)",
"function idCommitmentIndex() view returns (uint256)",
"function isValidCommitment(uint256 idCommitment) view returns (bool)",
"function memberExists(uint256) view returns (bool)",
"function members(uint256) view returns (uint256)",
"function owner() view returns (address)",
"function poseidonHasher() view returns (address)",
"function register(uint256[] idCommitments)",
"function register(uint256 idCommitment) payable",
"function renounceOwnership()",
"function slash(uint256 idCommitment, address receiver, uint256[8] proof) pure",
"function stakedAmounts(uint256) view returns (uint256)",
"function transferOwnership(address newOwner)",
"function verifier() view returns (address)",
"function withdraw() pure",
"function withdrawalBalance(address) view returns (uint256)"
];
export const SEPOLIA_CONTRACT = {
chainId: 11155111,
address: "0xF471d71E9b1455bBF4b85d475afb9BB0954A29c4",
abi: RLN_REGISTRY_ABI
};

View File

@ -0,0 +1,2 @@
export { RLNContract } from "./rln_contract.js";
export * from "./constants.js";

View File

@ -0,0 +1,82 @@
import { expect } from "chai";
import * as ethers from "ethers";
import { createRLN } from "../create.js";
import { SEPOLIA_CONTRACT } from "./constants.js";
import { RLNContract } from "./rln_contract.js";
describe("RLN Contract abstraction", () => {
it("should be able to fetch members from events and store to rln instance", async () => {
const rlnInstance = await createRLN();
let insertMemberCalled = false;
// Track if insertMember was called
const originalInsertMember = rlnInstance.zerokit.insertMember;
rlnInstance.zerokit.insertMember = function (
this: any,
...args: Parameters<typeof originalInsertMember>
) {
insertMemberCalled = true;
return originalInsertMember.apply(this, args);
};
const voidSigner = new ethers.VoidSigner(SEPOLIA_CONTRACT.address);
const rlnContract = new RLNContract(rlnInstance, {
registryAddress: SEPOLIA_CONTRACT.address,
signer: voidSigner
});
rlnContract["storageContract"] = {
queryFilter: () => Promise.resolve([mockEvent()])
} as unknown as ethers.Contract;
rlnContract["_membersFilter"] = {
address: "",
topics: []
} as unknown as ethers.EventFilter;
await rlnContract.fetchMembers(rlnInstance);
expect(insertMemberCalled).to.be.true;
});
it("should register a member", async () => {
const mockSignature =
"0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c";
const rlnInstance = await createRLN();
const voidSigner = new ethers.VoidSigner(SEPOLIA_CONTRACT.address);
const rlnContract = new RLNContract(rlnInstance, {
registryAddress: SEPOLIA_CONTRACT.address,
signer: voidSigner
});
let registerCalled = false;
rlnContract["storageIndex"] = 1;
rlnContract["_membersFilter"] = {
address: "",
topics: []
} as unknown as ethers.EventFilter;
rlnContract["registryContract"] = {
"register(uint16,uint256)": () => {
registerCalled = true;
return Promise.resolve({ wait: () => Promise.resolve(undefined) });
}
} as unknown as ethers.Contract;
const identity =
rlnInstance.zerokit.generateSeededIdentityCredential(mockSignature);
await rlnContract.registerWithIdentity(identity);
expect(registerCalled).to.be.true;
});
});
function mockEvent(): ethers.Event {
return {
args: {
idCommitment: { _hex: "0xb3df1c4e5600ef2b" },
index: ethers.BigNumber.from(1)
}
} as unknown as ethers.Event;
}

View File

@ -0,0 +1,353 @@
import { Logger } from "@waku/utils";
import { hexToBytes } from "@waku/utils/bytes";
import { ethers } from "ethers";
import type { IdentityCredential } from "../identity.js";
import type { DecryptedCredentials } from "../keystore/index.js";
import type { RLNInstance } from "../rln.js";
import { MerkleRootTracker } from "../root_tracker.js";
import { zeroPadLE } from "../utils/index.js";
import { RLN_REGISTRY_ABI, RLN_STORAGE_ABI } from "./constants.js";
const log = new Logger("waku:rln:contract");
type Member = {
idCommitment: string;
index: ethers.BigNumber;
};
type Signer = ethers.Signer;
type RLNContractOptions = {
signer: Signer;
registryAddress: string;
};
type RLNStorageOptions = {
storageIndex?: number;
};
type RLNContractInitOptions = RLNContractOptions & RLNStorageOptions;
type FetchMembersOptions = {
fromBlock?: number;
fetchRange?: number;
fetchChunks?: number;
};
export class RLNContract {
private registryContract: ethers.Contract;
private merkleRootTracker: MerkleRootTracker;
private deployBlock: undefined | number;
private storageIndex: undefined | number;
private storageContract: undefined | ethers.Contract;
private _membersFilter: undefined | ethers.EventFilter;
private _members: Map<number, Member> = new Map();
public static async init(
rlnInstance: RLNInstance,
options: RLNContractInitOptions
): Promise<RLNContract> {
const rlnContract = new RLNContract(rlnInstance, options);
await rlnContract.initStorageContract(options.signer);
await rlnContract.fetchMembers(rlnInstance);
rlnContract.subscribeToMembers(rlnInstance);
return rlnContract;
}
public constructor(
rlnInstance: RLNInstance,
{ registryAddress, signer }: RLNContractOptions
) {
const initialRoot = rlnInstance.zerokit.getMerkleRoot();
this.registryContract = new ethers.Contract(
registryAddress,
RLN_REGISTRY_ABI,
signer
);
this.merkleRootTracker = new MerkleRootTracker(5, initialRoot);
}
private async initStorageContract(
signer: Signer,
options: RLNStorageOptions = {}
): Promise<void> {
const storageIndex = options?.storageIndex
? options.storageIndex
: await this.registryContract.usingStorageIndex();
const storageAddress = await this.registryContract.storages(storageIndex);
if (!storageAddress || storageAddress === ethers.constants.AddressZero) {
throw Error("No RLN Storage initialized on registry contract.");
}
this.storageIndex = storageIndex;
this.storageContract = new ethers.Contract(
storageAddress,
RLN_STORAGE_ABI,
signer
);
this._membersFilter = this.storageContract.filters.MemberRegistered();
this.deployBlock = await this.storageContract.deployedBlockNumber();
}
public get registry(): ethers.Contract {
if (!this.registryContract) {
throw Error("Registry contract was not initialized");
}
return this.registryContract as ethers.Contract;
}
public get contract(): ethers.Contract {
if (!this.storageContract) {
throw Error("Storage contract was not initialized");
}
return this.storageContract as ethers.Contract;
}
public get members(): Member[] {
const sortedMembers = Array.from(this._members.values()).sort(
(left, right) => left.index.toNumber() - right.index.toNumber()
);
return sortedMembers;
}
private get membersFilter(): ethers.EventFilter {
if (!this._membersFilter) {
throw Error("Members filter was not initialized.");
}
return this._membersFilter as ethers.EventFilter;
}
public async fetchMembers(
rlnInstance: RLNInstance,
options: FetchMembersOptions = {}
): Promise<void> {
const registeredMemberEvents = await queryFilter(this.contract, {
fromBlock: this.deployBlock,
...options,
membersFilter: this.membersFilter
});
this.processEvents(rlnInstance, registeredMemberEvents);
}
public processEvents(rlnInstance: RLNInstance, events: ethers.Event[]): void {
const toRemoveTable = new Map<number, number[]>();
const toInsertTable = new Map<number, ethers.Event[]>();
events.forEach((evt) => {
if (!evt.args) {
return;
}
if (evt.removed) {
const index: ethers.BigNumber = evt.args.index;
const toRemoveVal = toRemoveTable.get(evt.blockNumber);
if (toRemoveVal != undefined) {
toRemoveVal.push(index.toNumber());
toRemoveTable.set(evt.blockNumber, toRemoveVal);
} else {
toRemoveTable.set(evt.blockNumber, [index.toNumber()]);
}
} else {
let eventsPerBlock = toInsertTable.get(evt.blockNumber);
if (eventsPerBlock == undefined) {
eventsPerBlock = [];
}
eventsPerBlock.push(evt);
toInsertTable.set(evt.blockNumber, eventsPerBlock);
}
});
this.removeMembers(rlnInstance, toRemoveTable);
this.insertMembers(rlnInstance, toInsertTable);
}
private insertMembers(
rlnInstance: RLNInstance,
toInsert: Map<number, ethers.Event[]>
): void {
toInsert.forEach((events: ethers.Event[], blockNumber: number) => {
events.forEach((evt) => {
const _idCommitment = evt?.args?.idCommitment;
const index: ethers.BigNumber = evt?.args?.index;
if (!_idCommitment || !index) {
return;
}
const idCommitment = zeroPadLE(hexToBytes(_idCommitment?._hex), 32);
rlnInstance.zerokit.insertMember(idCommitment);
this._members.set(index.toNumber(), {
index,
idCommitment: _idCommitment?._hex
});
});
const currentRoot = rlnInstance.zerokit.getMerkleRoot();
this.merkleRootTracker.pushRoot(blockNumber, currentRoot);
});
}
private removeMembers(
rlnInstance: RLNInstance,
toRemove: Map<number, number[]>
): void {
const removeDescending = new Map([...toRemove].sort().reverse());
removeDescending.forEach((indexes: number[], blockNumber: number) => {
indexes.forEach((index) => {
if (this._members.has(index)) {
this._members.delete(index);
rlnInstance.zerokit.deleteMember(index);
}
});
this.merkleRootTracker.backFill(blockNumber);
});
}
public subscribeToMembers(rlnInstance: RLNInstance): void {
this.contract.on(this.membersFilter, (_pubkey, _index, event) =>
this.processEvents(rlnInstance, [event])
);
}
public async registerWithIdentity(
identity: IdentityCredential
): Promise<DecryptedCredentials | undefined> {
if (this.storageIndex === undefined) {
throw Error(
"Cannot register credential, no storage contract index found."
);
}
const txRegisterResponse: ethers.ContractTransaction =
await this.registryContract["register(uint16,uint256)"](
this.storageIndex,
identity.IDCommitmentBigInt,
{ gasLimit: 100000 }
);
const txRegisterReceipt = await txRegisterResponse.wait();
// assumption: register(uint16,uint256) emits one event
const memberRegistered = txRegisterReceipt?.events?.[0];
if (!memberRegistered) {
return undefined;
}
const decodedData = this.contract.interface.decodeEventLog(
"MemberRegistered",
memberRegistered.data
);
const network = await this.registryContract.provider.getNetwork();
const address = this.registryContract.address;
const membershipId = decodedData.index.toNumber();
return {
identity,
membership: {
address,
treeIndex: membershipId,
chainId: network.chainId
}
};
}
public roots(): Uint8Array[] {
return this.merkleRootTracker.roots();
}
}
type CustomQueryOptions = FetchMembersOptions & {
membersFilter: ethers.EventFilter;
};
// these value should be tested on other networks
const FETCH_CHUNK = 5;
const BLOCK_RANGE = 3000;
async function queryFilter(
contract: ethers.Contract,
options: CustomQueryOptions
): Promise<ethers.Event[]> {
const {
fromBlock,
membersFilter,
fetchRange = BLOCK_RANGE,
fetchChunks = FETCH_CHUNK
} = options;
if (!fromBlock) {
return contract.queryFilter(membersFilter);
}
if (!contract.signer.provider) {
throw Error("No provider found on the contract's signer.");
}
const toBlock = await contract.signer.provider.getBlockNumber();
if (toBlock - fromBlock < fetchRange) {
return contract.queryFilter(membersFilter);
}
const events: ethers.Event[][] = [];
const chunks = splitToChunks(fromBlock, toBlock, fetchRange);
for (const portion of takeN<[number, number]>(chunks, fetchChunks)) {
const promises = portion.map(([left, right]) =>
ignoreErrors(contract.queryFilter(membersFilter, left, right), [])
);
const fetchedEvents = await Promise.all(promises);
events.push(fetchedEvents.flatMap((v) => v));
}
return events.flatMap((v) => v);
}
function splitToChunks(
from: number,
to: number,
step: number
): Array<[number, number]> {
const chunks = [];
let left = from;
while (left < to) {
const right = left + step < to ? left + step : to;
chunks.push([left, right] as [number, number]);
left = right;
}
return chunks;
}
function* takeN<T>(array: T[], size: number): Iterable<T[]> {
let start = 0;
while (start < array.length) {
const portion = array.slice(start, start + size);
yield portion;
start += size;
}
}
function ignoreErrors<T>(promise: Promise<T>, defaultValue: T): Promise<T> {
return promise.catch((err) => {
log.info(`Ignoring an error during query: ${err?.message}`);
return defaultValue;
});
}

View File

@ -0,0 +1,137 @@
import { assert, expect } from "chai";
import { createRLN } from "./create.js";
describe("js-rln", () => {
it("should verify a proof", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
//peer's index in the Merkle Tree
const index = 5;
// Create a Merkle tree with random members
for (let i = 0; i < 10; i++) {
if (i == index) {
// insert the current peer's pk
rlnInstance.zerokit.insertMember(credential.IDCommitment);
} else {
// create a new key pair
rlnInstance.zerokit.insertMember(
rlnInstance.zerokit.generateIdentityCredentials().IDCommitment
);
}
}
// prepare the message
const uint8Msg = Uint8Array.from(
"Hello World".split("").map((x) => x.charCodeAt(0))
);
// setting up the epoch
const epoch = new Date();
// generating proof
const proof = await rlnInstance.zerokit.generateRLNProof(
uint8Msg,
index,
epoch,
credential.IDSecretHash
);
try {
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.true;
} catch (err) {
assert.fail(0, 1, "should not have failed proof verification");
}
try {
// Modifying the signal so it's invalid
uint8Msg[4] = 4;
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.false;
} catch (err) {
console.log(err);
}
});
it("should verify a proof with a seeded membership key generation", async function () {
const rlnInstance = await createRLN();
const seed = "This is a test seed";
const credential =
rlnInstance.zerokit.generateSeededIdentityCredential(seed);
//peer's index in the Merkle Tree
const index = 5;
// Create a Merkle tree with random members
for (let i = 0; i < 10; i++) {
if (i == index) {
// insert the current peer's pk
rlnInstance.zerokit.insertMember(credential.IDCommitment);
} else {
// create a new key pair
rlnInstance.zerokit.insertMember(
rlnInstance.zerokit.generateIdentityCredentials().IDCommitment
);
}
}
// prepare the message
const uint8Msg = Uint8Array.from(
"Hello World".split("").map((x) => x.charCodeAt(0))
);
// setting up the epoch
const epoch = new Date();
// generating proof
const proof = await rlnInstance.zerokit.generateRLNProof(
uint8Msg,
index,
epoch,
credential.IDSecretHash
);
try {
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.true;
} catch (err) {
assert.fail(0, 1, "should not have failed proof verification");
}
try {
// Modifying the signal so it's invalid
uint8Msg[4] = 4;
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.false;
} catch (err) {
console.log(err);
}
});
it("should generate the same membership key if the same seed is provided", async function () {
const rlnInstance = await createRLN();
const seed = "This is a test seed";
const memKeys1 = rlnInstance.zerokit.generateSeededIdentityCredential(seed);
const memKeys2 = rlnInstance.zerokit.generateSeededIdentityCredential(seed);
memKeys1.IDCommitment.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDCommitment[index]);
});
memKeys1.IDNullifier.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDNullifier[index]);
});
memKeys1.IDSecretHash.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDSecretHash[index]);
});
memKeys1.IDTrapdoor.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDTrapdoor[index]);
});
});
});

View File

@ -0,0 +1,9 @@
import type { RLNInstance } from "./rln.js";
export async function createRLN(): Promise<RLNInstance> {
// A dependency graph that contains any wasm must all be imported
// asynchronously. This file does the single async import, so
// that no one else needs to worry about it again.
const rlnModule = await import("./rln.js");
return rlnModule.create();
}

View File

@ -0,0 +1,31 @@
import { buildBigIntFromUint8Array } from "./utils/index.js";
export class IdentityCredential {
public constructor(
public readonly IDTrapdoor: Uint8Array,
public readonly IDNullifier: Uint8Array,
public readonly IDSecretHash: Uint8Array,
public readonly IDCommitment: Uint8Array,
public readonly IDCommitmentBigInt: bigint
) {}
public static fromBytes(memKeys: Uint8Array): IdentityCredential {
if (memKeys.length < 128) {
throw new Error("Invalid memKeys length - must be at least 128 bytes");
}
const idTrapdoor = memKeys.subarray(0, 32);
const idNullifier = memKeys.subarray(32, 64);
const idSecretHash = memKeys.subarray(64, 96);
const idCommitment = memKeys.subarray(96, 128);
const idCommitmentBigInt = buildBigIntFromUint8Array(idCommitment, 32);
return new IdentityCredential(
idTrapdoor,
idNullifier,
idSecretHash,
idCommitment,
idCommitmentBigInt
);
}
}

30
packages/rln/src/index.ts Normal file
View File

@ -0,0 +1,30 @@
import { RLNDecoder, RLNEncoder } from "./codec.js";
import {
RLN_REGISTRY_ABI,
RLN_STORAGE_ABI,
SEPOLIA_CONTRACT
} from "./contract/index.js";
import { RLNContract } from "./contract/index.js";
import { createRLN } from "./create.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import { Proof } from "./proof.js";
import { RLNInstance } from "./rln.js";
import { MerkleRootTracker } from "./root_tracker.js";
import { extractMetaMaskSigner } from "./utils/index.js";
export {
createRLN,
Keystore,
RLNInstance,
IdentityCredential,
Proof,
RLNEncoder,
RLNDecoder,
MerkleRootTracker,
RLNContract,
RLN_STORAGE_ABI,
RLN_REGISTRY_ABI,
SEPOLIA_CONTRACT,
extractMetaMaskSigner
};

View File

@ -0,0 +1,54 @@
import type { IKeystore as IEipKeystore } from "@chainsafe/bls-keystore";
import { cipherDecrypt } from "@chainsafe/bls-keystore/lib/cipher";
import { kdf } from "@chainsafe/bls-keystore/lib/kdf";
import { normalizePassword } from "@chainsafe/bls-keystore/lib/password";
import { keccak256 } from "ethereum-cryptography/keccak";
import {
bytesToHex,
concatBytes,
hexToBytes
} from "ethereum-cryptography/utils";
import type { Keccak256Hash, Password } from "./types.js";
// eipKeystore supports only sha256 checksum so we just make an assumption it is keccak256
const validateChecksum = async (
password: Password,
eipKeystore: IEipKeystore
): Promise<boolean> => {
const computedChecksum = await keccak256Checksum(password, eipKeystore);
return computedChecksum === eipKeystore.crypto.checksum.message;
};
// decrypt from @chainsafe/bls-keystore supports only sha256
// but nwaku uses keccak256
// https://github.com/waku-org/nwaku/blob/25d6e52e3804d15f9b61bc4cc6dd448540c072a1/waku/waku_keystore/keyfile.nim#L367
export const decryptEipKeystore = async (
password: Password,
eipKeystore: IEipKeystore
): Promise<Uint8Array> => {
const decryptionKey = await kdf(
eipKeystore.crypto.kdf,
normalizePassword(password)
);
const isChecksumValid = await validateChecksum(password, eipKeystore);
if (!isChecksumValid) {
throw Error("Password is invalid.");
}
return cipherDecrypt(eipKeystore.crypto.cipher, decryptionKey.slice(0, 16));
};
export const keccak256Checksum = async (
password: Password,
eipKeystore: IEipKeystore
): Promise<Keccak256Hash> => {
const key = await kdf(eipKeystore.crypto.kdf, normalizePassword(password));
const payload = concatBytes(
key.slice(16),
hexToBytes(eipKeystore.crypto.cipher.message)
);
const ciphertext = keccak256(payload);
return bytesToHex(ciphertext);
};

View File

@ -0,0 +1,7 @@
/* eslint eslint-comments/no-unlimited-disable: "off" */
// This file was generated by /scripts/schema-validation-codegen.ts
// Do not modify this file by hand.
/* eslint-disable */
// @ts-ignore
"use strict";export const Credential = validate11;const schema12 = {"type":"object","properties":{"crypto":{"type":"object","properties":{"cipher":{"type":"string"},"cipherparams":{"type":"object"},"ciphertext":{"type":"string"},"kdf":{"type":"string"},"kdfparams":{"type":"object"},"mac":{"type":"string"}},"required":["cipher","cipherparams","ciphertext","kdf","kdfparams","mac"]}},"required":["crypto"]};function validate11(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((data.crypto === undefined) && (missing0 = "crypto")){validate11.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {if(data.crypto !== undefined){let data0 = data.crypto;const _errs1 = errors;if(errors === _errs1){if(data0 && typeof data0 == "object" && !Array.isArray(data0)){let missing1;if(((((((data0.cipher === undefined) && (missing1 = "cipher")) || ((data0.cipherparams === undefined) && (missing1 = "cipherparams"))) || ((data0.ciphertext === undefined) && (missing1 = "ciphertext"))) || ((data0.kdf === undefined) && (missing1 = "kdf"))) || ((data0.kdfparams === undefined) && (missing1 = "kdfparams"))) || ((data0.mac === undefined) && (missing1 = "mac"))){validate11.errors = [{instancePath:instancePath+"/crypto",schemaPath:"#/properties/crypto/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];return false;}else {if(data0.cipher !== undefined){const _errs3 = errors;if(typeof data0.cipher !== "string"){validate11.errors = [{instancePath:instancePath+"/crypto/cipher",schemaPath:"#/properties/crypto/properties/cipher/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs3 === errors;}else {var valid1 = true;}if(valid1){if(data0.cipherparams !== undefined){let data2 = data0.cipherparams;const _errs5 = errors;if(!(data2 && typeof data2 == "object" && !Array.isArray(data2))){validate11.errors = [{instancePath:instancePath+"/crypto/cipherparams",schemaPath:"#/properties/crypto/properties/cipherparams/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid1 = _errs5 === errors;}else {var valid1 = true;}if(valid1){if(data0.ciphertext !== undefined){const _errs7 = errors;if(typeof data0.ciphertext !== "string"){validate11.errors = [{instancePath:instancePath+"/crypto/ciphertext",schemaPath:"#/properties/crypto/properties/ciphertext/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data0.kdf !== undefined){const _errs9 = errors;if(typeof data0.kdf !== "string"){validate11.errors = [{instancePath:instancePath+"/crypto/kdf",schemaPath:"#/properties/crypto/properties/kdf/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data0.kdfparams !== undefined){let data5 = data0.kdfparams;const _errs11 = errors;if(!(data5 && typeof data5 == "object" && !Array.isArray(data5))){validate11.errors = [{instancePath:instancePath+"/crypto/kdfparams",schemaPath:"#/properties/crypto/properties/kdfparams/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data0.mac !== undefined){const _errs13 = errors;if(typeof data0.mac !== "string"){validate11.errors = [{instancePath:instancePath+"/crypto/mac",schemaPath:"#/properties/crypto/properties/mac/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}}}}}}}}else {validate11.errors = [{instancePath:instancePath+"/crypto",schemaPath:"#/properties/crypto/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}}}}else {validate11.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate11.errors = vErrors;return errors === 0;}

View File

@ -0,0 +1,5 @@
import { Keystore } from "./keystore.js";
import type { DecryptedCredentials, EncryptedCredentials } from "./types.js";
export { Keystore };
export type { EncryptedCredentials, DecryptedCredentials };

View File

@ -0,0 +1,313 @@
import * as chai from "chai";
import chaiAsPromised from "chai-as-promised";
import chaiSubset from "chai-subset";
import deepEqualInAnyOrder from "deep-equal-in-any-order";
const { expect } = chai;
chai.use(chaiSubset);
chai.use(deepEqualInAnyOrder);
chai.use(chaiAsPromised);
import { IdentityCredential } from "../identity.js";
import { buildBigIntFromUint8Array } from "../utils/bytes.js";
import { Keystore } from "./keystore.js";
import type { MembershipInfo } from "./types.js";
const DEFAULT_PASSWORD = "sup3rsecure";
const NWAKU_KEYSTORE = {
application: "waku-rln-relay",
appIdentifier: "01234567890abcdef",
version: "0.2",
credentials: {
"9DB2B4718A97485B9F70F68D1CC19F4E10F0B4CE943418838E94956CB8E57548": {
crypto: {
cipher: "aes-128-ctr",
cipherparams: {
iv: "fd6b39eb71d44c59f6bf5ff3d8945c80"
},
ciphertext:
"9c72f47ce95de03ed34502d0288e7576b66b51b9e7d5ae882c27bd89f94e6a03c2c44c2ddf0c982e72003d67212105f1b64614f57cabb0ceadab7e07be165eee1121ad6b81951368a9f3be2dd99ea294515f6013d5f2bd4702a40e36cfde2ea298b23b31e5ce719d8040c3331f73d6bf44f88bca39bac0e917d8bf545500e4f40d321c235426a80f315ac70666acbd3bdf803fbc1e7e7103fed466525ed332b25d72b2dbedf6fa383b2305987c1fe276b029570519b3e79930edf08c1029868d05c2c08ab61d7c64f63c054b4f6a5a12d43cdc79751b6fe58d3ed26b69443eb7c9f7efce27912340129c91b6b813ac94efd5776a40b1dda896d61357de208c7c47a14af911cc231355c8093ee6626e89c07e1037f9e0b22c690e3e049014399ca0212c509cb04c71c7860d1b17a0c47711c490c27bad2825926148a1f15a507f36ba2cdaa04897fce2914e53caed0beaf1bebd2a83af76511cc15bff2165ff0860ad6eca1f30022d7739b2a6b6a72f2feeef0f5941183cda015b4631469e1f4cf27003cab9a90920301cb30d95e4554686922dc5a05c13dfb575cdf113c700d607896011970e6ee7d6edb61210ab28ac8f0c84c606c097e3e300f0a5f5341edfd15432bef6225a498726b62a98283829ad51023b2987f30686cfb4ea3951f3957654035ec291f9b0964a3a8665d81b16cec20fb40f944d5f9bf03ac1e444ad45bae3fa85e7465ce620c0966d8148d6e2856f676c4fbbe3ebe470453efb4bbda1866680037917e37765f680e3da96ef3991f3fe5cda80c523996c2234758bf5f7b6d052dc6942f5a92c8b8eec5d2d8940203bbb6b1cba7b7ebc1334334ca69cdb509a5ea58ec6b2ebaea52307589eaae9430eb15ad234c0c39c83accdf3b77e52a616e345209c5bc9b442f9f0fa96836d9342f983a7",
kdf: "pbkdf2",
kdfparams: {
dklen: 32,
c: 1000000,
prf: "hmac-sha256",
salt: "60f0aa92fbf63a8356dfdbed2ab18058"
},
mac: "51a227ac6db7f2797c63925880b3db664e034231a4c68daa919ab42d8df38bc6"
}
},
"263335559F0578FD785F9CDFEDBB45CFF276799A27580B8F580CDFDCB990257C": {
crypto: {
cipher: "aes-128-ctr",
cipherparams: {
iv: "69f95461f811ac35a21987b1fdaa605e"
},
ciphertext:
"edfe844f8e2aedd62f26753e7247554920352b6b167f54ea4f728cd715577e9d2b7192b782471914870794205e77c2708b6db2d0ada19fec6b3533098cb2b7350bbaf81526d6bde7f1d0e83c366e3a2ddcced942cfb09a3c7704db7041132c3b511fed2f6d8599e6cddf649250b240687c2c335bf0aa75c892bc97f81c537898aefed20d1488e816d54eec72572acf36f140dc98cba0430cdeb8a00b8e8c8edf9b1292ca0e9c9a606acec51ea3dbe46438cb74b95d708cec18f8f126aecabbff11dd068d9194b25803f959f0bb62d49785dbc694486754f46bfe084cfa780cae27eca48cdcc88f4083d166d1747b8e2e637619e5d3848b9b6cdf7c7161eda8e476edfc083d417691d47b84fb224bfd26bf7713958893b934388e50783e49c5c84999971538ccda14c54b48b0d4aa37503e2a40212e9a1407d5a1ea4e96760de3d87e1b2287465a4e51cf330b7f1d14e3f2fb6521d10d32c798856464927b1e0286086a78f07a8f6f436d8c0c7b530f585320515e276d82c7b1f244702fa9ca6e6ad164fd2b1d9badcbdc17e01e95abf58e6825d8eeba5bc22db3a66dd41c64887d4c862298e921b3bae17d9fb7be1f619c60c82bd60dee351b77514d36e25d4092d6cde8ab613c40a117f7b784c80d65310e5b9cf1a31ba555f848e6984cc0c2d48315167d60131f3ffaaca5c81e359134bbfc81fa217f29b533868604ced4a2c5da8c89bd1238147b9f348168864ebea40c36a6abbf3d59d43086f26777104ce0a9f60cbf350058a337bc66abd5e4976950e5908192f98a9a8c1913abbc0d918479aeaa99e89a0e5cd65fd84a347d73df1d9c829863728a6fcd90150e52ecdec48bd07802110384f6c0aff0ca05ad42feb521223b58719fd4fc4ae88df8225ea58e303e4c61e8288e80f854bf0b",
kdf: "pbkdf2",
kdfparams: {
dklen: 32,
c: 1000000,
prf: "hmac-sha256",
salt: "3cf796e4857f296bef3bdb9ca844b1bf"
},
mac: "3d6cb0492afcf89c891365f097ae8989dc50038010c419b18228be6816c24c32"
}
},
F62A7FCE85E5B796AFCB38F54A44210515CB688EA0224E9A436CCA0A542F2C9D: {
crypto: {
cipher: "aes-128-ctr",
cipherparams: {
iv: "49816dacf881c85db9f11f7f068dcf71"
},
ciphertext:
"d7d805ee24dd34368e3d1829aa6d0856ca2ea54d9bcdc2655f8f197af0d293aff56c6e06de3137b0eddffbcad7cc0b8e3f6ba761ac7983d8e59ce04c8936868297b9f70238cb295e17567f2404b278b93c985496a6e1e46185965491449ccbc1e7155224acdba354ed18b1b9867ff6f1a833a77c9b21e2e9c4b6af27d5bd6303efd574465920928e5c467bd3c7888c3f31e8bece6af2e0c35fa03661399e9b420eeecd4376cb2b3266692f46c03161bb32cc2c79521f7b19cb0e6ec911213e105967f8887d94c73e793b18e4c14ee045dca13fcfb62ae267d3175f8a4fefd0e8bd636bd9431cc0cc7119e75f116a16dcbdcac1c15a3dcec57e1c49dbf5dccd1c75c0cfcb3473e81e8546048ce5231a4d4c8dd5d66311354e9ab70ad5745d5be27746954a08b0b29218562bfb632ae0a498cf09d7955a27377ed7a50fc1b4adaa0a3fb3e87a3b4d923136be0767a1428050944b9fd247332dea1b5016dfa1ec4da167e70e11e07cd58034b8470366dc16d77978b49a61e213ab5a7817fd69af26c2a8c3cd3a488d6e1491e0215071e1f3e9d49d0dfab3a7e324644c98a088e20259980495dcc379dcdce2e61752711bdf8abf057a2e696624078601245828193d838cc806065ee3f2bb138302ec72c70f34f14c0ab816211011f0ac55423732875e220175c717f6bc86f071bb4fab51c1963eb5c5d70d504c1e4d2307a8c8c4b8b5a84566a4606deb3fc6d7a420adc2b2b37c0ef3018f82a3ce0044e082407e8e7cb6214a3abc139b7f75b2c36c6902080e7696c730ab062e75e597274e0c945b6a7a366d20bd210dd02b097071142d033597e2fc4174be683a866510fa1c2fe150a2fb81dbd2b5da25da27f29367fb22dd4e9d4785856e4deea56219f9495fb3ab772f7867db11cb14026b",
kdf: "pbkdf2",
kdfparams: {
dklen: 32,
c: 1000000,
prf: "hmac-sha256",
salt: "2dcb5ba5c98fe5e46d961dad36e79a5b"
},
mac: "2d6e9de6440f52c5db64b13f80399967c8770e82616294e14f40a2e213e7d925"
}
},
"8479C6B9125D43E7B7739F1BAB41779F2F5A4D27FF0E2B6F6CA353032010A22C": {
crypto: {
cipher: "aes-128-ctr",
cipherparams: {
iv: "b0eef2c385a04909c4ae9b318e179fa7"
},
ciphertext:
"90b982222072366566fa194be5c170506888e184cadbd52aa38f184ac4e9bc160cc719d809fb6a128e0cbd908e70a71efdc5d51c4dab8aab71e3e6a2ebd9ea4238cb47585137990e896dfa53961bb2b328abfbba82f49db6a9b6e3790cf9e29c145796c6dbf409dc875e7998db827c944a835a29ab4192a11ad1efde5ebdd1a775ecbfefd139c50fbdcbebd6c124d9d65ba6ddaaa83e57695293e7c85dfd6f418d58fa5ffb9ab9b2395c84b57da796d31b6351fde3f1dbab29da6c3f259859bd0719c34f5111a9a12075b53ee91b4598fb2f452dbea823ec094cb757f370b5386a8e5db25cf732681d0cd9bda651ae55cdd125138fd2c8f1ffe87a5eea14df7d355762b37e3e71c33c6fe46a10c2083538910fec12e294de84ff587cab2dd268203699cb180e481f4a3a093b86854cea64341dd9482305abd4a9d7bb304b078bc255bf7cde78689225f17006f24c2cd82d38a59f1e0899965c38fcfd1ec67069143ee05a34922963a527549a002e3221e1461463f573e5f66ba87dcb83a63cb8e3a721c13cd9d4d0c9a0334a558f32027424a5bc9fc12b91981a3f74ac4b62eea3aae8be6c44504696b96afadce5d9222bb67dddf5a7d98dd43d544d79f8720a946c37eba8eb5ae6d70f4bdbbe554cbd4b3abb35ed357c8cb8f55e016ab83bef12bf5c0cdf26c7624c86f16437f545d796addb1aa7370de329930c68b174c871706e7afdf78cc07e0f0c58e45495d0d3bcf3faf9fb6d20369b0adc89766b0c9132677e52112770d017da7658f2a0c0eaeac57416f203700f98bf7b30119407733d4f0bd4322c622120cdf81646c4a1adfb80e757954e41ba0e7816c403b2e4b9ceb2d36e4198921ea719a410ae6f6983e49e7b99c266deb0465af716799e36a5bab70923291da808edeba54267e31e8b64c37123fd45d86e0638",
kdf: "pbkdf2",
kdfparams: {
dklen: 32,
c: 1000000,
prf: "hmac-sha256",
salt: "142a0a65b7f6f480546cc4ef743d7ef9"
},
mac: "7119b7b78598850de5f6af742e42748a3b005394b6b8b272490f24527ebd8b15"
}
}
}
};
describe("Keystore", () => {
it("shoud create empty store with predefined values", () => {
const store = Keystore.create();
expect(store.toObject()).to.deep.eq({
application: "waku-rln-relay",
appIdentifier: "01234567890abcdef",
version: "0.2",
credentials: {}
});
});
// TODO: add more thorow credentials testing
[
{
some: "3123",
dadw: "1212"
},
{
application: 123,
version: "01234567890abcdef",
appIdentifier: "0.2",
credentials: {}
},
{
application: "waku-rln-relay",
version: 213,
appIdentifier: "0.2",
credentials: {}
},
{
application: "waku-rln-relay",
version: "01234567890abcdef",
appIdentifier: 12,
credentials: {}
},
{
application: "waku-rln-relay",
version: "01234567890abcdef",
appIdentifier: "12",
credentials: []
},
{
application: "waku-rln-relay",
version: "01234567890abcdef",
appIdentifier: "12",
credentials: 123
},
{
application: "waku-rln-relay",
version: "01234567890abcdef",
appIdentifier: "12",
credentials: "123"
},
{
application: "waku-rln-relay",
version: "01234567890abcdef",
appIdentifier: "12",
credentials: {
hash: {
invalid: "here"
}
}
}
].map((options) => {
it("should fail to create store from invalid object", () => {
expect(() => Keystore.fromObject(options as any)).to.throw(
"Invalid object, does not match Nwaku Keystore format."
);
});
});
it("shoud create store from valid object", () => {
const store = Keystore.fromObject(NWAKU_KEYSTORE as any);
expect(store.toObject()).to.deep.eq(NWAKU_KEYSTORE);
});
it("should fail to create store from invalid string", () => {
expect(Keystore.fromString("/asdq}")).to.eq(undefined);
expect(Keystore.fromString('{ "name": "it" }')).to.eq(undefined);
});
it("shoud create store from valid string", async () => {
const store = Keystore.fromObject(NWAKU_KEYSTORE as any);
expect(store.toObject()).to.deep.eq(NWAKU_KEYSTORE);
});
it("should convert keystore to string", async () => {
let store = Keystore.create();
expect(store.toString()).to.eq(
JSON.stringify({
application: "waku-rln-relay",
appIdentifier: "01234567890abcdef",
version: "0.2",
credentials: {}
})
);
store = Keystore.fromObject(NWAKU_KEYSTORE as any);
expect(store.toString()).to.eq(JSON.stringify(NWAKU_KEYSTORE));
});
it("shoud add / read new credentials", async () => {
const expectedHash =
"9DB2B4718A97485B9F70F68D1CC19F4E10F0B4CE943418838E94956CB8E57548";
const identity = {
IDTrapdoor: new Uint8Array([
211, 23, 66, 42, 179, 130, 131, 111, 201, 205, 244, 34, 27, 238, 244,
216, 131, 240, 188, 45, 193, 172, 4, 168, 225, 225, 43, 197, 114, 176,
126, 9
]),
IDNullifier: new Uint8Array([
238, 168, 239, 65, 73, 63, 105, 19, 132, 62, 213, 205, 191, 255, 209, 9,
178, 155, 239, 201, 131, 125, 233, 136, 246, 217, 9, 237, 55, 89, 81, 42
]),
IDSecretHash: new Uint8Array([
150, 54, 194, 28, 18, 216, 138, 253, 95, 139, 120, 109, 98, 129, 146,
101, 41, 194, 36, 36, 96, 152, 152, 89, 151, 160, 118, 15, 222, 124,
187, 4
]),
IDCommitment: new Uint8Array([
112, 216, 27, 89, 188, 135, 203, 19, 168, 211, 117, 13, 231, 135, 229,
58, 94, 20, 246, 8, 33, 65, 238, 37, 112, 97, 65, 241, 255, 93, 171, 15
]),
IDCommitmentBigInt: buildBigIntFromUint8Array(
new Uint8Array([
112, 216, 27, 89, 188, 135, 203, 19, 168, 211, 117, 13, 231, 135, 229,
58, 94, 20, 246, 8, 33, 65, 238, 37, 112, 97, 65, 241, 255, 93, 171,
15
])
)
} as unknown as IdentityCredential;
const membership = {
chainId: "0xAA36A7",
treeIndex: 8,
address: "0x8e1F3742B987d8BA376c0CBbD7357fE1F003ED71"
} as unknown as MembershipInfo;
const store = Keystore.create();
const hash = await store.addCredential(
{ identity, membership },
DEFAULT_PASSWORD
);
expect(hash).to.eq(expectedHash);
const actualCredentials = await store.readCredential(
expectedHash,
DEFAULT_PASSWORD
);
expect(actualCredentials).to.deep.equalInAnyOrder({
identity,
membership
});
});
it("shoud fail to add credentials if already exist", async () => {
const identity = {
IDTrapdoor: [
211, 23, 66, 42, 179, 130, 131, 111, 201, 205, 244, 34, 27, 238, 244,
216, 131, 240, 188, 45, 193, 172, 4, 168, 225, 225, 43, 197, 114, 176,
126, 9
],
IDNullifier: [
238, 168, 239, 65, 73, 63, 105, 19, 132, 62, 213, 205, 191, 255, 209, 9,
178, 155, 239, 201, 131, 125, 233, 136, 246, 217, 9, 237, 55, 89, 81, 42
],
IDSecretHash: [
150, 54, 194, 28, 18, 216, 138, 253, 95, 139, 120, 109, 98, 129, 146,
101, 41, 194, 36, 36, 96, 152, 152, 89, 151, 160, 118, 15, 222, 124,
187, 4
],
IDCommitment: [
112, 216, 27, 89, 188, 135, 203, 19, 168, 211, 117, 13, 231, 135, 229,
58, 94, 20, 246, 8, 33, 65, 238, 37, 112, 97, 65, 241, 255, 93, 171, 15
]
} as unknown as IdentityCredential;
const membership = {
chainId: "0xAA36A7",
treeIndex: 8,
address: "0x8e1F3742B987d8BA376c0CBbD7357fE1F003ED71"
} as unknown as MembershipInfo;
const store = Keystore.fromObject(NWAKU_KEYSTORE as any);
try {
await store.addCredential({ identity, membership }, DEFAULT_PASSWORD);
} catch (e) {
expect((e as Error).message).to.eq(
"Credential already exists in the store."
);
}
});
it("shoud fail to read credentials with wrong password", async () => {
const expectedHash =
"9DB2B4718A97485B9F70F68D1CC19F4E10F0B4CE943418838E94956CB8E57548";
const store = Keystore.fromObject(NWAKU_KEYSTORE as any);
try {
await store.readCredential(expectedHash, "wrong-password");
} catch (e) {
expect((e as Error).message).to.eq("Password is invalid.");
}
});
it("shoud fail to read missing credentials", async () => {
const store = Keystore.fromObject(NWAKU_KEYSTORE as any);
const result = await store.readCredential("wrong-hash", "wrong-password");
expect(result).to.eq(undefined);
});
});

View File

@ -0,0 +1,330 @@
import type {
ICipherModule,
IKeystore as IEipKeystore,
IPbkdf2KdfModule
} from "@chainsafe/bls-keystore";
import { create as createEipKeystore } from "@chainsafe/bls-keystore";
import { Logger } from "@waku/utils";
import { sha256 } from "ethereum-cryptography/sha256";
import {
bytesToHex,
bytesToUtf8,
utf8ToBytes
} from "ethereum-cryptography/utils";
import _ from "lodash";
import { v4 as uuidV4 } from "uuid";
import { buildBigIntFromUint8Array } from "../utils/bytes.js";
import { decryptEipKeystore, keccak256Checksum } from "./cipher.js";
import { isCredentialValid, isKeystoreValid } from "./schema_validator.js";
import type {
Keccak256Hash,
KeystoreEntity,
MembershipHash,
MembershipInfo,
Password,
Sha256Hash
} from "./types.js";
const log = new Logger("waku:rln:keystore");
type NwakuCredential = {
crypto: {
cipher: ICipherModule["function"];
cipherparams: ICipherModule["params"];
ciphertext: ICipherModule["message"];
kdf: IPbkdf2KdfModule["function"];
kdfparams: IPbkdf2KdfModule["params"];
mac: Sha256Hash;
};
};
// examples from nwaku
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/tests/test_waku_keystore.nim#L43
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/keystore.nim#L154C35-L154C38
// important: each credential has it's own password
// important: not compatible with https://eips.ethereum.org/EIPS/eip-2335
interface NwakuKeystore {
application: string;
version: string;
appIdentifier: string;
credentials: {
[key: MembershipHash]: NwakuCredential;
};
}
type KeystoreCreateOptions = {
application?: string;
version?: string;
appIdentifier?: string;
};
export class Keystore {
private data: NwakuKeystore;
private constructor(options: KeystoreCreateOptions | NwakuKeystore) {
this.data = Object.assign(
{
application: "waku-rln-relay",
appIdentifier: "01234567890abcdef",
version: "0.2",
credentials: {}
},
options
);
}
public static create(options: KeystoreCreateOptions = {}): Keystore {
return new Keystore(options);
}
// should be valid JSON string that contains Keystore file
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/keyfile.nim#L376
public static fromString(str: string): undefined | Keystore {
try {
const obj = JSON.parse(str);
if (!Keystore.isValidNwakuStore(obj)) {
throw Error("Invalid string, does not match Nwaku Keystore format.");
}
return new Keystore(obj);
} catch (err) {
log.error("Cannot create Keystore from string:", err);
return;
}
}
public static fromObject(obj: NwakuKeystore): Keystore {
if (!Keystore.isValidNwakuStore(obj)) {
throw Error("Invalid object, does not match Nwaku Keystore format.");
}
return new Keystore(obj);
}
public async addCredential(
options: KeystoreEntity,
password: Password
): Promise<MembershipHash> {
const membershipHash: MembershipHash = Keystore.computeMembershipHash(
options.membership
);
if (this.data.credentials[membershipHash]) {
throw Error("Credential already exists in the store.");
}
// these are not important
const stubPath = "/stub/path";
const stubPubkey = new Uint8Array([0]);
const secret = Keystore.fromIdentityToBytes(options);
const eipKeystore = await createEipKeystore(
password,
secret,
stubPubkey,
stubPath
);
// need to re-compute checksum since nwaku uses keccak256 instead of sha256
const checksum = await keccak256Checksum(password, eipKeystore);
const nwakuCredential = Keystore.fromEipToCredential(eipKeystore, checksum);
this.data.credentials[membershipHash] = nwakuCredential;
return membershipHash;
}
public async readCredential(
membershipHash: MembershipHash,
password: Password
): Promise<undefined | KeystoreEntity> {
const nwakuCredential = this.data.credentials[membershipHash];
if (!nwakuCredential) {
return;
}
const eipKeystore = Keystore.fromCredentialToEip(nwakuCredential);
const bytes = await decryptEipKeystore(password, eipKeystore);
return Keystore.fromBytesToIdentity(bytes);
}
public removeCredential(hash: MembershipHash): void {
if (!this.data.credentials[hash]) {
return;
}
delete this.data.credentials[hash];
}
public toString(): string {
return JSON.stringify(this.data);
}
public toObject(): NwakuKeystore {
return this.data;
}
/**
* Read array of hashes of current credentials
* @returns array of keys of credentials in current Keystore
*/
public keys(): string[] {
return Object.keys(this.toObject().credentials || {});
}
private static isValidNwakuStore(obj: unknown): boolean {
if (!isKeystoreValid(obj)) {
return false;
}
const areCredentialsValid = Object.values(_.get(obj, "credentials", {}))
.map((c) => isCredentialValid(c))
.every((v) => v);
return areCredentialsValid;
}
private static fromCredentialToEip(
credential: NwakuCredential
): IEipKeystore {
const nwakuCrypto = credential.crypto;
const eipCrypto: IEipKeystore["crypto"] = {
kdf: {
function: nwakuCrypto.kdf,
params: nwakuCrypto.kdfparams,
message: ""
},
cipher: {
function: nwakuCrypto.cipher,
params: nwakuCrypto.cipherparams,
message: nwakuCrypto.ciphertext
},
checksum: {
// @chainsafe/bls-keystore supports only sha256
// but nwaku uses keccak256
// https://github.com/waku-org/nwaku/blob/25d6e52e3804d15f9b61bc4cc6dd448540c072a1/waku/waku_keystore/keyfile.nim#L367
function: "sha256",
params: {},
message: nwakuCrypto.mac
}
};
return {
version: 4,
uuid: uuidV4(),
description: undefined,
path: "safe to ignore, not important for decrypt",
pubkey: "safe to ignore, not important for decrypt",
crypto: eipCrypto
};
}
private static fromEipToCredential(
eipKeystore: IEipKeystore,
checksum: Keccak256Hash
): NwakuCredential {
const eipCrypto = eipKeystore.crypto;
const eipKdf = eipCrypto.kdf as IPbkdf2KdfModule;
return {
crypto: {
cipher: eipCrypto.cipher.function,
cipherparams: eipCrypto.cipher.params,
ciphertext: eipCrypto.cipher.message,
kdf: eipKdf.function,
kdfparams: eipKdf.params,
// @chainsafe/bls-keystore generates only sha256
// but nwaku uses keccak256
// https://github.com/waku-org/nwaku/blob/25d6e52e3804d15f9b61bc4cc6dd448540c072a1/waku/waku_keystore/keyfile.nim#L367
mac: checksum
}
};
}
private static fromBytesToIdentity(
bytes: Uint8Array
): undefined | KeystoreEntity {
try {
const str = bytesToUtf8(bytes);
const obj = JSON.parse(str);
// TODO: add runtime validation of nwaku credentials
return {
identity: {
IDCommitment: Keystore.fromArraylikeToBytes(
_.get(obj, "identityCredential.idCommitment", [])
),
IDTrapdoor: Keystore.fromArraylikeToBytes(
_.get(obj, "identityCredential.idTrapdoor", [])
),
IDNullifier: Keystore.fromArraylikeToBytes(
_.get(obj, "identityCredential.idNullifier", [])
),
IDCommitmentBigInt: buildBigIntFromUint8Array(
Keystore.fromArraylikeToBytes(
_.get(obj, "identityCredential.idCommitment", [])
)
),
IDSecretHash: Keystore.fromArraylikeToBytes(
_.get(obj, "identityCredential.idSecretHash", [])
)
},
membership: {
treeIndex: _.get(obj, "treeIndex"),
chainId: _.get(obj, "membershipContract.chainId"),
address: _.get(obj, "membershipContract.address")
}
};
} catch (err) {
log.error("Cannot parse bytes to Nwaku Credentials:", err);
return;
}
}
private static fromArraylikeToBytes(obj: {
[key: number]: number;
}): Uint8Array {
const bytes = [];
let index = 0;
let lastElement = obj[index];
while (lastElement !== undefined) {
bytes.push(lastElement);
index += 1;
lastElement = obj[index];
}
return new Uint8Array(bytes);
}
// follows nwaku implementation
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L111
private static computeMembershipHash(info: MembershipInfo): MembershipHash {
return bytesToHex(
sha256(utf8ToBytes(`${info.chainId}${info.address}${info.treeIndex}`))
).toUpperCase();
}
// follows nwaku implementation
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L98
private static fromIdentityToBytes(options: KeystoreEntity): Uint8Array {
return utf8ToBytes(
JSON.stringify({
treeIndex: options.membership.treeIndex,
identityCredential: {
idCommitment: options.identity.IDCommitment,
idNullifier: options.identity.IDNullifier,
idSecretHash: options.identity.IDSecretHash,
idTrapdoor: options.identity.IDTrapdoor
},
membershipContract: {
chainId: options.membership.chainId,
address: options.membership.address
}
})
);
}
}

View File

@ -0,0 +1,7 @@
/* eslint eslint-comments/no-unlimited-disable: "off" */
// This file was generated by /scripts/schema-validation-codegen.ts
// Do not modify this file by hand.
/* eslint-disable */
// @ts-ignore
"use strict";export const Keystore = validate11;const schema12 = {"type":"object","properties":{"credentials":{"type":"object"},"appIdentifier":{"type":"string"},"version":{"type":"string"},"application":{"type":"string"}},"required":["application","appIdentifier","credentials","version"]};function validate11(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(errors === 0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if(((((data.application === undefined) && (missing0 = "application")) || ((data.appIdentifier === undefined) && (missing0 = "appIdentifier"))) || ((data.credentials === undefined) && (missing0 = "credentials"))) || ((data.version === undefined) && (missing0 = "version"))){validate11.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {if(data.credentials !== undefined){let data0 = data.credentials;const _errs1 = errors;if(!(data0 && typeof data0 == "object" && !Array.isArray(data0))){validate11.errors = [{instancePath:instancePath+"/credentials",schemaPath:"#/properties/credentials/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}var valid0 = _errs1 === errors;}else {var valid0 = true;}if(valid0){if(data.appIdentifier !== undefined){const _errs3 = errors;if(typeof data.appIdentifier !== "string"){validate11.errors = [{instancePath:instancePath+"/appIdentifier",schemaPath:"#/properties/appIdentifier/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs3 === errors;}else {var valid0 = true;}if(valid0){if(data.version !== undefined){const _errs5 = errors;if(typeof data.version !== "string"){validate11.errors = [{instancePath:instancePath+"/version",schemaPath:"#/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs5 === errors;}else {var valid0 = true;}if(valid0){if(data.application !== undefined){const _errs7 = errors;if(typeof data.application !== "string"){validate11.errors = [{instancePath:instancePath+"/application",schemaPath:"#/properties/application/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid0 = _errs7 === errors;}else {var valid0 = true;}}}}}}else {validate11.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate11.errors = vErrors;return errors === 0;}

View File

@ -0,0 +1,34 @@
import { Credential as _validateCredentialGenerated } from "./credential_validation_generated.js";
import { Keystore as _validateKeystoreGenerated } from "./keystore_validation_generated.js";
type ErrorObject = {
instancePath: string;
schemaPath: string;
keyword: string;
params: object;
message: string;
};
type ValidatorFn = ((data: unknown) => boolean) & { errors: ErrorObject[] };
const _validateKeystore = _validateKeystoreGenerated as ValidatorFn;
const _validateCredential = _validateCredentialGenerated as ValidatorFn;
function schemaValidationErrors(
validator: ValidatorFn,
data: unknown
): ErrorObject[] | null {
const validated = validator(data);
if (validated) {
return null;
}
return validator.errors;
}
export function isKeystoreValid(keystore: unknown): boolean {
return !schemaValidationErrors(_validateKeystore, keystore);
}
export function isCredentialValid(credential: unknown): boolean {
return !schemaValidationErrors(_validateCredential, credential);
}

View File

@ -0,0 +1,36 @@
import type { IdentityCredential } from "../identity.js";
export type MembershipHash = string;
export type Sha256Hash = string;
export type Keccak256Hash = string;
export type Password = string | Uint8Array;
// see reference
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L111
export type MembershipInfo = {
chainId: number;
address: string;
treeIndex: number;
};
export type KeystoreEntity = {
identity: IdentityCredential;
membership: MembershipInfo;
};
export type DecryptedCredentials = KeystoreEntity;
export type EncryptedCredentials = {
/**
* Valid JSON string that contains Keystore
*/
keystore: string;
/**
* ID of credentials from provided Keystore to use
*/
id: string;
/**
* Password to decrypt credentials provided
*/
password: Password;
};

View File

@ -0,0 +1,70 @@
import type {
IDecodedMessage,
IMessage,
IRateLimitProof
} from "@waku/interfaces";
import * as utils from "@waku/utils/bytes";
import { RLNInstance } from "./rln.js";
import { epochBytesToInt } from "./utils/index.js";
export function toRLNSignal(contentTopic: string, msg: IMessage): Uint8Array {
const contentTopicBytes = utils.utf8ToBytes(contentTopic ?? "");
return new Uint8Array([...(msg.payload ?? []), ...contentTopicBytes]);
}
export class RlnMessage<T extends IDecodedMessage> implements IDecodedMessage {
public pubsubTopic = "";
public constructor(
public rlnInstance: RLNInstance,
public msg: T,
public rateLimitProof: IRateLimitProof | undefined
) {}
public verify(roots: Uint8Array[]): boolean | undefined {
return this.rateLimitProof
? this.rlnInstance.zerokit.verifyWithRoots(
this.rateLimitProof,
toRLNSignal(this.msg.contentTopic, this.msg),
...roots
) // this.rlnInstance.verifyRLNProof once issue status-im/nwaku#1248 is fixed
: undefined;
}
public verifyNoRoot(): boolean | undefined {
return this.rateLimitProof
? this.rlnInstance.zerokit.verifyWithNoRoot(
this.rateLimitProof,
toRLNSignal(this.msg.contentTopic, this.msg)
) // this.rlnInstance.verifyRLNProof once issue status-im/nwaku#1248 is fixed
: undefined;
}
public get payload(): Uint8Array {
return this.msg.payload;
}
public get contentTopic(): string {
return this.msg.contentTopic;
}
public get timestamp(): Date | undefined {
return this.msg.timestamp;
}
public get ephemeral(): boolean | undefined {
return this.msg.ephemeral;
}
public get meta(): Uint8Array | undefined {
return this.msg.meta;
}
public get epoch(): number | undefined {
const bytes = this.rateLimitProof?.epoch;
if (!bytes) return undefined;
return epochBytesToInt(bytes);
}
}

69
packages/rln/src/proof.ts Normal file
View File

@ -0,0 +1,69 @@
import type { IRateLimitProof } from "@waku/interfaces";
import { concatenate, poseidonHash } from "./utils/index.js";
const proofOffset = 128;
const rootOffset = proofOffset + 32;
const epochOffset = rootOffset + 32;
const shareXOffset = epochOffset + 32;
const shareYOffset = shareXOffset + 32;
const nullifierOffset = shareYOffset + 32;
const rlnIdentifierOffset = nullifierOffset + 32;
class ProofMetadata {
public constructor(
public readonly nullifier: Uint8Array,
public readonly shareX: Uint8Array,
public readonly shareY: Uint8Array,
public readonly externalNullifier: Uint8Array
) {}
}
export class Proof implements IRateLimitProof {
public readonly proof: Uint8Array;
public readonly merkleRoot: Uint8Array;
public readonly epoch: Uint8Array;
public readonly shareX: Uint8Array;
public readonly shareY: Uint8Array;
public readonly nullifier: Uint8Array;
public readonly rlnIdentifier: Uint8Array;
public constructor(proofBytes: Uint8Array) {
if (proofBytes.length < rlnIdentifierOffset) {
throw new Error("invalid proof");
}
// parse the proof as proof<128> | share_y<32> | nullifier<32> | root<32> | epoch<32> | share_x<32> | rln_identifier<32>
this.proof = proofBytes.subarray(0, proofOffset);
this.merkleRoot = proofBytes.subarray(proofOffset, rootOffset);
this.epoch = proofBytes.subarray(rootOffset, epochOffset);
this.shareX = proofBytes.subarray(epochOffset, shareXOffset);
this.shareY = proofBytes.subarray(shareXOffset, shareYOffset);
this.nullifier = proofBytes.subarray(shareYOffset, nullifierOffset);
this.rlnIdentifier = proofBytes.subarray(
nullifierOffset,
rlnIdentifierOffset
);
}
public extractMetadata(): ProofMetadata {
const externalNullifier = poseidonHash(this.epoch, this.rlnIdentifier);
return new ProofMetadata(
this.nullifier,
this.shareX,
this.shareY,
externalNullifier
);
}
}
export function proofToBytes(p: IRateLimitProof): Uint8Array {
return concatenate(
p.proof,
p.merkleRoot,
p.epoch,
p.shareX,
p.shareY,
p.nullifier,
p.rlnIdentifier
);
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,13 @@
declare const verificationKey: {
protocol: string;
curve: string;
nPublic: number;
vk_alpha_1: string[];
vk_beta_2: string[][];
vk_gamma_2: string[][];
vk_delta_2: string[][];
vk_alphabeta_12: string[][][];
IC: string[][];
};
export default verificationKey;

View File

@ -0,0 +1,112 @@
const verificationKey = {
protocol: "groth16",
curve: "bn128",
nPublic: 6,
vk_alpha_1: [
"20124996762962216725442980738609010303800849578410091356605067053491763969391",
"9118593021526896828671519912099489027245924097793322973632351264852174143923",
"1"
],
vk_beta_2: [
[
"4693952934005375501364248788849686435240706020501681709396105298107971354382",
"14346958885444710485362620645446987998958218205939139994511461437152241966681"
],
[
"16851772916911573982706166384196538392731905827088356034885868448550849804972",
"823612331030938060799959717749043047845343400798220427319188951998582076532"
],
["1", "0"]
],
vk_gamma_2: [
[
"10857046999023057135944570762232829481370756359578518086990519993285655852781",
"11559732032986387107991004021392285783925812861821192530917403151452391805634"
],
[
"8495653923123431417604973247489272438418190587263600148770280649306958101930",
"4082367875863433681332203403145435568316851327593401208105741076214120093531"
],
["1", "0"]
],
vk_delta_2: [
[
"8353516066399360694538747105302262515182301251524941126222712285088022964076",
"9329524012539638256356482961742014315122377605267454801030953882967973561832"
],
[
"16805391589556134376869247619848130874761233086443465978238468412168162326401",
"10111259694977636294287802909665108497237922060047080343914303287629927847739"
],
["1", "0"]
],
vk_alphabeta_12: [
[
[
"12608968655665301215455851857466367636344427685631271961542642719683786103711",
"9849575605876329747382930567422916152871921500826003490242628251047652318086"
],
[
"6322029441245076030714726551623552073612922718416871603535535085523083939021",
"8700115492541474338049149013125102281865518624059015445617546140629435818912"
],
[
"10674973475340072635573101639867487770811074181475255667220644196793546640210",
"2926286967251299230490668407790788696102889214647256022788211245826267484824"
]
],
[
[
"9660441540778523475944706619139394922744328902833875392144658911530830074820",
"19548113127774514328631808547691096362144426239827206966690021428110281506546"
],
[
"1870837942477655969123169532603615788122896469891695773961478956740992497097",
"12536105729661705698805725105036536744930776470051238187456307227425796690780"
],
[
"21811903352654147452884857281720047789720483752548991551595462057142824037334",
"19021616763967199151052893283384285352200445499680068407023236283004353578353"
]
]
],
IC: [
[
"11992897507809711711025355300535923222599547639134311050809253678876341466909",
"17181525095924075896332561978747020491074338784673526378866503154966799128110",
"1"
],
[
"17018665030246167677911144513385572506766200776123272044534328594850561667818",
"18601114175490465275436712413925513066546725461375425769709566180981674884464",
"1"
],
[
"18799470100699658367834559797874857804183288553462108031963980039244731716542",
"13064227487174191981628537974951887429496059857753101852163607049188825592007",
"1"
],
[
"17432501889058124609368103715904104425610382063762621017593209214189134571156",
"13406815149699834788256141097399354592751313348962590382887503595131085938635",
"1"
],
[
"10320964835612716439094703312987075811498239445882526576970512041988148264481",
"9024164961646353611176283204118089412001502110138072989569118393359029324867",
"1"
],
[
"718355081067365548229685160476620267257521491773976402837645005858953849298",
"14635482993933988261008156660773180150752190597753512086153001683711587601974",
"1"
],
[
"11777720285956632126519898515392071627539405001940313098390150593689568177535",
"8483603647274280691250972408211651407952870456587066148445913156086740744515",
"1"
]
]
};
export default verificationKey;

View File

@ -0,0 +1,11 @@
export async function builder(
code: Uint8Array,
sanityCheck: boolean
): Promise<WitnessCalculator>;
export class WitnessCalculator {
public calculateWitness(
input: unknown,
sanityCheck: boolean
): Promise<Array<bigint>>;
}

View File

@ -0,0 +1,328 @@
// File generated with https://github.com/iden3/circom
// following the instructions from:
// https://github.com/vacp2p/zerokit/tree/master/rln#compiling-circuits
export async function builder(code, options) {
options = options || {};
let wasmModule;
try {
wasmModule = await WebAssembly.compile(code);
} catch (err) {
console.log(err);
console.log(
"\nTry to run circom --c in order to generate c++ code instead\n"
);
throw new Error(err);
}
let wc;
let errStr = "";
let msgStr = "";
const instance = await WebAssembly.instantiate(wasmModule, {
runtime: {
exceptionHandler: function (code) {
let err;
if (code == 1) {
err = "Signal not found.\n";
} else if (code == 2) {
err = "Too many signals set.\n";
} else if (code == 3) {
err = "Signal already set.\n";
} else if (code == 4) {
err = "Assert Failed.\n";
} else if (code == 5) {
err = "Not enough memory.\n";
} else if (code == 6) {
err = "Input signal array access exceeds the size.\n";
} else {
err = "Unknown error.\n";
}
throw new Error(err + errStr);
},
printErrorMessage: function () {
errStr += getMessage() + "\n";
// console.error(getMessage());
},
writeBufferMessage: function () {
const msg = getMessage();
// Any calls to `log()` will always end with a `\n`, so that's when we print and reset
if (msg === "\n") {
console.log(msgStr);
msgStr = "";
} else {
// If we've buffered other content, put a space in between the items
if (msgStr !== "") {
msgStr += " ";
}
// Then append the message to the message we are creating
msgStr += msg;
}
},
showSharedRWMemory: function () {
printSharedRWMemory();
}
}
});
const sanityCheck = options;
// options &&
// (
// options.sanityCheck ||
// options.logGetSignal ||
// options.logSetSignal ||
// options.logStartComponent ||
// options.logFinishComponent
// );
wc = new WitnessCalculator(instance, sanityCheck);
return wc;
function getMessage() {
var message = "";
var c = instance.exports.getMessageChar();
while (c != 0) {
message += String.fromCharCode(c);
c = instance.exports.getMessageChar();
}
return message;
}
function printSharedRWMemory() {
const shared_rw_memory_size = instance.exports.getFieldNumLen32();
const arr = new Uint32Array(shared_rw_memory_size);
for (let j = 0; j < shared_rw_memory_size; j++) {
arr[shared_rw_memory_size - 1 - j] =
instance.exports.readSharedRWMemory(j);
}
// If we've buffered other content, put a space in between the items
if (msgStr !== "") {
msgStr += " ";
}
// Then append the value to the message we are creating
msgStr += fromArray32(arr).toString();
}
}
class WitnessCalculator {
constructor(instance, sanityCheck) {
this.instance = instance;
this.version = this.instance.exports.getVersion();
this.n32 = this.instance.exports.getFieldNumLen32();
this.instance.exports.getRawPrime();
const arr = new Uint32Array(this.n32);
for (let i = 0; i < this.n32; i++) {
arr[this.n32 - 1 - i] = this.instance.exports.readSharedRWMemory(i);
}
this.prime = fromArray32(arr);
this.witnessSize = this.instance.exports.getWitnessSize();
this.sanityCheck = sanityCheck;
}
circom_version() {
return this.instance.exports.getVersion();
}
async _doCalculateWitness(input, sanityCheck) {
//input is assumed to be a map from signals to arrays of bigints
this.instance.exports.init(this.sanityCheck || sanityCheck ? 1 : 0);
const keys = Object.keys(input);
var input_counter = 0;
keys.forEach((k) => {
const h = fnvHash(k);
const hMSB = parseInt(h.slice(0, 8), 16);
const hLSB = parseInt(h.slice(8, 16), 16);
const fArr = flatArray(input[k]);
let signalSize = this.instance.exports.getInputSignalSize(hMSB, hLSB);
if (signalSize < 0) {
throw new Error(`Signal ${k} not found\n`);
}
if (fArr.length < signalSize) {
throw new Error(`Not enough values for input signal ${k}\n`);
}
if (fArr.length > signalSize) {
throw new Error(`Too many values for input signal ${k}\n`);
}
for (let i = 0; i < fArr.length; i++) {
const arrFr = toArray32(BigInt(fArr[i]) % this.prime, this.n32);
for (let j = 0; j < this.n32; j++) {
this.instance.exports.writeSharedRWMemory(j, arrFr[this.n32 - 1 - j]);
}
try {
this.instance.exports.setInputSignal(hMSB, hLSB, i);
input_counter++;
} catch (err) {
// console.log(`After adding signal ${i} of ${k}`)
throw new Error(err);
}
}
});
if (input_counter < this.instance.exports.getInputSize()) {
throw new Error(
`Not all inputs have been set. Only ${input_counter} out of ${this.instance.exports.getInputSize()}`
);
}
}
async calculateWitness(input, sanityCheck) {
const w = [];
await this._doCalculateWitness(input, sanityCheck);
for (let i = 0; i < this.witnessSize; i++) {
this.instance.exports.getWitness(i);
const arr = new Uint32Array(this.n32);
for (let j = 0; j < this.n32; j++) {
arr[this.n32 - 1 - j] = this.instance.exports.readSharedRWMemory(j);
}
w.push(fromArray32(arr));
}
return w;
}
async calculateBinWitness(input, sanityCheck) {
const buff32 = new Uint32Array(this.witnessSize * this.n32);
const buff = new Uint8Array(buff32.buffer);
await this._doCalculateWitness(input, sanityCheck);
for (let i = 0; i < this.witnessSize; i++) {
this.instance.exports.getWitness(i);
const pos = i * this.n32;
for (let j = 0; j < this.n32; j++) {
buff32[pos + j] = this.instance.exports.readSharedRWMemory(j);
}
}
return buff;
}
async calculateWTNSBin(input, sanityCheck) {
const buff32 = new Uint32Array(this.witnessSize * this.n32 + this.n32 + 11);
const buff = new Uint8Array(buff32.buffer);
await this._doCalculateWitness(input, sanityCheck);
//"wtns"
buff[0] = "w".charCodeAt(0);
buff[1] = "t".charCodeAt(0);
buff[2] = "n".charCodeAt(0);
buff[3] = "s".charCodeAt(0);
//version 2
buff32[1] = 2;
//number of sections: 2
buff32[2] = 2;
//id section 1
buff32[3] = 1;
const n8 = this.n32 * 4;
//id section 1 length in 64bytes
const idSection1length = 8 + n8;
const idSection1lengthHex = idSection1length.toString(16);
buff32[4] = parseInt(idSection1lengthHex.slice(0, 8), 16);
buff32[5] = parseInt(idSection1lengthHex.slice(8, 16), 16);
//this.n32
buff32[6] = n8;
//prime number
this.instance.exports.getRawPrime();
var pos = 7;
for (let j = 0; j < this.n32; j++) {
buff32[pos + j] = this.instance.exports.readSharedRWMemory(j);
}
pos += this.n32;
// witness size
buff32[pos] = this.witnessSize;
pos++;
//id section 2
buff32[pos] = 2;
pos++;
// section 2 length
const idSection2length = n8 * this.witnessSize;
const idSection2lengthHex = idSection2length.toString(16);
buff32[pos] = parseInt(idSection2lengthHex.slice(0, 8), 16);
buff32[pos + 1] = parseInt(idSection2lengthHex.slice(8, 16), 16);
pos += 2;
for (let i = 0; i < this.witnessSize; i++) {
this.instance.exports.getWitness(i);
for (let j = 0; j < this.n32; j++) {
buff32[pos + j] = this.instance.exports.readSharedRWMemory(j);
}
pos += this.n32;
}
return buff;
}
}
function toArray32(rem, size) {
const res = []; //new Uint32Array(size); //has no unshift
const radix = BigInt(0x100000000);
while (rem) {
res.unshift(Number(rem % radix));
rem = rem / radix;
}
if (size) {
var i = size - res.length;
while (i > 0) {
res.unshift(0);
i--;
}
}
return res;
}
function fromArray32(arr) {
//returns a BigInt
var res = BigInt(0);
const radix = BigInt(0x100000000);
for (let i = 0; i < arr.length; i++) {
res = res * radix + BigInt(arr[i]);
}
return res;
}
function flatArray(a) {
var res = [];
fillArray(res, a);
return res;
function fillArray(res, a) {
if (Array.isArray(a)) {
for (let i = 0; i < a.length; i++) {
fillArray(res, a[i]);
}
} else {
res.push(a);
}
}
}
function fnvHash(str) {
const uint64_max = BigInt(2) ** BigInt(64);
let hash = BigInt("0xCBF29CE484222325");
for (var i = 0; i < str.length; i++) {
hash ^= BigInt(str[i].charCodeAt());
hash *= BigInt(0x100000001b3);
hash %= uint64_max;
}
let shash = hash.toString(16);
let n = 16 - shash.length;
shash = "0".repeat(n).concat(shash);
return shash;
}

298
packages/rln/src/rln.ts Normal file
View File

@ -0,0 +1,298 @@
import { createDecoder, createEncoder } from "@waku/core";
import type {
ContentTopic,
IDecodedMessage,
EncoderOptions as WakuEncoderOptions
} from "@waku/interfaces";
import { Logger } from "@waku/utils";
import init from "@waku/zerokit-rln-wasm";
import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import { ethers } from "ethers";
import {
createRLNDecoder,
createRLNEncoder,
type RLNDecoder,
type RLNEncoder
} from "./codec.js";
import { RLNContract, SEPOLIA_CONTRACT } from "./contract/index.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import type {
DecryptedCredentials,
EncryptedCredentials
} from "./keystore/index.js";
import { KeystoreEntity, Password } from "./keystore/types.js";
import verificationKey from "./resources/verification_key";
import * as wc from "./resources/witness_calculator";
import { extractMetaMaskSigner } from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
const log = new Logger("waku:rln");
async function loadWitnessCalculator(): Promise<wc.WitnessCalculator> {
const res = await fetch("/base/rln.wasm");
if (!res.ok) {
throw new Error(`Failed to fetch rln.wasm: ${res.statusText}`);
}
const witnessBuffer = await res.arrayBuffer();
return wc.builder(new Uint8Array(witnessBuffer), false);
}
async function loadZkey(): Promise<Uint8Array> {
const res = await fetch("/base/rln_final.zkey");
if (!res.ok) {
throw new Error(`Failed to fetch rln_final.zkey: ${res.statusText}`);
}
return new Uint8Array(await res.arrayBuffer());
}
/**
* Create an instance of RLN
* @returns RLNInstance
*/
export async function create(): Promise<RLNInstance> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (init as any)?.();
zerokitRLN.init_panic_hook();
const witnessCalculator = await loadWitnessCalculator();
const zkey = await loadZkey();
const stringEncoder = new TextEncoder();
const vkey = stringEncoder.encode(JSON.stringify(verificationKey));
const DEPTH = 20;
const zkRLN = zerokitRLN.newRLN(DEPTH, zkey, vkey);
const zerokit = new Zerokit(zkRLN, witnessCalculator);
return new RLNInstance(zerokit);
} catch (error) {
log.error("Failed to initialize RLN:", error);
throw error;
}
}
type StartRLNOptions = {
/**
* If not set - will extract MetaMask account and get signer from it.
*/
signer?: ethers.Signer;
/**
* If not set - will use default SEPOLIA_CONTRACT address.
*/
registryAddress?: string;
/**
* Credentials to use for generating proofs and connecting to the contract and network.
* If provided used for validating the network chainId and connecting to registry contract.
*/
credentials?: EncryptedCredentials | DecryptedCredentials;
};
type RegisterMembershipOptions =
| { signature: string }
| { identity: IdentityCredential };
type WakuRLNEncoderOptions = WakuEncoderOptions & {
credentials: EncryptedCredentials | DecryptedCredentials;
};
export class RLNInstance {
private started = false;
private starting = false;
private _contract: undefined | RLNContract;
private _signer: undefined | ethers.Signer;
private keystore = Keystore.create();
private _credentials: undefined | DecryptedCredentials;
public constructor(public zerokit: Zerokit) {}
public get contract(): undefined | RLNContract {
return this._contract;
}
public get signer(): undefined | ethers.Signer {
return this._signer;
}
public async start(options: StartRLNOptions = {}): Promise<void> {
if (this.started || this.starting) {
return;
}
this.starting = true;
try {
const { credentials, keystore } =
await RLNInstance.decryptCredentialsIfNeeded(options.credentials);
const { signer, registryAddress } = await this.determineStartOptions(
options,
credentials
);
if (keystore) {
this.keystore = keystore;
}
this._credentials = credentials;
this._signer = signer!;
this._contract = await RLNContract.init(this, {
registryAddress: registryAddress!,
signer: signer!
});
this.started = true;
} finally {
this.starting = false;
}
}
private async determineStartOptions(
options: StartRLNOptions,
credentials: KeystoreEntity | undefined
): Promise<StartRLNOptions> {
let chainId = credentials?.membership.chainId;
const registryAddress =
credentials?.membership.address ||
options.registryAddress ||
SEPOLIA_CONTRACT.address;
if (registryAddress === SEPOLIA_CONTRACT.address) {
chainId = SEPOLIA_CONTRACT.chainId;
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = await signer.getChainId();
if (chainId && chainId !== currentChainId) {
throw Error(
`Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}`
);
}
return {
signer,
registryAddress
};
}
private static async decryptCredentialsIfNeeded(
credentials?: EncryptedCredentials | DecryptedCredentials
): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> {
if (!credentials) {
return {};
}
if ("identity" in credentials) {
return { credentials };
}
const keystore = Keystore.fromString(credentials.keystore);
if (!keystore) {
return {};
}
const decryptedCredentials = await keystore.readCredential(
credentials.id,
credentials.password
);
return {
keystore,
credentials: decryptedCredentials
};
}
public async registerMembership(
options: RegisterMembershipOptions
): Promise<undefined | DecryptedCredentials> {
if (!this.contract) {
throw Error("RLN Contract is not initialized.");
}
let identity = "identity" in options && options.identity;
if ("signature" in options) {
identity = this.zerokit.generateSeededIdentityCredential(
options.signature
);
}
if (!identity) {
throw Error("Missing signature or identity to register membership.");
}
return this.contract.registerWithIdentity(identity);
}
/**
* Changes credentials in use by relying on provided Keystore earlier in rln.start
* @param id: string, hash of credentials to select from Keystore
* @param password: string or bytes to use to decrypt credentials from Keystore
*/
public async useCredentials(id: string, password: Password): Promise<void> {
this._credentials = await this.keystore?.readCredential(id, password);
}
public async createEncoder(
options: WakuRLNEncoderOptions
): Promise<RLNEncoder> {
const { credentials: decryptedCredentials } =
await RLNInstance.decryptCredentialsIfNeeded(options.credentials);
const credentials = decryptedCredentials || this._credentials;
if (!credentials) {
throw Error(
"Failed to create Encoder: missing RLN credentials. Use createRLNEncoder directly."
);
}
await this.verifyCredentialsAgainstContract(credentials);
return createRLNEncoder({
encoder: createEncoder(options),
rlnInstance: this,
index: credentials.membership.treeIndex,
credential: credentials.identity
});
}
private async verifyCredentialsAgainstContract(
credentials: KeystoreEntity
): Promise<void> {
if (!this._contract) {
throw Error(
"Failed to verify chain coordinates: no contract initialized."
);
}
const registryAddress = credentials.membership.address;
const currentRegistryAddress = this._contract.registry.address;
if (registryAddress !== currentRegistryAddress) {
throw Error(
`Failed to verify chain coordinates: credentials contract address=${registryAddress} is not equal to registryContract address=${currentRegistryAddress}`
);
}
const chainId = credentials.membership.chainId;
const network = await this._contract.registry.provider.getNetwork();
const currentChainId = network.chainId;
if (chainId !== currentChainId) {
throw Error(
`Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}`
);
}
}
public createDecoder(
contentTopic: ContentTopic
): RLNDecoder<IDecodedMessage> {
return createRLNDecoder({
rlnInstance: this,
decoder: createDecoder(contentTopic)
});
}
}

View File

@ -0,0 +1,56 @@
import { assert, expect } from "chai";
import { MerkleRootTracker } from "./root_tracker.js";
describe("js-rln", () => {
it("should track merkle roots and backfill from block number", async function () {
const acceptableRootWindow = 3;
const tracker = new MerkleRootTracker(
acceptableRootWindow,
new Uint8Array([0, 0, 0, 0])
);
expect(tracker.roots()).to.have.length(1);
expect(tracker.buffer()).to.have.length(0);
expect(tracker.roots()[0]).to.deep.equal(new Uint8Array([0, 0, 0, 0]));
for (let i = 1; i <= 30; i++) {
tracker.pushRoot(i, new Uint8Array([0, 0, 0, i]));
}
expect(tracker.roots()).to.have.length(acceptableRootWindow);
expect(tracker.buffer()).to.have.length(20);
assert.sameDeepMembers(tracker.roots(), [
new Uint8Array([0, 0, 0, 30]),
new Uint8Array([0, 0, 0, 29]),
new Uint8Array([0, 0, 0, 28])
]);
// Buffer should keep track of 20 blocks previous to the current valid merkle root window
expect(tracker.buffer()[0]).to.be.eql(new Uint8Array([0, 0, 0, 8]));
expect(tracker.buffer()[19]).to.be.eql(new Uint8Array([0, 0, 0, 27]));
// Remove roots 29 and 30
tracker.backFill(29);
assert.sameDeepMembers(tracker.roots(), [
new Uint8Array([0, 0, 0, 28]),
new Uint8Array([0, 0, 0, 27]),
new Uint8Array([0, 0, 0, 26])
]);
expect(tracker.buffer()).to.have.length(18);
expect(tracker.buffer()[0]).to.be.eql(new Uint8Array([0, 0, 0, 8]));
expect(tracker.buffer()[17]).to.be.eql(new Uint8Array([0, 0, 0, 25]));
// Remove roots from block 15 onwards. These blocks exists within the buffer
tracker.backFill(15);
assert.sameDeepMembers(tracker.roots(), [
new Uint8Array([0, 0, 0, 14]),
new Uint8Array([0, 0, 0, 13]),
new Uint8Array([0, 0, 0, 12])
]);
expect(tracker.buffer()).to.have.length(4);
expect(tracker.buffer()[0]).to.be.eql(new Uint8Array([0, 0, 0, 8]));
expect(tracker.buffer()[3]).to.be.eql(new Uint8Array([0, 0, 0, 11]));
});
});

View File

@ -0,0 +1,92 @@
class RootPerBlock {
public constructor(
public root: Uint8Array,
public blockNumber: number
) {}
}
const maxBufferSize = 20;
export class MerkleRootTracker {
private validMerkleRoots: Array<RootPerBlock> = new Array<RootPerBlock>();
private merkleRootBuffer: Array<RootPerBlock> = new Array<RootPerBlock>();
public constructor(
private acceptableRootWindowSize: number,
initialRoot: Uint8Array
) {
this.pushRoot(0, initialRoot);
}
public backFill(fromBlockNumber: number): void {
if (this.validMerkleRoots.length == 0) return;
let numBlocks = 0;
for (let i = this.validMerkleRoots.length - 1; i >= 0; i--) {
if (this.validMerkleRoots[i].blockNumber >= fromBlockNumber) {
numBlocks++;
}
}
if (numBlocks == 0) return;
const olderBlock = fromBlockNumber < this.validMerkleRoots[0].blockNumber;
// Remove last roots
let rootsToPop = numBlocks;
if (this.validMerkleRoots.length < rootsToPop) {
rootsToPop = this.validMerkleRoots.length;
}
this.validMerkleRoots = this.validMerkleRoots.slice(
0,
this.validMerkleRoots.length - rootsToPop
);
if (this.merkleRootBuffer.length == 0) return;
if (olderBlock) {
const idx = this.merkleRootBuffer.findIndex(
(x) => x.blockNumber == fromBlockNumber
);
if (idx > -1) {
this.merkleRootBuffer = this.merkleRootBuffer.slice(0, idx);
}
}
// Backfill the tree's acceptable roots
let rootsToRestore =
this.acceptableRootWindowSize - this.validMerkleRoots.length;
if (this.merkleRootBuffer.length < rootsToRestore) {
rootsToRestore = this.merkleRootBuffer.length;
}
for (let i = 0; i < rootsToRestore; i++) {
const x = this.merkleRootBuffer.pop();
if (x) this.validMerkleRoots.unshift(x);
}
}
public pushRoot(blockNumber: number, root: Uint8Array): void {
this.validMerkleRoots.push(new RootPerBlock(root, blockNumber));
// Maintain valid merkle root window
if (this.validMerkleRoots.length > this.acceptableRootWindowSize) {
const x = this.validMerkleRoots.shift();
if (x) this.merkleRootBuffer.push(x);
}
// Maintain merkle root buffer
if (this.merkleRootBuffer.length > maxBufferSize) {
this.merkleRootBuffer.shift();
}
}
public roots(): Array<Uint8Array> {
return this.validMerkleRoots.map((x) => x.root);
}
public buffer(): Array<Uint8Array> {
return this.merkleRootBuffer.map((x) => x.root);
}
}

View File

@ -0,0 +1,84 @@
/**
* Concatenate Uint8Arrays
* @param input
* @returns concatenation of all Uint8Array received as input
*/
export function concatenate(...input: Uint8Array[]): Uint8Array {
let totalLength = 0;
for (const arr of input) {
totalLength += arr.length;
}
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of input) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
// Adapted from https://github.com/feross/buffer
function checkInt(
buf: Uint8Array,
value: number,
offset: number,
ext: number,
max: number,
min: number
): void {
if (value > max || value < min)
throw new RangeError('"value" argument is out of bounds');
if (offset + ext > buf.length) throw new RangeError("Index out of range");
}
export function writeUIntLE(
buf: Uint8Array,
value: number,
offset: number,
byteLength: number,
noAssert?: boolean
): Uint8Array {
value = +value;
offset = offset >>> 0;
byteLength = byteLength >>> 0;
if (!noAssert) {
const maxBytes = Math.pow(2, 8 * byteLength) - 1;
checkInt(buf, value, offset, byteLength, maxBytes, 0);
}
let mul = 1;
let i = 0;
buf[offset] = value & 0xff;
while (++i < byteLength && (mul *= 0x100)) {
buf[offset + i] = (value / mul) & 0xff;
}
return buf;
}
/**
* Transforms Uint8Array into BigInt
* @param array: Uint8Array
* @returns BigInt
*/
export function buildBigIntFromUint8Array(
array: Uint8Array,
byteOffset: number = 0
): bigint {
const dataView = new DataView(array.buffer);
return dataView.getBigUint64(byteOffset, true);
}
/**
* Fills with zeros to set length
* @param array little endian Uint8Array
* @param length amount to pad
* @returns little endian Uint8Array padded with zeros to set length
*/
export function zeroPadLE(array: Uint8Array, length: number): Uint8Array {
const result = new Uint8Array(length);
for (let i = 0; i < length; i++) {
result[i] = array[i] || 0;
}
return result;
}

View File

@ -0,0 +1,17 @@
import { expect } from "chai";
import fc from "fast-check";
import { epochBytesToInt, epochIntToBytes } from "./epoch.js";
describe("epoch serialization", () => {
it("Round trip", async function () {
await fc.assert(
fc.asyncProperty(fc.integer({ min: 0 }), async (date) => {
const bytes = epochIntToBytes(date);
const _date = epochBytesToInt(bytes);
expect(_date.valueOf()).to.eq(date.valueOf());
})
);
});
});

View File

@ -0,0 +1,30 @@
import { Logger } from "@waku/utils";
const DefaultEpochUnitSeconds = 10; // the rln-relay epoch length in seconds
const log = new Logger("waku:rln:epoch");
export function dateToEpoch(
timestamp: Date,
epochUnitSeconds: number = DefaultEpochUnitSeconds
): number {
const time = timestamp.getTime();
const epoch = Math.floor(time / 1000 / epochUnitSeconds);
log.info("generated epoch", epoch);
return epoch;
}
export function epochIntToBytes(epoch: number): Uint8Array {
const bytes = new Uint8Array(32);
const db = new DataView(bytes.buffer);
db.setUint32(0, epoch, true);
log.info("encoded epoch", epoch, bytes);
return bytes;
}
export function epochBytesToInt(bytes: Uint8Array): number {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const epoch = dv.getUint32(0, true);
log.info("decoded epoch", epoch, bytes);
return epoch;
}

View File

@ -0,0 +1,15 @@
import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import { concatenate, writeUIntLE } from "./bytes.js";
export function poseidonHash(...input: Array<Uint8Array>): Uint8Array {
const inputLen = writeUIntLE(new Uint8Array(8), input.length, 0, 8);
const lenPrefixedData = concatenate(inputLen, ...input);
return zerokitRLN.poseidonHash(lenPrefixedData);
}
export function sha256(input: Uint8Array): Uint8Array {
const inputLen = writeUIntLE(new Uint8Array(8), input.length, 0, 8);
const lenPrefixedData = concatenate(inputLen, input);
return zerokitRLN.hash(lenPrefixedData);
}

View File

@ -0,0 +1,9 @@
export { extractMetaMaskSigner } from "./metamask.js";
export {
concatenate,
writeUIntLE,
buildBigIntFromUint8Array,
zeroPadLE
} from "./bytes.js";
export { sha256, poseidonHash } from "./hash.js";
export { dateToEpoch, epochIntToBytes, epochBytesToInt } from "./epoch.js";

View File

@ -0,0 +1,17 @@
import { ethers } from "ethers";
export const extractMetaMaskSigner = async (): Promise<ethers.Signer> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ethereum = (window as any).ethereum;
if (!ethereum) {
throw Error(
"Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask."
);
}
await ethereum.request({ method: "eth_requestAccounts" });
const provider = new ethers.providers.Web3Provider(ethereum, "any");
return provider.getSigner();
};

184
packages/rln/src/zerokit.ts Normal file
View File

@ -0,0 +1,184 @@
import type { IRateLimitProof } from "@waku/interfaces";
import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import { IdentityCredential } from "./identity.js";
import { Proof, proofToBytes } from "./proof.js";
import { WitnessCalculator } from "./resources/witness_calculator";
import {
concatenate,
dateToEpoch,
epochIntToBytes,
writeUIntLE
} from "./utils/index.js";
export class Zerokit {
public constructor(
private readonly zkRLN: number,
private readonly witnessCalculator: WitnessCalculator
) {}
public generateIdentityCredentials(): IdentityCredential {
const memKeys = zerokitRLN.generateExtendedMembershipKey(this.zkRLN); // TODO: rename this function in zerokit rln-wasm
return IdentityCredential.fromBytes(memKeys);
}
public generateSeededIdentityCredential(seed: string): IdentityCredential {
const stringEncoder = new TextEncoder();
const seedBytes = stringEncoder.encode(seed);
// TODO: rename this function in zerokit rln-wasm
const memKeys = zerokitRLN.generateSeededExtendedMembershipKey(
this.zkRLN,
seedBytes
);
return IdentityCredential.fromBytes(memKeys);
}
public insertMember(idCommitment: Uint8Array): void {
zerokitRLN.insertMember(this.zkRLN, idCommitment);
}
public insertMembers(
index: number,
...idCommitments: Array<Uint8Array>
): void {
// serializes a seq of IDCommitments to a byte seq
// the order of serialization is |id_commitment_len<8>|id_commitment<var>|
const idCommitmentLen = writeUIntLE(
new Uint8Array(8),
idCommitments.length,
0,
8
);
const idCommitmentBytes = concatenate(idCommitmentLen, ...idCommitments);
zerokitRLN.setLeavesFrom(this.zkRLN, index, idCommitmentBytes);
}
public deleteMember(index: number): void {
zerokitRLN.deleteLeaf(this.zkRLN, index);
}
public getMerkleRoot(): Uint8Array {
return zerokitRLN.getRoot(this.zkRLN);
}
public serializeMessage(
uint8Msg: Uint8Array,
memIndex: number,
epoch: Uint8Array,
idKey: Uint8Array
): Uint8Array {
// calculate message length
const msgLen = writeUIntLE(new Uint8Array(8), uint8Msg.length, 0, 8);
// Converting index to LE bytes
const memIndexBytes = writeUIntLE(new Uint8Array(8), memIndex, 0, 8);
// [ id_key<32> | id_index<8> | epoch<32> | signal_len<8> | signal<var> ]
return concatenate(idKey, memIndexBytes, epoch, msgLen, uint8Msg);
}
public async generateRLNProof(
msg: Uint8Array,
index: number,
epoch: Uint8Array | Date | undefined,
idSecretHash: Uint8Array
): Promise<IRateLimitProof> {
if (epoch === undefined) {
epoch = epochIntToBytes(dateToEpoch(new Date()));
} else if (epoch instanceof Date) {
epoch = epochIntToBytes(dateToEpoch(epoch));
}
if (epoch.length !== 32) throw new Error("invalid epoch");
if (idSecretHash.length !== 32) throw new Error("invalid id secret hash");
if (index < 0) throw new Error("index must be >= 0");
const serialized_msg = this.serializeMessage(
msg,
index,
epoch,
idSecretHash
);
const rlnWitness = zerokitRLN.getSerializedRLNWitness(
this.zkRLN,
serialized_msg
);
const inputs = zerokitRLN.RLNWitnessToJson(this.zkRLN, rlnWitness);
const calculatedWitness = await this.witnessCalculator.calculateWitness(
inputs,
false
); // no sanity check being used in zerokit
const proofBytes = zerokitRLN.generate_rln_proof_with_witness(
this.zkRLN,
calculatedWitness,
rlnWitness
);
return new Proof(proofBytes);
}
public verifyRLNProof(
proof: IRateLimitProof | Uint8Array,
msg: Uint8Array
): boolean {
let pBytes: Uint8Array;
if (proof instanceof Uint8Array) {
pBytes = proof;
} else {
pBytes = proofToBytes(proof);
}
// calculate message length
const msgLen = writeUIntLE(new Uint8Array(8), msg.length, 0, 8);
return zerokitRLN.verifyRLNProof(
this.zkRLN,
concatenate(pBytes, msgLen, msg)
);
}
public verifyWithRoots(
proof: IRateLimitProof | Uint8Array,
msg: Uint8Array,
...roots: Array<Uint8Array>
): boolean {
let pBytes: Uint8Array;
if (proof instanceof Uint8Array) {
pBytes = proof;
} else {
pBytes = proofToBytes(proof);
}
// calculate message length
const msgLen = writeUIntLE(new Uint8Array(8), msg.length, 0, 8);
const rootsBytes = concatenate(...roots);
return zerokitRLN.verifyWithRoots(
this.zkRLN,
concatenate(pBytes, msgLen, msg),
rootsBytes
);
}
public verifyWithNoRoot(
proof: IRateLimitProof | Uint8Array,
msg: Uint8Array
): boolean {
let pBytes: Uint8Array;
if (proof instanceof Uint8Array) {
pBytes = proof;
} else {
pBytes = proofToBytes(proof);
}
// calculate message length
const msgLen = writeUIntLE(new Uint8Array(8), msg.length, 0, 8);
return zerokitRLN.verifyWithRoots(
this.zkRLN,
concatenate(pBytes, msgLen, msg),
new Uint8Array()
);
}
}

View File

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.dev"
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
"outDir": "dist/",
"rootDir": "src",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src"],
"exclude": ["src/**/*.spec.ts", "src/test_utils"]
}

View File

@ -51,7 +51,7 @@ export async function runNodes<T>(
log.info("Starting js waku node with :", JSON.stringify(waku_options)); log.info("Starting js waku node with :", JSON.stringify(waku_options));
let waku: WakuNode | undefined; let waku: WakuNode | undefined;
try { try {
waku = (await createNode(waku_options)) as WakuNode; waku = (await createNode(waku_options)) as unknown as WakuNode;
await waku.start(); await waku.start();
} catch (error) { } catch (error) {
log.error("jswaku node failed to start:", error); log.error("jswaku node failed to start:", error);

View File

@ -3,7 +3,7 @@
"incremental": true, "incremental": true,
"target": "ES2022", "target": "ES2022",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"module": "ES2022", "module": "esnext",
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,