@cryptkeeperzk/rlnjs
Version:
Client library for generating and using RLN ZK proofs, is a forked repo from main Rate-Limiting-Nullifier/rlnjs to make it work with Cryptkeeper Browser Extension using `@cryptkeeperzk/snarkjs` and `@cryptkeeperzk/ffjavascript`
617 lines (573 loc) • 23.1 kB
text/typescript
import { Identity } from '@semaphore-protocol/identity'
import { VerificationKey } from './types'
import { DEFAULT_MERKLE_TREE_DEPTH, calculateIdentitySecret, calculateSignalHash } from './common'
import { IRLNRegistry, ContractRLNRegistry } from './registry'
import { MemoryCache, EvaluatedProof, ICache, Status } from './cache'
import { IMessageIDCounter, MemoryMessageIDCounter } from './message-id-counter'
import { RLNFullProof, RLNProver, RLNVerifier } from './circuit-wrapper'
import { ethers } from 'ethers'
import { RLNContract } from './contract-wrapper'
import { getDefaultRLNParams, getDefaultWithdrawParams } from './resources'
// Ref: https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/55c7da2227b501175076bf73e3ff6dc512c4c813/circuits/rln.circom#L40
const LIMIT_BIT_SIZE = 16
const MAX_MESSAGE_LIMIT = (BigInt(1) << BigInt(LIMIT_BIT_SIZE)) - BigInt(1)
export interface IRLN {
/* Membership */
/**
* Register the user to the registry.
* @param userMessageLimit The message limit of the user.
* @param messageIDCounter The messageIDCounter is used to **safely** generate the latest messageID for the user.
* If not provided, a new `MemoryMessageIDCounter` is created.
*/
register(userMessageLimit: bigint, messageIDCounter?: IMessageIDCounter): Promise<void>
/**
* Withdraw the user from the registry.
*/
withdraw(): Promise<void>
/**
* Slash the user with the given secret.
* @param secretToBeSlashed The secret to be slashed.
* @param receiver The address of the slash reward receiver. If not provided,
* the signer will receive the reward.
*/
slash(secretToBeSlashed: bigint, receiver?: string): Promise<void>
/* Proof-related */
/**
* Create a proof for the given epoch and message.
* @param epoch the timestamp of the message
* @param message the message to be proved
*/
createProof(epoch: bigint, message: string): Promise<RLNFullProof>
/**
* Verify a RLNFullProof
* @param epoch the timestamp of the message
* @param message the message to be proved
* @param proof the RLNFullProof to be verified
*/
verifyProof(epoch: bigint, message: string, proof: RLNFullProof): Promise<boolean>
/**
* Save a proof to the cache and check if it's a spam.
* @param proof the RLNFullProof to save and detect spam
* @returns result of the check. It could be VALID if the proof hasn't been seen,
* or DUPLICATE if the proof has been seen before, else BREACH means it could be spam.
*/
saveProof(proof: RLNFullProof): Promise<EvaluatedProof>
}
/**
* RLN handles all operations for a RLN user, including registering, withdrawing, creating proof, verifying proof.
* Please use `RLN.create` or `RLN.createWithContractRegistry` to create a RLN instance instead of
* using the constructor.
*/
export class RLN implements IRLN {
// the unique identifier of the app using RLN
readonly rlnIdentifier: bigint
// the semaphore identity of the user
readonly identity: Identity
// the prover allows user to generate proof with the RLN circuit
private prover?: RLNProver
// the verifier allows user to verify proof with the RLN circuit
private verifier?: RLNVerifier
// the registry that stores the registered users
private registry: IRLNRegistry
// the cache that stores proofs added by the user with `addProof`, and detect spams automatically
private cache: ICache
// the messageIDCounter is used to **safely** generate the latest messageID for the user
public messageIDCounter?: IMessageIDCounter
constructor(args: {
/** Required */
/**
* The unique identifier of the app using RLN. The identifier must be unique for every app.
*/
rlnIdentifier: bigint
/**
* `IRegistry` that stores the registered users. If not provided, a new `ContractRLNRegistry` is created.
* @see {@link ContractRLNRegistry}
*/
registry: IRLNRegistry
/**
* `ICache` that stores proofs added by the user with `addProof`, and detect spams automatically.
* If not provided, a new `MemoryCache` is created.
* @see {@link MemoryCache}
*/
cache: ICache
/**
* Semaphore identity of the user. If not provided, a new `Identity` is created.
*/
identity: Identity
/** Optional */
/**
* File path of the RLN wasm file. If not provided, `createProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
wasmFilePath?: string | Uint8Array
/**
* File path of the RLN final zkey file. If not provided, `createProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
finalZkeyPath?: string | Uint8Array
/**
* Verification key of the RLN circuit. If not provided, `verifyProof` and `saveProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
verificationKey?: VerificationKey
}) {
this.rlnIdentifier = args.rlnIdentifier
this.registry = args.registry
this.cache = args.cache
this.identity = args.identity
if ((args.wasmFilePath === undefined || args.finalZkeyPath === undefined) && args.verificationKey === undefined) {
throw new Error(
'Either both `wasmFilePath` and `finalZkeyPath` must be supplied to generate proofs, ' +
'or `verificationKey` must be provided to verify proofs.',
)
}
if (args.wasmFilePath !== undefined && args.finalZkeyPath !== undefined) {
this.prover = new RLNProver(args.wasmFilePath, args.finalZkeyPath)
}
if (args.verificationKey !== undefined) {
this.verifier = new RLNVerifier(args.verificationKey)
}
}
/**
* Create RLN instance with a custom registry
*/
static async create(args: {
/** Required */
/**
* The unique identifier of the app using RLN. The identifier must be unique for every app.
*/
rlnIdentifier: bigint
/**
* `IRegistry` that stores the registered users.
* @see {@link IRegistry}
*/
registry: IRLNRegistry
/** Optional */
/**
* Semaphore identity of the user. If not provided, a new `Identity` is created.
*/
identity?: Identity
/**
* Tree depth of the merkle tree used by the circuit. If not provided, the default value will be used.
* @default 20
*/
treeDepth?: number
/**
* The maximum number of epochs that the cache can store. If not provided, the default value will be used.
* This is only used when `cache` is not provided.
* @default 100
* @see {@link MemoryCache}
*/
cacheSize?: number
/**
* `ICache` that stores proofs added by the user with `addProof`, and detect spams automatically.
* If not provided, a new `MemoryCache` is created.
* @see {@link MemoryCache}
*/
cache?: ICache
/**
* If all of `wasmFilePath`, `finalZkeyPath`, and `verificationKey` are not given, default ones according to
* the `treeDepth` are used.
*/
/**
* File path of the RLN wasm file. If not provided, `createProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
wasmFilePath?: string | Uint8Array
/**
* File path of the RLN final zkey file. If not provided, `createProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
finalZkeyPath?: string | Uint8Array
/**
* Verification key of the RLN circuit. If not provided, `verifyProof` and `saveProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
verificationKey?: VerificationKey
}) {
if (args.rlnIdentifier < 0) {
throw new Error('rlnIdentifier must be positive')
}
if (args.treeDepth !== undefined && args.treeDepth <= 0) {
throw new Error('treeDepth must be positive')
}
if (args.cacheSize !== undefined && args.cacheSize <= 0) {
throw new Error('cacheSize must be positive')
}
const rlnIdentifier = args.rlnIdentifier
const registry = args.registry
const cache = args.cache ? args.cache : new MemoryCache(args.cacheSize)
const identity = args.identity ? args.identity : new Identity()
const treeDepth = args.treeDepth ? args.treeDepth : DEFAULT_MERKLE_TREE_DEPTH
// If `args.treeDepth` is given, `wasmFilePath`, `finalZkeyPath`, and `verificationKey` will be
// set to default first
// If all params are not given, use the default
let wasmFilePath: string | Uint8Array | undefined
let finalZkeyPath: string | Uint8Array | undefined
let verificationKey: VerificationKey | undefined
// If `args.wasmFilePath`, `args.finalZkeyPath`, and `args.verificationKey` are not given, see if we have defaults that can be used
if (args.wasmFilePath === undefined && args.finalZkeyPath === undefined && args.verificationKey === undefined) {
const defaultParams = await getDefaultRLNParams(treeDepth)
if (defaultParams !== undefined) {
wasmFilePath = defaultParams.wasmFile
finalZkeyPath = defaultParams.finalZkey
verificationKey = defaultParams.verificationKey
}
} else {
// Else, use the given params even if it is not complete
wasmFilePath = args.wasmFilePath
finalZkeyPath = args.finalZkeyPath
verificationKey = args.verificationKey
}
return new RLN({
rlnIdentifier,
registry,
identity,
cache,
wasmFilePath,
finalZkeyPath,
verificationKey,
})
}
/**
* Create RLN instance, using a deployed RLN contract as registry.
*/
static async createWithContractRegistry(args: {
/** Required */
/**
* The unique identifier of the app using RLN. The identifier must be unique for every app.
*/
rlnIdentifier: bigint
/**
* The ethers provider that is used to interact with the RLN contract.
* @see {@link https://docs.ethers.io/v5/api/providers/}
*/
provider: ethers.Provider
/**
* The address of the RLN contract.
*/
contractAddress: string
/** Optional */
/**
* The ethers signer that is used to interact with the RLN contract. If not provided,
* user can only do read-only operations. Functions like `register` and `withdraw` will not work
* since they need to send transactions to interact with the RLN contract.
* @see {@link https://docs.ethers.io/v5/api/signer/#Signer}
*/
signer?: ethers.Signer,
/**
* The block number where the RLN contract is deployed. If not provided, `0` will be used.
* @default 0
* @see {@link https://docs.ethers.io/v5/api/providers/provider/#Provider-getLogs}
*/
contractAtBlock?: number,
/**
* Semaphore identity of the user. If not provided, a new `Identity` is created.
*/
identity?: Identity
// File paths of the wasm and zkey file. If not provided, `createProof` will not work.
/**
* File path of the RLN wasm file. If not provided, `createProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
wasmFilePath?: string | Uint8Array
/**
* File path of the RLN final zkey file. If not provided, `createProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
finalZkeyPath?: string | Uint8Array
/**
* Verification key of the RLN circuit. If not provided, `verifyProof` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/rln.circom}
*/
verificationKey?: VerificationKey
/**
* Tree depth of the merkle tree used by the circuit. If not provided, the default value will be used.
* @default 20
*/
treeDepth?: number
/* Registry configs */
/**
* File path of the wasm file for withdraw circuit. If not provided, `withdraw` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/withdraw.circom}
*/
withdrawWasmFilePath?: string | Uint8Array,
/**
* File path of the final zkey file for withdraw circuit. If not provided, `withdraw` will not work.
* @see {@link https://github.com/Rate-Limiting-Nullifier/circom-rln/blob/main/circuits/withdraw.circom}
*/
withdrawFinalZkeyPath?: string | Uint8Array,
/** Others */
/**
* `ICache` that stores proofs added by the user with `addProof`, and detect spams automatically.
* If not provided, a new `MemoryCache` is created.
* @see {@link MemoryCache}
*/
cache?: ICache
/**
* The maximum number of epochs that the cache can store. If not provided, the default value will be used.
* This is only used when `cache` is not provided.
* @default 100
* @see {@link MemoryCache}
*/
cacheSize?: number
}) {
const rlnContractWrapper = new RLNContract({
provider: args.provider,
signer: args.signer,
contractAddress: args.contractAddress,
contractAtBlock: args.contractAtBlock ? args.contractAtBlock : 0,
})
const treeDepth = args.treeDepth ? args.treeDepth : DEFAULT_MERKLE_TREE_DEPTH
// If all params are not given, use the default
let withdrawWasmFilePath: string | Uint8Array | undefined
let withdrawFinalZkeyPath: string | Uint8Array | undefined
// If `args.withdrawWasmFilePath`, `args.finalZkeyPath`, see if we have defaults that can be used
if (args.withdrawWasmFilePath === undefined && args.withdrawFinalZkeyPath === undefined) {
const defaultParams = await getDefaultWithdrawParams()
if (defaultParams !== undefined) {
withdrawWasmFilePath = defaultParams.wasmFile
withdrawFinalZkeyPath = defaultParams.finalZkey
}
} else {
// Else, use the given params even if it is not complete
withdrawWasmFilePath = args.withdrawWasmFilePath
withdrawFinalZkeyPath = args.withdrawFinalZkeyPath
}
const registry = new ContractRLNRegistry({
rlnIdentifier: args.rlnIdentifier,
rlnContract: rlnContractWrapper,
treeDepth,
withdrawWasmFilePath: withdrawWasmFilePath,
withdrawFinalZkeyPath: withdrawFinalZkeyPath,
})
const argsWithRegistry = {
...args,
registry,
}
return RLN.create(argsWithRegistry)
}
/**
* Set a custom messageIDCounter
* @param messageIDCounter The custom messageIDCounter
*/
async setMessageIDCounter(messageIDCounter?: IMessageIDCounter) {
if (await this.isRegistered() === false) {
throw new Error('Cannot set messageIDCounter for an unregistered user.')
}
if (messageIDCounter !== undefined) {
this.messageIDCounter = messageIDCounter
} else {
const userMessageLimit = await this.registry.getMessageLimit(this.identityCommitment)
this.messageIDCounter = new MemoryMessageIDCounter(userMessageLimit)
}
}
/**
* Set a custom cache
* @param cache The custom cache
*/
setCache(cache: ICache) {
this.cache = cache
}
/**
* Set a custom registry
* @param registry The custom registry
*/
setRegistry(registry: IRLNRegistry) {
this.registry = registry
}
/**
* Get the latest merkle root of the registry.
* @returns the latest merkle root of the registry
*/
async getMerkleRoot(): Promise<bigint> {
return this.registry.getMerkleRoot()
}
/**
* Get the identity commitment of the user.
*/
get identityCommitment(): bigint {
return this.identity.commitment
}
private get identitySecret(): bigint {
return calculateIdentitySecret(this.identity)
}
/**
* Get the rate commitment of the user, i.e. hash(identitySecret, messageLimit)
* @returns the rate commitment
*/
async getRateCommitment(): Promise<bigint> {
return this.registry.getRateCommitment(this.identityCommitment)
}
/**
* @returns the user has been registered or not
*/
async isRegistered(): Promise<boolean> {
return this.registry.isRegistered(this.identityCommitment)
}
/**
* @returns all rate commitments in the registry
*/
async getAllRateCommitments(): Promise<bigint[]> {
return this.registry.getAllRateCommitments()
}
/**
* User registers to the registry.
* @param userMessageLimit the maximum number of messages that the user can send in one epoch
* @param messageIDCounter the messageIDCounter that the user wants to use. If not provided, a new `MemoryMessageIDCounter` is created.
*/
async register(userMessageLimit: bigint, messageIDCounter?: IMessageIDCounter) {
if (userMessageLimit <= BigInt(0) || userMessageLimit > MAX_MESSAGE_LIMIT) {
throw new Error(
`userMessageLimit must be in range (0, ${MAX_MESSAGE_LIMIT}]. Got ${userMessageLimit}.`,
)
}
await this.registry.register(this.identityCommitment, userMessageLimit)
this.messageIDCounter = messageIDCounter ? messageIDCounter : new MemoryMessageIDCounter(userMessageLimit)
}
/**
* User withdraws from the registry. User will not receive the funds immediately,
* they need to wait `freezePeriod + 1` blocks and call `releaseWithdrawal` to get the funds.
*/
async withdraw() {
await this.registry.withdraw(this.identitySecret)
}
/**
* Release the funds from the pending withdrawal requested by `withdraw`.
*/
async releaseWithdrawal() {
await this.registry.releaseWithdrawal(this.identityCommitment)
this.messageIDCounter = undefined
}
/**
* Slash a user by its identity secret.
* @param secretToBeSlashed the identity secret of the user to be slashed
* @param receiver the receiver of the slashed funds. If not provided, the funds will be sent to
* the `signer` given in the constructor.
*/
async slash(secretToBeSlashed: bigint, receiver?: string) {
await this.registry.slash(secretToBeSlashed, receiver)
}
/**
* Create a proof for the given epoch and message.
* @param epoch the epoch to create the proof for
* @param message the message to create the proof for
* @returns the RLNFullProof
*/
async createProof(epoch: bigint, message: string): Promise<RLNFullProof> {
if (epoch < 0) {
throw new Error('epoch cannot be negative')
}
if (this.prover === undefined) {
throw new Error('Prover is not initialized')
}
if (!await this.isRegistered()) {
throw new Error('User has not registered before')
}
if (this.messageIDCounter === undefined) {
throw new Error(
'State is not synced with the registry. ' +
'If user is currently registered, `messageIDCounter` should be non-undefined',
)
}
const merkleProof = await this.registry.generateMerkleProof(this.identityCommitment)
// NOTE: get the message id and increment the counter.
// Even if the message is not sent, the counter is still incremented.
// It's intended to avoid any possibly for user to reuse the same message id.
const messageId = await this.messageIDCounter.getMessageIDAndIncrement(epoch)
const userMessageLimit = await this.registry.getMessageLimit(this.identityCommitment)
const proof = await this.prover.generateProof({
rlnIdentifier: this.rlnIdentifier,
identitySecret: this.identitySecret,
userMessageLimit: userMessageLimit,
messageId,
merkleProof,
x: calculateSignalHash(message),
epoch,
})
// Double check if the proof will spam or not using the cache.
// Even if messageIDCounter is used, it is possible that the user restart and the counter is reset.
const res = await this.checkProof(proof)
if (res.status === Status.DUPLICATE) {
throw new Error('Proof has been generated before')
} else if (res.status === Status.BREACH) {
throw new Error('Proof will spam')
} else if (res.status === Status.VALID) {
const resSaveProof = await this.saveProof(proof)
if (resSaveProof.status !== res.status) {
// Sanity check
throw new Error('Status of save proof and check proof mismatch')
}
return proof
} else {
// Sanity check
throw new Error('Unknown status')
}
}
/**
* Verify a proof is valid and indeed for `epoch` and `message`.
* @param epoch the epoch to verify the proof for
* @param message the message to verify the proof for
* @param proof the RLNFullProof to verify
* @returns true if the proof is valid, false otherwise
*/
async verifyProof(epoch: bigint, message: string, proof: RLNFullProof): Promise<boolean> {
if (epoch < BigInt(0)) {
throw new Error('epoch cannot be negative')
}
if (this.verifier === undefined) {
throw new Error('Verifier is not initialized')
}
// Check if the proof is using the same parameters
const snarkProof = proof.snarkProof
const epochInProof = proof.epoch
const rlnIdentifier = proof.rlnIdentifier
const { root, x } = snarkProof.publicSignals
// Check if the proof is using the same rlnIdentifier
if (BigInt(rlnIdentifier) !== this.rlnIdentifier) {
return false
}
// Check if the proof is using the same epoch
if (BigInt(epochInProof) !== epoch) {
return false
}
// Check if the proof and message match
const messageToX = calculateSignalHash(message)
if (BigInt(x) !== messageToX) {
return false
}
// Check if the merkle root is the same as the registry's
const registryMerkleRoot = await this.registry.getMerkleRoot()
if (BigInt(root) !== registryMerkleRoot) {
return false
}
// Verify snark proof
return this.verifier.verifyProof(rlnIdentifier, proof)
}
/**
* Save a proof to the cache and check if it's a spam.
* @param proof the RLNFullProof to save and detect spam
* @returns result of the check. `status` could be status.VALID if the proof is not a spam or invalid.
* Otherwise, it will be status.DUPLICATE or status.BREACH.
*/
async saveProof(proof: RLNFullProof): Promise<EvaluatedProof> {
const { snarkProof, epoch } = proof
const { x, y, nullifier } = snarkProof.publicSignals
return this.cache.addProof({ x, y, nullifier, epoch })
}
private async checkProof(proof: RLNFullProof): Promise<EvaluatedProof> {
const { snarkProof, epoch } = proof
const { x, y, nullifier } = snarkProof.publicSignals
return this.cache.checkProof({ x, y, nullifier, epoch })
}
/**
* Clean up the worker threads used by the prover and verifier in snarkjs
* This function should be called when the user is done with the library
* and wants to clean up the worker threads.
*
* Ref: https://github.com/iden3/snarkjs/issues/152#issuecomment-1164821515
*/
static cleanUp() {
globalThis.curve_bn128.terminate()
}
}