feat: add @waku/rln

This commit is contained in:
Sasha 2024-03-12 01:08:04 +01:00
parent 8ff15db44b
commit 1469ccb58c
No known key found for this signature in database
51 changed files with 4149 additions and 47866 deletions

View File

@ -10,5 +10,6 @@
"packages/message-encryption": "0.0.25",
"packages/relay": "0.0.10",
"packages/sdk": "0.0.23",
"packages/local-peer-cache-discovery": "1.0.0"
"packages/local-peer-cache-discovery": "1.0.0",
"packages/rln": "0.1.2"
}

47864
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
"packages/dns-discovery",
"packages/local-peer-cache-discovery",
"packages/message-encryption",
"packages/rln",
"packages/sdk",
"packages/tests",
"packages/browser-tests",

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

@ -0,0 +1,201 @@
# `@waku/rln`
This browser library enables the usage of RLN with Waku, as specified in the [Waku v2 RLN Relay RFC](https://rfc.vac.dev/spec/17/).
## Purpose
### RLN Cryptography
The RLN cryptographic function are provided by [zerokit](https://github.com/vacp2p/zerokit/).
This is imported via the `@waku/zerokit-rln-wasm` dependencies which contains a WASM extract of zerokit's RLN functions.
Note that RLN Credentials generated with `zerokit`, and hence `@waku/rln`, are compatible with semaphore credentials.
Note that the WASM blob uses browser APIs, **NodeJS is not supported**.
### Waku Interfaces
This library implements the [`IEncoder`](https://github.com/waku-org/js-waku/blob/604ba1a889f1994bd27f5749107c3a5b2ef490d5/packages/interfaces/src/message.ts#L43)
and [`IDecoder`](https://github.com/waku-org/js-waku/blob/604ba1a889f1994bd27f5749107c3a5b2ef490d5/packages/interfaces/src/message.ts#L58)
interfaces of [js-waku](https://github.com/waku-org/js-waku).
This enables a seamless usage with js-waku applications, as demonstrated in the [rln-js example](https://github.com/waku-org/js-waku-examples/tree/master/examples/rln-js).
### Comparison to Existing Work
[Rate-Limiting-Nullifier/rlnjs](https://github.com/Rate-Limiting-Nullifier/rlnjs)
is an existing JavaScript / TypeScript library that already provides RLN cryptographic functionalities for the browser.
The core difference is that `@waku/rln` uses [zerokit](https://github.com/vacp2p/zerokit/) for cryptography and provide opinionated interfaces to use RLN specifically in the context of Waku.
## Install
```
npm install @waku/rln
# or with yarn
yarn add @waku/rln
```
Or to use ESM import directly from a `<script>` tag:
```html
<script type="module">
import * as rln from "https://unpkg.com/@waku/rln@0.0.13/bundle/index.js";
</script>
```
## Running example app
```
git clone https://github.com/waku-org/js-rln
cd js-rln/
npm install
cd example/
npm install # or yarn
npm start
```
Browse http://localhost:8080 and open the dev tools console to see the proof being generated and its verification
## Usage
### Initializing the Library
```js
import * as rln from "@waku/rln";
const rlnInstance = await rln.createRLN();
```
### Starting RLN to listen to a contract
```js
import * as rln from "@waku/rln";
const rlnInstance = await rln.createRLN();
await rlnInstance.start(); // will use default Sepolia contract
```
#### Generating RLN Membership Credentials
```js
let credentials = rlnInstance.generateIdentityCredentials();
```
### Generating RLN Membership Keypair Using a Seed
This can be used to generate credentials from a signature hash (e.g. signed by an Ethereum account).
```js
let credentials = rlnInstance.generateSeededIdentityCredentials(seed);
```
### Adding Membership Keys Into Merkle Tree
```js
rlnInstance.insertMember(credentials.IDCommitment);
```
### Registering Membership on a contract
```js
import * as rln from "@waku/rln";
const rlnInstance = await rln.createRLN();
await rlnInstance.start(); // will use default Sepolia contract
const membershipInfo = await rlnInstance.contract.registerWithKey(credentials);
```
### Generating a Proof
```js
// prepare the message
const uint8Msg = Uint8Array.from(
"Hello World".split("").map((x) => x.charCodeAt())
);
// setting up the epoch (With 0s for the test)
const epoch = new Uint8Array(32);
// generating a proof
const proof = await rlnInstance.generateProof(
uint8Msg,
index,
epoch,
credentials.IDSecretHash
);
```
### Verifying a Proof
```js
try {
// verify the proof
const verificationResult = rlnInstance.verifyProof(proof, uint8Msg);
console.log("Is proof verified?", verificationResult ? "yes" : "no");
} catch (err) {
console.log("Invalid proof");
}
```
### Updating Circuit, Verification Key and Zkey
The RLN specs defines the defaults.
These values are fixed and should not change.
Currently, these [resources](https://github.com/vacp2p/zerokit/tree/master/rln/resources/tree_height_20) are being used.
If they change, this file needs to be updated in `resources.ts` which
contains these values encoded in base64 in this format:
```
const verification_key = "...";
const circuit = "..."; // wasm file generated by circom
const zkey = "...";
export {verification_key, circuit, zkey};
```
A tool like GNU's `base64` could be used to encode this data.
### Updating Zerokit
1. Make sure you have NodeJS installed and a C compiler
2. Install wasm-pack
```
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
```
3. Compile RLN for wasm
```
git clone https://github.com/vacp2p/zerokit
cd zerokit/rln-wasm
wasm-pack build --release
```
4. Copy `pkg/rln*` into `src/zerokit`
## Bugs, Questions & Features
If you encounter any bug or would like to propose new features, feel free to [open an issue](https://github.com/waku-org/js-rln/issues/new/).
For more general discussion, help and latest news, join [Vac Discord](https://discord.gg/PQFdubGt6d) or [Telegram](https://t.me/vacp2p).
## License
Licensed and distributed under either of
- MIT license: [LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT
or
- Apache License, Version 2.0, ([LICENSE-APACHE-v2](LICENSE-APACHE-v2) or http://www.apache.org/licenses/LICENSE-2.0)
at your option. These files may not be copied, modified, or distributed except according to those terms.

View File

@ -0,0 +1,79 @@
const path = require("path");
const webpack = require("webpack");
const playwright = require('playwright');
process.env.CHROME_BIN = playwright.chromium.executablePath();
const output = {
path: path.join(__dirname, "dist"),
};
module.exports = function (config) {
config.set({
frameworks: ["webpack", "mocha"],
preprocessors: {
"**/*.ts": ["webpack"],
},
files: [
"src/**/*.spec.ts",
"src/**/*.ts",
{
pattern: `${output.path}/**/*`,
watched: false,
included: false,
served: true,
},
],
envPreprocessor: ["CI"],
reporters: ["progress"],
browsers: ["ChromeHeadless"],
pingTimeout: 60000,
singleRun: true,
client: {
mocha: {
timeout: 60000, // Default is 2s
},
},
webpack: {
output,
mode: "production",
module: {
rules: [
{
test: /\.wasm$/,
type: "asset/resource",
},
{
test: /\.(js|tsx?)$/,
loader: "ts-loader",
exclude: /node_modules|\.d\.ts$/,
options: { configFile: "tsconfig.karma.json" },
},
{
test: /\.d\.ts$/,
loader: "ignore-loader",
},
],
},
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", ".tsx", ".js"],
extensionAlias: {
".js": [".js", ".ts"],
".cjs": [".cjs", ".cts"],
".mjs": [".mjs", ".mts"]
}
},
stats: { warnings: false },
devtool: "inline-source-map"
},
});
};

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

@ -0,0 +1,145 @@
{
"name": "@waku/rln",
"version": "0.1.2",
"description": "Rate Limit Nullifier for js-waku",
"types": "./dist/index.d.ts",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"type": "module",
"repository": "https://github.com/waku-org/js-rln",
"license": "MIT OR Apache-2.0",
"keywords": [
"waku",
"decentralised",
"communication",
"web3",
"ethereum",
"dapps"
],
"scripts": {
"prepare": "husky install",
"build": "run-s build:**",
"build:codegen": "node ./scripts/schema_validator_codegen.js",
"build:tsc": "tsc",
"build:bundle": "rollup --config rollup.config.js",
"size": "npm run build && size-limit",
"fix": "run-s fix:*",
"fix:lint": "eslint src --ext .ts --ext .cjs --fix",
"test": "run-s test:*",
"test:lint": "eslint src --ext .ts",
"test:spelling": "cspell \"{*.md,.github/*.md,src/**/*.ts}\"",
"test:tsc": "tsc -p tsconfig.dev.json",
"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 && git reset --hard && npm i && npm run build"
},
"browser": {
"crypto": false
},
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-wasm": "^5.2.0",
"@size-limit/preset-big-lib": "^8.0.0",
"@types/app-root-path": "^1.2.4",
"@types/chai": "^4.2.15",
"@types/chai-as-promised": "^7.1.6",
"@types/chai-spies": "^1.0.3",
"@types/chai-subset": "^1.3.3",
"@types/debug": "^4.1.7",
"@types/deep-equal-in-any-order": "^1.0.1",
"@types/lodash": "^4.14.199",
"@types/mocha": "^9.1.0",
"@types/node": "^17.0.6",
"@types/tail": "^2.0.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.21.0",
"@waku/interfaces": "^0.0.22",
"@waku/message-encryption": "^0.0.25",
"@web/rollup-plugin-import-meta-assets": "^1.0.7",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"app-root-path": "^3.0.0",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"chai-spies": "^1.0.0",
"chai-subset": "^1.6.0",
"cspell": "^5.14.0",
"deep-equal-in-any-order": "^2.0.6",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^6.0.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^5.0.1",
"fast-check": "^2.25.0",
"gh-pages": "^3.2.3",
"husky": "^7.0.4",
"ignore-loader": "^0.1.2",
"isomorphic-fetch": "^3.0.0",
"jsdom": "^19.0.0",
"jsdom-global": "^3.0.2",
"karma": "^6.4.2",
"karma-chrome-launcher": "^3.2.0",
"karma-firefox-launcher": "^2.1.2",
"karma-mocha": "^2.0.1",
"karma-webkit-launcher": "^2.4.0",
"karma-webpack": "github:codymikol/karma-webpack#2337a82beb078c0d8e25ae8333a06249b8e72828",
"lint-staged": "^13.0.3",
"mocha": "10.1.0",
"npm-run-all": "^4.1.5",
"p-timeout": "^4.1.0",
"playwright": "^1.40.1",
"process": "^0.11.10",
"rollup": "^2.75.0",
"rollup-plugin-copy": "^3.4.0",
"size-limit": "^8.0.0",
"tail": "^2.2.0",
"ts-loader": "^9.3.1",
"ts-node": "^10.9.1",
"typedoc": "^0.25.7",
"typescript": "^5.3.2"
},
"files": [
"dist",
"bundle",
"src/**.ts",
"src/**.js",
"!**/*.spec.*",
"!**/*.json",
"CHANGELOG.md",
"LICENSE",
"README.md"
],
"lint-staged": {
"*.ts": [
"eslint --fix"
]
},
"dependencies": {
"@chainsafe/bls-keystore": "^3.0.0",
"@waku/core": "^0.0.27",
"@waku/utils": "^0.0.15",
"@waku/zerokit-rln-wasm": "^0.0.13",
"ethereum-cryptography": "^2.1.2",
"ethers": "^5.7.2",
"lodash": "^4.17.21",
"uuid": "^9.0.1"
}
}

View File

@ -0,0 +1,35 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import { wasm } from "@rollup/plugin-wasm";
import copy from "rollup-plugin-copy";
import { importMetaAssets } from "@web/rollup-plugin-import-meta-assets";
export default {
input: {
index: "dist/index.js",
},
output: {
dir: "bundle",
format: "esm",
},
plugins: [
copy({
hook: "buildStart",
targets: [
{ src: "src/resources/rln.wasm", dest: "dist/resources" },
{ src: "src/resources/rln_final.zkey", dest: "dist/resources" },
],
}),
commonjs(),
json(),
wasm({
maxFileSize: 0,
}),
nodeResolve({
browser: true,
preferBuiltins: false,
extensions: [".js", ".ts", ".wasm"],
}),
importMetaAssets(),
],
};

0
packages/rln/runtime.js Normal file
View File

View File

@ -0,0 +1,23 @@
{
"$ref": "#/definitions/Keystore",
"definitions": {
"Keystore": {
"type": "object",
"properties": {
"credentials": {
"type": "object"
},
"appIdentifier": {
"type": "string"
},
"version": {
"type": "string"
},
"application": {
"type": "string"
}
},
"required": ["application", "appIdentifier", "credentials", "version"]
}
}
}

View File

@ -0,0 +1,42 @@
{
"$ref": "#/definitions/Credential",
"definitions": {
"Credential": {
"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"]
}
}
}

View File

@ -0,0 +1,77 @@
import { writeFileSync } from "fs";
import { join } from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import standaloneCode from "ajv/dist/standalone/index.js";
import keystoreSchema from "./nwaku_keystore.json" assert { type: "json" };
import credentialSchema from "./nwaku_keystore_credential.json" assert { type: "json" };
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Generate a function that validates the Keystore schema.
// Write the function to a file in the source.
const OUTPUT_DIR = join(__dirname, "../src/keystore");
const OUTPUT_KEYSTORE_FILE = join(
OUTPUT_DIR,
"keystore_validation_generated.ts"
);
const OUTPUT_CREDENTIALS_FILE = join(
OUTPUT_DIR,
"credential_validation_generated.ts"
);
// Initialize ajv instance
let ajv = new Ajv({
schemas: [keystoreSchema],
code: {
source: true,
esm: true,
},
});
addFormats(ajv);
// The output code is minified vanilla javascript and uses the cute pattern of attaching errors to the exported function as a property.
// The easiest way to treat this is to just ts-ignore the whole output and wrap the function with a handcrafted type.
const keystoreModuleCode =
`
/* 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
` +
standaloneCode(ajv, {
Keystore: "#/definitions/Keystore",
});
ajv = new Ajv({
schemas: [credentialSchema],
code: {
source: true,
esm: true,
},
});
addFormats(ajv);
const credentialModuleCode =
`
/* 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
` +
standaloneCode(ajv, {
Credential: "#/definitions/Credential",
});
writeFileSync(OUTPUT_KEYSTORE_FILE, keystoreModuleCode);
writeFileSync(OUTPUT_CREDENTIALS_FILE, credentialModuleCode);

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;
});
});

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

@ -0,0 +1,130 @@
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;
constructor(
private encoder: IEncoder,
private rlnInstance: RLNInstance,
private index: number,
identityCredential: IdentityCredential
) {
if (index < 0) throw "invalid membership index";
this.idSecretHash = identityCredential.IDSecretHash;
}
async toWire(message: IMessage): Promise<Uint8Array | undefined> {
message.rateLimitProof = await this.generateProof(message);
log("Proof generated", message.rateLimitProof);
return this.encoder.toWire(message);
}
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("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;
}
get pubsubTopic(): string {
return this.encoder.pubsubTopic;
}
get contentTopic(): string {
return this.encoder.contentTopic;
}
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>>
{
constructor(
private rlnInstance: RLNInstance,
private decoder: IDecoder<T>
) {}
get pubsubTopic(): string {
return this.decoder.pubsubTopic;
}
get contentTopic(): string {
return this.decoder.contentTopic;
}
fromWireToProtoObj(bytes: Uint8Array): Promise<IProtoMessage | undefined> {
const protoMessage = this.decoder.fromWireToProtoObj(bytes);
log("Message decoded", protoMessage);
return Promise.resolve(protoMessage);
}
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,78 @@
import chai from "chai";
import spies from "chai-spies";
import * as ethers from "ethers";
import { createRLN } from "../create.js";
import { SEPOLIA_CONTRACT } from "./constants.js";
import { RLNContract } from "./rln_contract.js";
chai.use(spies);
describe("RLN Contract abstraction", () => {
it("should be able to fetch members from events and store to rln instance", async () => {
const rlnInstance = await createRLN();
rlnInstance.zerokit.insertMember = () => undefined;
const insertMemberSpy = chai.spy.on(rlnInstance.zerokit, "insertMember");
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);
chai.expect(insertMemberSpy).to.have.been.called();
});
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
});
rlnContract["storageIndex"] = 1;
rlnContract["_membersFilter"] = {
address: "",
topics: []
} as unknown as ethers.EventFilter;
rlnContract["registryContract"] = {
"register(uint16,uint256)": () =>
Promise.resolve({ wait: () => Promise.resolve(undefined) })
} as unknown as ethers.Contract;
const contractSpy = chai.spy.on(
rlnContract["registryContract"],
"register(uint16,uint256)"
);
const identity =
rlnInstance.zerokit.generateSeededIdentityCredential(mockSignature);
await rlnContract.registerWithIdentity(identity);
chai.expect(contractSpy).to.have.been.called();
});
});
function mockEvent(): ethers.Event {
return {
args: {
idCommitment: { _hex: "0xb3df1c4e5600ef2b" },
index: ethers.BigNumber.from(1)
}
} as unknown as ethers.Event;
}

View File

@ -0,0 +1,355 @@
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;
}
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;
let skip = size;
while (skip < array.length) {
const portion = array.slice(start, skip);
yield portion;
start = skip;
skip += size;
}
}
function ignoreErrors<T>(promise: Promise<T>, defaultValue: T): Promise<T> {
return promise.catch((err) => {
log(`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,27 @@
import { buildBigIntFromUint8Array } from "./utils/index.js";
export class IdentityCredential {
constructor(
public readonly IDTrapdoor: Uint8Array,
public readonly IDNullifier: Uint8Array,
public readonly IDSecretHash: Uint8Array,
public readonly IDCommitment: Uint8Array,
public readonly IDCommitmentBigInt: bigint
) {}
static fromBytes(memKeys: Uint8Array): IdentityCredential {
const idTrapdoor = memKeys.subarray(0, 32);
const idNullifier = memKeys.subarray(32, 64);
const idSecretHash = memKeys.subarray(64, 96);
const idCommitment = memKeys.subarray(96);
const idCommitmentBigInt = buildBigIntFromUint8Array(idCommitment);
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.js";
import { kdf } from "@chainsafe/bls-keystore/lib/kdf.js";
import { normalizePassword } from "@chainsafe/bls-keystore/lib/password.js";
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,311 @@
import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import chaiSubset from "chai-subset";
import deepEqualInAnyOrder from "deep-equal-in-any-order";
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("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("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 = "";
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;
}
get payload(): Uint8Array {
return this.msg.payload;
}
get contentTopic(): string {
return this.msg.contentTopic;
}
get timestamp(): Date | undefined {
return this.msg.timestamp;
}
get ephemeral(): boolean | undefined {
return this.msg.ephemeral;
}
get meta(): Uint8Array | undefined {
return this.msg.meta;
}
get epoch(): number | undefined {
const bytes = this.msg.rateLimitProof?.epoch;
if (!bytes) return;
return epochBytesToInt(bytes);
}
}

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

@ -0,0 +1,67 @@
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 {
constructor(
public readonly nullifier: Uint8Array,
public readonly shareX: Uint8Array,
public readonly shareY: Uint8Array,
public readonly externalNullifier: Uint8Array
) {}
}
export class Proof implements IRateLimitProof {
readonly proof: Uint8Array;
readonly merkleRoot: Uint8Array;
readonly epoch: Uint8Array;
readonly shareX: Uint8Array;
readonly shareY: Uint8Array;
readonly nullifier: Uint8Array;
readonly rlnIdentifier: Uint8Array;
constructor(proofBytes: Uint8Array) {
if (proofBytes.length < rlnIdentifierOffset) throw "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
);
}
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,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,8 @@
export async function builder(
code: Uint8Array,
sanityCheck: bool
): Promise<WitnessCalculator>;
export class WitnessCalculator {
calculateWitness(input, sanityCheck): Promise<Array<bigint>>;
}

View File

@ -0,0 +1,335 @@
// 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;
}

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

@ -0,0 +1,286 @@
import { createDecoder, createEncoder } from "@waku/core";
import type {
ContentTopic,
IDecodedMessage,
EncoderOptions as WakuEncoderOptions
} from "@waku/interfaces";
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.js";
import * as wc from "./resources/witness_calculator.js";
import { WitnessCalculator } from "./resources/witness_calculator.js";
import { extractMetaMaskSigner } from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
async function loadWitnessCalculator(): Promise<WitnessCalculator> {
const url = new URL("./resources/rln.wasm", import.meta.url);
const response = await fetch(url);
return await wc.builder(new Uint8Array(await response.arrayBuffer()), false);
}
async function loadZkey(): Promise<Uint8Array> {
const url = new URL("./resources/rln_final.zkey", import.meta.url);
const response = await fetch(url);
return new Uint8Array(await response.arrayBuffer());
}
/**
* Create an instance of RLN
* @returns RLNInstance
*/
export async function create(): Promise<RLNInstance> {
// 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);
}
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;
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,91 @@
class RootPerBlock {
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>();
constructor(
private acceptableRootWindowSize: number,
initialRoot: Uint8Array
) {
this.pushRoot(0, initialRoot);
}
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);
}
}
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();
}
}
roots(): Array<Uint8Array> {
return this.validMerkleRoots.map((x) => x.root);
}
buffer(): Array<Uint8Array> {
return this.merkleRootBuffer.map((x) => x.root);
}
}

View File

@ -0,0 +1,81 @@
/**
* 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): bigint {
const dataView = new DataView(array.buffer);
return dataView.getBigUint64(0, 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("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("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("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();
};

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

@ -0,0 +1,181 @@
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.js";
import {
concatenate,
dateToEpoch,
epochIntToBytes,
writeUIntLE
} from "./utils/index.js";
export class Zerokit {
constructor(
private zkRLN: number,
private witnessCalculator: WitnessCalculator
) {}
generateIdentityCredentials(): IdentityCredential {
const memKeys = zerokitRLN.generateExtendedMembershipKey(this.zkRLN); // TODO: rename this function in zerokit rln-wasm
return IdentityCredential.fromBytes(memKeys);
}
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);
}
insertMember(idCommitment: Uint8Array): void {
zerokitRLN.insertMember(this.zkRLN, idCommitment);
}
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);
}
deleteMember(index: number): void {
zerokitRLN.deleteLeaf(this.zkRLN, index);
}
getMerkleRoot(): Uint8Array {
return zerokitRLN.getRoot(this.zkRLN);
}
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);
}
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 "invalid epoch";
if (idSecretHash.length != 32) throw "invalid id secret hash";
if (index < 0) throw "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);
}
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)
);
}
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
);
}
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,8 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
}
}

View File

@ -0,0 +1,53 @@
{
"compilerOptions": {
"incremental": true,
"target": "ES2022",
"moduleResolution": "Bundler",
"module": "ES2022",
"declaration": true,
"sourceMap": true,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"resolveJsonModule": true /* Include modules imported with .json extension. */,
"strict": true /* Enable all strict type-checking options. */,
/* Strict Type-Checking Options */
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"strictNullChecks": true /* Enable strict null checks. */,
"strictFunctionTypes": true /* Enable strict checking of function types. */,
"strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
"alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
/* Additional Checks */
"noUnusedLocals": true /* Report errors on unused locals. */,
"noUnusedParameters": true /* Report errors on unused parameters. */,
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
"forceConsistentCasingInFileNames": true,
/* Debugging Options */
"traceResolution": false /* Report module resolution log messages. */,
"listEmittedFiles": false /* Print names of generated files part of the compilation. */,
"listFiles": false /* Print names of files part of the compilation. */,
"pretty": true /* Stylize errors and messages using color and context. */,
// Due to broken types in indirect dependencies
"skipLibCheck": true,
"allowJs": true,
/* Experimental Options */
// "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
// "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
"lib": ["es2022", "dom"],
"types": ["node", "mocha"],
"typeRoots": ["node_modules/@types"],
"outDir": "dist/",
"rootDir": "src",
},
"include": ["src"],
"compileOnSave": false,
"ts-node": {
"files": true
}
}

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.dev",
"compilerOptions": {
"noEmit": false
}
}

10
packages/rln/typedoc.json Normal file
View File

@ -0,0 +1,10 @@
{
"entryPoints": ["./src/index.ts"],
"out": "build/docs",
"exclude": "**/*.spec.ts",
"excludeInternal": true,
"validation": {
"invalidLink": true,
"notExported": true
}
}

View File

@ -17,6 +17,7 @@
"packages/message-encryption": {},
"packages/sdk": {},
"packages/relay": {},
"packages/local-peer-cache-discovery": {}
"packages/local-peer-cache-discovery": {},
"packages/rln": {}
}
}