diff --git a/src/index.ts b/src/index.ts index ae89bec..d0eed69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { } from "./constants.js"; import { createRLN } from "./create.js"; import { Keystore } from "./keystore/index.js"; +import { extractMetaMaskSigner } from "./metamask.js"; import { IdentityCredential, Proof, @@ -29,4 +30,5 @@ export { RLN_STORAGE_ABI, RLN_REGISTRY_ABI, SEPOLIA_CONTRACT, + extractMetaMaskSigner, }; diff --git a/src/keystore/index.ts b/src/keystore/index.ts index fc85b09..e5d4abc 100644 --- a/src/keystore/index.ts +++ b/src/keystore/index.ts @@ -1,3 +1,5 @@ import { Keystore } from "./keystore.js"; +import type { DecryptedCredentials, EncryptedCredentials } from "./types.js"; export { Keystore }; +export type { EncryptedCredentials, DecryptedCredentials }; diff --git a/src/keystore/keystore.spec.ts b/src/keystore/keystore.spec.ts index cd28060..896759d 100644 --- a/src/keystore/keystore.spec.ts +++ b/src/keystore/keystore.spec.ts @@ -172,8 +172,8 @@ describe("Keystore", () => { }); it("should fail to create store from invalid string", () => { - expect(Keystore.fromString("/asdq}")).to.eq(null); - expect(Keystore.fromString('{ "name": "it" }')).to.eq(null); + expect(Keystore.fromString("/asdq}")).to.eq(undefined); + expect(Keystore.fromString('{ "name": "it" }')).to.eq(undefined); }); it("shoud create store from valid string", async () => { @@ -308,6 +308,6 @@ describe("Keystore", () => { const store = Keystore.fromObject(NWAKU_KEYSTORE as any); const result = await store.readCredential("wrong-hash", "wrong-password"); - expect(result).to.eq(null); + expect(result).to.eq(undefined); }); }); diff --git a/src/keystore/keystore.ts b/src/keystore/keystore.ts index 4232aad..51714d4 100644 --- a/src/keystore/keystore.ts +++ b/src/keystore/keystore.ts @@ -14,12 +14,12 @@ import _ from "lodash"; import { v4 as uuidV4 } from "uuid"; import { buildBigIntFromUint8Array } from "../byte_utils.js"; -import type { IdentityCredential } from "../rln.js"; import { decryptEipKeystore, keccak256Checksum } from "./cipher.js"; import { isCredentialValid, isKeystoreValid } from "./schema_validator.js"; import type { Keccak256Hash, + KeystoreEntity, MembershipHash, MembershipInfo, Password, @@ -57,11 +57,6 @@ type KeystoreCreateOptions = { appIdentifier?: string; }; -type IdentityOptions = { - identity: IdentityCredential; - membership: MembershipInfo; -}; - export class Keystore { private data: NwakuKeystore; @@ -81,7 +76,9 @@ export class Keystore { return new Keystore(options); } - public static fromString(str: string): Keystore | null { + // 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); @@ -92,7 +89,7 @@ export class Keystore { return new Keystore(obj); } catch (err) { console.error("Cannot create Keystore from string:", err); - return null; + return; } } @@ -105,7 +102,7 @@ export class Keystore { } public async addCredential( - options: IdentityOptions, + options: KeystoreEntity, password: Password ): Promise { const membershipHash: MembershipHash = Keystore.computeMembershipHash( @@ -138,11 +135,11 @@ export class Keystore { public async readCredential( membershipHash: MembershipHash, password: Password - ): Promise { + ): Promise { const nwakuCredential = this.data.credentials[membershipHash]; if (!nwakuCredential) { - return null; + return; } const eipKeystore = Keystore.fromCredentialToEip(nwakuCredential); @@ -167,6 +164,14 @@ export class Keystore { 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; @@ -237,7 +242,7 @@ export class Keystore { private static fromBytesToIdentity( bytes: Uint8Array - ): null | IdentityOptions { + ): undefined | KeystoreEntity { try { const str = bytesToUtf8(bytes); const obj = JSON.parse(str); @@ -271,7 +276,7 @@ export class Keystore { }; } catch (err) { console.error("Cannot parse bytes to Nwaku Credentials:", err); - return null; + return; } } @@ -302,7 +307,7 @@ export class Keystore { // follows nwaku implementation // https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L98 - private static fromIdentityToBytes(options: IdentityOptions): Uint8Array { + private static fromIdentityToBytes(options: KeystoreEntity): Uint8Array { return utf8ToBytes( JSON.stringify({ treeIndex: options.membership.treeIndex, diff --git a/src/keystore/types.ts b/src/keystore/types.ts index 4873f97..417eb83 100644 --- a/src/keystore/types.ts +++ b/src/keystore/types.ts @@ -1,3 +1,5 @@ +import type { IdentityCredential } from "../rln.js"; + export type MembershipHash = string; export type Sha256Hash = string; export type Keccak256Hash = string; @@ -10,3 +12,25 @@ export type MembershipInfo = { 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; +}; diff --git a/src/metamask.ts b/src/metamask.ts index e171205..f35423d 100644 --- a/src/metamask.ts +++ b/src/metamask.ts @@ -1,15 +1,16 @@ import { ethers } from "ethers"; -export const extractMetaMaskAccount = - async (): Promise => { - const ethereum = (window as any).ethereum; +export const extractMetaMaskSigner = async (): Promise => { + const ethereum = (window as any).ethereum; - if (!ethereum) { - throw Error( - "Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask." - ); - } + if (!ethereum) { + throw Error( + "Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask." + ); + } - await ethereum.request({ method: "eth_requestAccounts" }); - return new ethers.providers.Web3Provider(ethereum, "any"); - }; + await ethereum.request({ method: "eth_requestAccounts" }); + const provider = new ethers.providers.Web3Provider(ethereum, "any"); + + return provider.getSigner(); +}; diff --git a/src/rln.ts b/src/rln.ts index f96bdc4..314c48d 100644 --- a/src/rln.ts +++ b/src/rln.ts @@ -1,12 +1,26 @@ +import { createDecoder, createEncoder } from "@waku/core"; import type { IRateLimitProof } from "@waku/interfaces"; +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 { buildBigIntFromUint8Array, writeUIntLE } from "./byte_utils.js"; +import type { RLNDecoder, RLNEncoder } from "./codec.js"; +import { createRLNDecoder, createRLNEncoder } from "./codec.js"; import { SEPOLIA_CONTRACT } from "./constants.js"; import { dateToEpoch, epochIntToBytes } from "./epoch.js"; -import { extractMetaMaskAccount } from "./metamask.js"; +import { Keystore } from "./keystore/index.js"; +import type { + DecryptedCredentials, + EncryptedCredentials, +} from "./keystore/index.js"; +import { Password } from "./keystore/types.js"; +import { extractMetaMaskSigner } from "./metamask.js"; import verificationKey from "./resources/verification_key.js"; import { RLNContract } from "./rln_contract.js"; import * as wc from "./witness_calculator.js"; @@ -164,34 +178,180 @@ export function sha256(input: Uint8Array): Uint8Array { type StartRLNOptions = { /** - * If not set - will extract MetaMask account and get provider from it. + * If not set - will extract MetaMask account and get signer from it. */ - provider?: ethers.providers.Provider; + 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 }; + export class RLNInstance { - private _contract: null | RLNContract = null; + private started = false; + private starting = false; + + private _contract: undefined | RLNContract; + private _signer: undefined | ethers.Signer; + + private keystore = Keystore.create(); + private _credentials: undefined | DecryptedCredentials; constructor( private zkRLN: number, private witnessCalculator: WitnessCalculator ) {} - public get contract(): null | RLNContract { + public get contract(): undefined | RLNContract { return this._contract; } - public async start(options: StartRLNOptions = {}): Promise { - const provider = options.provider || (await extractMetaMaskAccount()); - const registryAddress = options.registryAddress || SEPOLIA_CONTRACT.address; + public get signer(): undefined | ethers.Signer { + return this._signer; + } - this._contract = await RLNContract.init(this, { + public async start(options: StartRLNOptions = {}): Promise { + if (this.started || this.starting) { + return; + } + + this.starting = true; + + try { + const { signer, registryAddress } = await this.determineStartOptions( + options + ); + + 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 + ): Promise { + const credentials = await this.decryptCredentialsIfNeeded( + options.credentials + ); + + 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, - provider, + credentials: options.credentials, + }; + } + + private async decryptCredentialsIfNeeded( + credentials?: EncryptedCredentials | DecryptedCredentials + ): Promise { + if (!credentials) { + return; + } + + if ("identity" in credentials) { + this._credentials = credentials; + return credentials; + } + + const keystore = Keystore.fromString(credentials.keystore); + + if (!keystore) { + throw Error("Failed to start RLN: cannot read Keystore provided."); + } + + this.keystore = keystore; + this._credentials = await keystore.readCredential( + credentials.id, + credentials.password + ); + + return this._credentials; + } + + public async registerMembership( + options: RegisterMembershipOptions + ): Promise { + if (!this.contract) { + throw Error("RLN Contract is not initialized."); + } + + let identity = "identity" in options && options.identity; + + if ("signature" in options) { + identity = await this.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 { + this._credentials = await this.keystore?.readCredential(id, password); + } + + public createEncoder(options: WakuEncoderOptions): RLNEncoder { + if (!this._credentials) { + throw Error( + "Failed to create Encoder: missing RLN credentials. Use createRLNEncoder directly." + ); + } + + return createRLNEncoder({ + encoder: createEncoder(options), + rlnInstance: this, + index: this._credentials.membership.treeIndex, + credential: this._credentials.identity, + }); + } + + public createDecoder( + contentTopic: ContentTopic + ): RLNDecoder { + return createRLNDecoder({ + rlnInstance: this, + decoder: createDecoder(contentTopic), }); } diff --git a/src/rln_contract.spec.ts b/src/rln_contract.spec.ts index 7116e9b..5af6cfa 100644 --- a/src/rln_contract.spec.ts +++ b/src/rln_contract.spec.ts @@ -16,7 +16,7 @@ describe("RLN Contract abstraction", () => { const voidSigner = new ethers.VoidSigner(rln.SEPOLIA_CONTRACT.address); const rlnContract = new rln.RLNContract(rlnInstance, { registryAddress: rln.SEPOLIA_CONTRACT.address, - provider: voidSigner, + signer: voidSigner, }); rlnContract["storageContract"] = { @@ -32,7 +32,7 @@ describe("RLN Contract abstraction", () => { chai.expect(insertMemberSpy).to.have.been.called(); }); - it("should register a member by signature", async () => { + it("should register a member", async () => { const mockSignature = "0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c"; @@ -40,7 +40,7 @@ describe("RLN Contract abstraction", () => { const voidSigner = new ethers.VoidSigner(rln.SEPOLIA_CONTRACT.address); const rlnContract = new rln.RLNContract(rlnInstance, { registryAddress: rln.SEPOLIA_CONTRACT.address, - provider: voidSigner, + signer: voidSigner, }); rlnContract["storageIndex"] = 1; @@ -57,7 +57,9 @@ describe("RLN Contract abstraction", () => { "register(uint16,uint256)" ); - await rlnContract.registerWithSignature(rlnInstance, mockSignature); + const identity = + rlnInstance.generateSeededIdentityCredential(mockSignature); + await rlnContract.registerWithIdentity(identity); chai.expect(contractSpy).to.have.been.called(); }); diff --git a/src/rln_contract.ts b/src/rln_contract.ts index 6a53fa9..5570d77 100644 --- a/src/rln_contract.ts +++ b/src/rln_contract.ts @@ -3,7 +3,8 @@ import { ethers } from "ethers"; import { zeroPadLE } from "./byte_utils.js"; import { RLN_REGISTRY_ABI, RLN_STORAGE_ABI } from "./constants.js"; -import { IdentityCredential, RLNInstance } from "./rln.js"; +import type { DecryptedCredentials } from "./keystore/index.js"; +import { type IdentityCredential, RLNInstance } from "./rln.js"; import { MerkleRootTracker } from "./root_tracker.js"; type Member = { @@ -11,10 +12,10 @@ type Member = { index: ethers.BigNumber; }; -type Provider = ethers.Signer | ethers.providers.Provider; +type Signer = ethers.Signer; type RLNContractOptions = { - provider: Provider; + signer: Signer; registryAddress: string; }; @@ -47,7 +48,7 @@ export class RLNContract { ): Promise { const rlnContract = new RLNContract(rlnInstance, options); - await rlnContract.initStorageContract(options.provider); + await rlnContract.initStorageContract(options.signer); await rlnContract.fetchMembers(rlnInstance); rlnContract.subscribeToMembers(rlnInstance); @@ -56,20 +57,20 @@ export class RLNContract { constructor( rlnInstance: RLNInstance, - { registryAddress, provider }: RLNContractOptions + { registryAddress, signer }: RLNContractOptions ) { const initialRoot = rlnInstance.getMerkleRoot(); this.registryContract = new ethers.Contract( registryAddress, RLN_REGISTRY_ABI, - provider + signer ); this.merkleRootTracker = new MerkleRootTracker(5, initialRoot); } private async initStorageContract( - provider: Provider, + signer: Signer, options: RLNStorageOptions = {} ): Promise { const storageIndex = options?.storageIndex @@ -85,7 +86,7 @@ export class RLNContract { this.storageContract = new ethers.Contract( storageAddress, RLN_STORAGE_ABI, - provider + signer ); this._membersFilter = this.storageContract.filters.MemberRegistered(); @@ -207,19 +208,9 @@ export class RLNContract { ); } - public async registerWithSignature( - rlnInstance: RLNInstance, - signature: string - ): Promise { - const identityCredential = - await rlnInstance.generateSeededIdentityCredential(signature); - - return this.registerWithKey(identityCredential); - } - - public async registerWithKey( - credential: IdentityCredential - ): Promise { + public async registerWithIdentity( + identity: IdentityCredential + ): Promise { if (this.storageIndex === undefined) { throw Error( "Cannot register credential, no storage contract index found." @@ -228,7 +219,7 @@ export class RLNContract { const txRegisterResponse: ethers.ContractTransaction = await this.registryContract["register(uint16,uint256)"]( this.storageIndex, - credential.IDCommitmentBigInt, + identity.IDCommitmentBigInt, { gasLimit: 100000 } ); const txRegisterReceipt = await txRegisterResponse.wait(); @@ -245,9 +236,17 @@ export class RLNContract { memberRegistered.data ); + const network = await this.registryContract.provider.getNetwork(); + const address = this.registryContract.address; + const membershipId = decodedData.index.toNumber(); + return { - idCommitment: decodedData.idCommitment, - index: decodedData.index, + identity, + membership: { + address, + treeIndex: membershipId, + chainId: network.chainId, + }, }; }