From ca56fde14e1f659a02c2cf2c91be28458a27b0ca Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Tue, 24 Oct 2023 20:00:11 -0500 Subject: [PATCH 1/9] feat: add PPPP support --- package-lock.json | 16 +-- package.json | 4 +- src/Client.ts | 20 ++- src/Contacts.ts | 203 +++++++++++++++++++++++++++++ src/conversations/Conversation.ts | 28 +++- src/conversations/Conversations.ts | 8 ++ src/keystore/InMemoryKeystore.ts | 73 +++++++++++ src/keystore/encryption.ts | 22 ++++ src/keystore/interfaces.ts | 16 +++ src/keystore/rpcDefinitions.ts | 12 ++ src/utils/topic.ts | 4 + 11 files changed, 387 insertions(+), 19 deletions(-) create mode 100644 src/Contacts.ts diff --git a/package-lock.json b/package-lock.json index 49612d1ce..8c3543735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/ecies-bindings-wasm": "^0.1.5", - "@xmtp/proto": "^3.28.0-beta.1", + "@xmtp/ecies-bindings-wasm": "^0.1.7", + "@xmtp/proto": "^3.32.0", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", "ethers": "^5.5.3", @@ -4821,14 +4821,14 @@ } }, "node_modules/@xmtp/ecies-bindings-wasm": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@xmtp/ecies-bindings-wasm/-/ecies-bindings-wasm-0.1.5.tgz", - "integrity": "sha512-SBxBxQCX0weIIHgrVxZ0QmAX7bqjhBsheXtmcgiciC/ZyAB7UC0KsNx2xqYrIDE0YQm6WXRg90qsX7a30SrLfQ==" + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@xmtp/ecies-bindings-wasm/-/ecies-bindings-wasm-0.1.7.tgz", + "integrity": "sha512-+bwI5koXneyRLVUh9Mpm9Md7A1w8GdEKqPPEVhaszfWyGr1eSeMOnkLZ0JCXMxCirYJcmiC/aua96LiuAQpACQ==" }, "node_modules/@xmtp/proto": { - "version": "3.29.0", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.29.0.tgz", - "integrity": "sha512-+ibo+u6NwdzfLN3QEDMiNrnXd7eT1/+F2j5WWz3b4mk91wgn8lJ66fxFPwLTQs6AbaBBUmhO2cdpgIL/g4kvZg==", + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.32.0.tgz", + "integrity": "sha512-rA05O3CtGEsVl5cLr9qCFg08lKeNiWsJiAOt33I1u9hQPgzm4JOywnc6GJrJomwED3cZ1TsKTf/1sj3yCzaDIA==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", diff --git a/package.json b/package.json index dcf5a5e05..cd13c3648 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,8 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/ecies-bindings-wasm": "^0.1.5", - "@xmtp/proto": "^3.28.0-beta.1", + "@xmtp/ecies-bindings-wasm": "^0.1.7", + "@xmtp/proto": "^3.32.0", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", "ethers": "^5.5.3", diff --git a/src/Client.ts b/src/Client.ts index 50b233abf..3a33ff694 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -44,6 +44,7 @@ import { hasMetamaskWithSnaps } from './keystore/snapHelpers' import { version as snapVersion, package as snapPackage } from './snapInfo.json' import { ExtractDecodedType } from './types/client' import type { WalletClient } from 'viem' +import { Contacts } from './Contacts' const { Compression } = proto // eslint-disable @typescript-eslint/explicit-module-boundary-types @@ -194,6 +195,10 @@ export type PreEventCallbackOptions = { preEnableIdentityCallback?: PreEventCallback } +export type AllowListOptions = { + enableAllowList: boolean +} + /** * Aggregate type for client options. Optional properties are used when the default value is calculated on invocation, and are computed * as needed by each function. All other defaults are specified in defaultOptions. @@ -203,7 +208,8 @@ export type ClientOptions = Flatten< KeyStoreOptions & ContentOptions & LegacyOptions & - PreEventCallbackOptions + PreEventCallbackOptions & + AllowListOptions > /** @@ -226,6 +232,7 @@ export function defaultOptions(opts?: Partial): ClientOptions { disablePersistenceEncryption: false, keystoreProviders: defaultKeystoreProviders(), apiClientFactory: createHttpApiClientFromOptions, + enableAllowList: false, } if (opts?.codecs) { @@ -251,7 +258,7 @@ export default class Client { address: string keystore: Keystore apiClient: ApiClient - contacts: Set // address which we have connected to + contacts: Contacts publicKeyBundle: PublicKeyBundle private knownPublicKeyBundles: Map< string, @@ -263,14 +270,16 @@ export default class Client { // eslint-disable-next-line @typescript-eslint/no-explicit-any private _codecs: Map> private _maxContentSize: number + readonly _enableAllowList: boolean constructor( publicKeyBundle: PublicKeyBundle, apiClient: ApiClient, backupClient: BackupClient, - keystore: Keystore + keystore: Keystore, + enableAllowList: boolean = false ) { - this.contacts = new Set() + this.contacts = new Contacts(this) this.knownPublicKeyBundles = new Map< string, PublicKeyBundle | SignedPublicKeyBundle @@ -284,6 +293,7 @@ export default class Client { this._maxContentSize = MaxContentSize this.apiClient = apiClient this._backupClient = backupClient + this._enableAllowList = enableAllowList } /** @@ -328,7 +338,7 @@ export default class Client { const backupClient = await Client.setupBackupClient(address, options.env) const client = new Client< ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined - >(publicKeyBundle, apiClient, backupClient, keystore) + >(publicKeyBundle, apiClient, backupClient, keystore, opts?.enableAllowList) await client.init(options) return client } diff --git a/src/Contacts.ts b/src/Contacts.ts new file mode 100644 index 000000000..91c2cfd6a --- /dev/null +++ b/src/Contacts.ts @@ -0,0 +1,203 @@ +import { Envelope } from '@xmtp/proto/ts/dist/types/message_api/v1/message_api.pb' +import Client from './Client' +import { privatePreferences } from '@xmtp/proto' +import { buildUserPrivatePreferencesTopic } from './utils' +import { PublishParams } from './ApiClient' + +export type AllowListPermissionType = 'allow' | 'block' | 'unknown' + +export type AllowListEntryType = 'address' + +export class AllowListEntry { + value: string + entryType: AllowListEntryType + permissionType: AllowListPermissionType + + constructor( + value: string, + entryType: AllowListEntryType, + permissionType: AllowListPermissionType + ) { + this.value = value + this.entryType = entryType + this.permissionType = permissionType + } + + get key(): string { + return `${this.entryType}-${this.value}` + } + + static fromAddress( + address: string, + permissionType: AllowListPermissionType = 'unknown' + ): AllowListEntry { + return new AllowListEntry(address, 'address', permissionType) + } +} + +export class AllowList { + entries: Map + static _identifier: string + + constructor() { + this.entries = new Map() + } + + allow(address: string) { + const entry = AllowListEntry.fromAddress(address, 'allow') + this.entries.set(entry.key, 'allow') + return entry + } + + block(address: string) { + const entry = AllowListEntry.fromAddress(address, 'block') + this.entries.set(entry.key, 'block') + return entry + } + + state(address: string) { + const entry = AllowListEntry.fromAddress(address) + return this.entries.get(entry.key) ?? 'unknown' + } + + static async getIdentifier(client: Client): Promise { + if (!this._identifier) { + const { identifier } = + await client.keystore.getPrivatePreferencesTopicIdentifier() + this._identifier = identifier + } + return this._identifier + } + + static async load(client: Client): Promise { + const allowList = new AllowList() + const identifier = await this.getIdentifier(client) + const envelopes = ( + await client.listEnvelopes( + buildUserPrivatePreferencesTopic(identifier), + async ({ message }: Envelope) => { + if (message) { + const result = await client.keystore.selfDecrypt({ + requests: [{ payload: message }], + }) + const payload = result.responses[0].result?.decrypted + if (payload) { + return privatePreferences.PrivatePreferencesAction.decode(payload) + } + } + return undefined + } + ) + ).filter( + (envelope) => envelope !== undefined + ) as privatePreferences.PrivatePreferencesAction[] + + envelopes.forEach((envelope) => { + envelope.allow?.walletAddresses.forEach((address) => { + allowList.allow(address) + }) + envelope.block?.walletAddresses.forEach((address) => { + allowList.block(address) + }) + }) + + return allowList + } + + static async publish(entries: AllowListEntry[], client: Client) { + const identifier = await this.getIdentifier(client) + + // TODO: preserve order + const rawEnvelopes = await Promise.all( + entries.map(async (entry) => { + if (entry.entryType === 'address') { + const action: privatePreferences.PrivatePreferencesAction = { + allow: + entry.permissionType === 'allow' + ? { + walletAddresses: [entry.value], + } + : undefined, + block: + entry.permissionType === 'block' + ? { + walletAddresses: [entry.value], + } + : undefined, + } + const payload = + privatePreferences.PrivatePreferencesAction.encode(action).finish() + const result = await client.keystore.selfEncrypt({ + requests: [{ payload }], + }) + const message = result.responses[0].result?.encrypted + if (message) { + return { + contentTopic: buildUserPrivatePreferencesTopic(identifier), + message, + timestamp: new Date(), + } + } + } + return undefined + }) + ) + + const envelopes = rawEnvelopes.filter( + (envelope) => envelope !== undefined + ) as PublishParams[] + + await client.publishEnvelopes(envelopes) + } +} + +export class Contacts { + /** + * Addresses that the client has connected to + */ + addresses: Set + allowList: AllowList + client: Client + + constructor(client: Client) { + this.addresses = new Set() + this.allowList = new AllowList() + this.client = client + } + + async refreshAllowList() { + if (this.client._enableAllowList) { + this.allowList = await AllowList.load(this.client) + } + } + + isAllowed(address: string) { + return this.allowList.state(address) === 'allow' + } + + isBlocked(address: string) { + return this.allowList.state(address) === 'block' + } + + allowState(address: string) { + return this.allowList.state(address) + } + + async allow(addresses: string[]) { + if (this.client._enableAllowList) { + await AllowList.publish( + addresses.map((address) => this.allowList.allow(address)), + this.client + ) + } + } + + async block(addresses: string[]) { + if (this.client._enableAllowList) { + await AllowList.publish( + addresses.map((address) => this.allowList.block(address)), + this.client + ) + } + } +} diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 65fb7fccf..9042e902f 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -237,13 +237,13 @@ export class ConversationV1 const topic = options?.ephemeral ? this.ephemeralTopic : this.topic - if (!this.client.contacts.has(this.peerAddress)) { + if (!this.client.contacts.addresses.has(this.peerAddress)) { topics = [ buildUserIntroTopic(this.peerAddress), buildUserIntroTopic(this.client.address), topic, ] - this.client.contacts.add(this.peerAddress) + this.client.contacts.addresses.add(this.peerAddress) } else { topics = [topic] } @@ -345,13 +345,13 @@ export class ConversationV1 const topic = options?.ephemeral ? this.ephemeralTopic : this.topic - if (!this.client.contacts.has(this.peerAddress)) { + if (!this.client.contacts.addresses.has(this.peerAddress)) { topics = [ buildUserIntroTopic(this.peerAddress), buildUserIntroTopic(this.client.address), topic, ] - this.client.contacts.add(this.peerAddress) + this.client.contacts.addresses.add(this.peerAddress) } else { topics = [topic] } @@ -475,6 +475,26 @@ export class ConversationV2 return this.client.address } + async allow() { + await this.client.contacts.allow([this.peerAddress]) + } + + async block() { + await this.client.contacts.block([this.peerAddress]) + } + + get isAllowed() { + return this.client.contacts.isAllowed(this.peerAddress) + } + + get isBlocked() { + return this.client.contacts.isBlocked(this.peerAddress) + } + + get allowState() { + return this.client.contacts.allowState(this.peerAddress) + } + /** * Returns a list of all messages to/from the peerAddress */ diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index efabd1e86..b8661211b 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -49,6 +49,9 @@ export default class Conversations { this.listV2Conversations(), ]) + // fetch allow list if enabled + this.client.contacts.refreshAllowList() + const conversations = v1Convos.concat(v2Convos) conversations.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) @@ -567,6 +570,11 @@ export default class Conversations { }, ]) + // add peer address to allow list + if (this.client._enableAllowList) { + this.client.contacts.allow([peerAddress]) + } + return this.conversationReferenceToV2(conversation) } diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 1c773ec03..6fd6ff0a2 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -29,6 +29,9 @@ import { hmacSha256Sign } from '../crypto/ecies' import crypto from '../crypto/crypto' import { bytesToHex } from '../crypto/utils' import Long from 'long' +import { selfDecrypt, selfEncrypt } from '../keystore/encryption' +// eslint-disable-next-line camelcase +import { generate_private_preferences_topic } from '@xmtp/ecies-bindings-wasm' const { ErrorCode } = keystore @@ -197,6 +200,76 @@ export default class InMemoryKeystore implements Keystore { ) } + async selfEncrypt( + req: keystore.SelfEncryptRequest + ): Promise { + const responses = await mapAndConvertErrors( + req.requests, + async (req) => { + const { payload } = req + + if (!payload) { + throw new KeystoreError( + ErrorCode.ERROR_CODE_INVALID_INPUT, + 'Missing field payload' + ) + } + + const publicKey = + this.v1Keys.getPublicKeyBundle().preKey.secp256k1Uncompressed.bytes + const privateKey = this.v1Keys.identityKey.secp256k1.bytes + + return { + encrypted: await selfEncrypt(publicKey, privateKey, payload), + } + }, + ErrorCode.ERROR_CODE_INVALID_INPUT + ) + + return keystore.SelfEncryptResponse.fromPartial({ + responses, + }) + } + + async selfDecrypt( + req: keystore.SelfDecryptRequest + ): Promise { + const responses = await mapAndConvertErrors( + req.requests, + async (req) => { + const { payload } = req + + if (!payload) { + throw new KeystoreError( + ErrorCode.ERROR_CODE_INVALID_INPUT, + 'Missing field payload' + ) + } + + const publicKey = + this.v1Keys.getPublicKeyBundle().preKey.secp256k1Uncompressed.bytes + const privateKey = this.v1Keys.identityKey.secp256k1.bytes + + return { + decrypted: await selfDecrypt(publicKey, privateKey, payload), + } + }, + ErrorCode.ERROR_CODE_INVALID_INPUT + ) + + return keystore.DecryptResponse.fromPartial({ + responses, + }) + } + + async getPrivatePreferencesTopicIdentifier(): Promise { + const privateKey = this.v1Keys.identityKey.secp256k1.bytes + const identifier = generate_private_preferences_topic(privateKey).toString() + return keystore.GetPrivatePreferencesTopicIdentifierResponse.fromPartial({ + identifier, + }) + } + async encryptV2( req: keystore.EncryptV2Request ): Promise { diff --git a/src/keystore/encryption.ts b/src/keystore/encryption.ts index c0f6e5143..9d849accb 100644 --- a/src/keystore/encryption.ts +++ b/src/keystore/encryption.ts @@ -5,6 +5,12 @@ import { decrypt, } from '../crypto' import { ciphertext } from '@xmtp/proto' +import { + // eslint-disable-next-line camelcase + ecies_decrypt_k256_sha3_256, + // eslint-disable-next-line camelcase + ecies_encrypt_k256_sha3_256, +} from '@xmtp/ecies-bindings-wasm' export const decryptV1 = async ( myKeys: PrivateKeyBundleV1, @@ -48,3 +54,19 @@ export const encryptV2 = ( secret: Uint8Array, headerBytes: Uint8Array ) => encrypt(payload, secret, headerBytes) + +export async function selfEncrypt( + publicKey: Uint8Array, + privateKey: Uint8Array, + payload: Uint8Array +) { + return ecies_encrypt_k256_sha3_256(publicKey, privateKey, payload) +} + +export async function selfDecrypt( + publicKey: Uint8Array, + privateKey: Uint8Array, + payload: Uint8Array +) { + return ecies_decrypt_k256_sha3_256(publicKey, privateKey, payload) +} diff --git a/src/keystore/interfaces.ts b/src/keystore/interfaces.ts index 4e70e9b7b..15382f7b2 100644 --- a/src/keystore/interfaces.ts +++ b/src/keystore/interfaces.ts @@ -81,6 +81,22 @@ export interface Keystore { * Get the account address of the wallet used to create the Keystore */ getAccountAddress(): Promise + /** + * Encrypt a batch of messages to yourself + */ + selfEncrypt( + req: keystore.SelfEncryptRequest + ): Promise + /** + * Decrypt a batch of messages to yourself + */ + selfDecrypt( + req: keystore.SelfDecryptRequest + ): Promise + /** + * Get the private preferences topic identifier + */ + getPrivatePreferencesTopicIdentifier(): Promise } export type TopicData = WithoutUndefined diff --git a/src/keystore/rpcDefinitions.ts b/src/keystore/rpcDefinitions.ts index b362d3e7f..3d04d5208 100644 --- a/src/keystore/rpcDefinitions.ts +++ b/src/keystore/rpcDefinitions.ts @@ -73,4 +73,16 @@ export const apiDefs: ApiDefs = { req: keystore.SetRefeshJobRequest, res: keystore.SetRefreshJobResponse, }, + selfEncrypt: { + req: keystore.SelfEncryptRequest, + res: keystore.SelfEncryptResponse, + }, + selfDecrypt: { + req: keystore.SelfDecryptRequest, + res: keystore.DecryptResponse, + }, + getPrivatePreferencesTopicIdentifier: { + req: null, + res: keystore.GetPrivatePreferencesTopicIdentifierResponse, + }, } as const diff --git a/src/utils/topic.ts b/src/utils/topic.ts index 5290cbf51..125cca5b5 100644 --- a/src/utils/topic.ts +++ b/src/utils/topic.ts @@ -31,7 +31,11 @@ export const buildUserInviteTopic = (walletAddr: string): string => { // EIP55 normalize the address case. return buildContentTopic(`invite-${utils.getAddress(walletAddr)}`) } + export const buildUserPrivateStoreTopic = (addrPrefixedKey: string): string => { // e.g. "0x1111111111222222222233333333334444444444/key_bundle" return buildContentTopic(`privatestore-${addrPrefixedKey}`) } + +export const buildUserPrivatePreferencesTopic = (identifier: string) => + buildContentTopic(`pppp-${identifier}`) From 1ee84c063e642b3e64ed0ab3332d27974a7c301b Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 12:56:45 -0500 Subject: [PATCH 2/9] refactor: address feedback --- src/Client.ts | 6 ++- src/Contacts.ts | 132 ++++++++++++++++++++++++--------------------- src/utils/async.ts | 8 +++ 3 files changed, 82 insertions(+), 64 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 3a33ff694..d3c8334be 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -7,6 +7,8 @@ import { buildUserInviteTopic, isBrowser, getSigner, + EnvelopeMapperWithMessage, + EnvelopeWithMessage, } from './utils' import { utils } from 'ethers' import { Signer } from './types/Signer' @@ -711,7 +713,7 @@ export default class Client { */ async listEnvelopes( topic: string, - mapper: EnvelopeMapper, + mapper: EnvelopeMapperWithMessage, opts?: ListMessagesOptions ): Promise { if (!opts) { @@ -731,7 +733,7 @@ export default class Client { for (const env of envelopes) { if (!env.message) continue try { - const res = await mapper(env) + const res = await mapper(env as EnvelopeWithMessage) results.push(res) } catch (e) { console.warn('Error in listEnvelopes mapper', e) diff --git a/src/Contacts.ts b/src/Contacts.ts index 91c2cfd6a..1fcad44af 100644 --- a/src/Contacts.ts +++ b/src/Contacts.ts @@ -1,8 +1,6 @@ -import { Envelope } from '@xmtp/proto/ts/dist/types/message_api/v1/message_api.pb' import Client from './Client' import { privatePreferences } from '@xmtp/proto' -import { buildUserPrivatePreferencesTopic } from './utils' -import { PublishParams } from './ApiClient' +import { EnvelopeWithMessage, buildUserPrivatePreferencesTopic } from './utils' export type AllowListPermissionType = 'allow' | 'block' | 'unknown' @@ -72,31 +70,34 @@ export class AllowList { static async load(client: Client): Promise { const allowList = new AllowList() const identifier = await this.getIdentifier(client) - const envelopes = ( - await client.listEnvelopes( - buildUserPrivatePreferencesTopic(identifier), - async ({ message }: Envelope) => { - if (message) { - const result = await client.keystore.selfDecrypt({ - requests: [{ payload: message }], - }) - const payload = result.responses[0].result?.decrypted - if (payload) { - return privatePreferences.PrivatePreferencesAction.decode(payload) - } - } - return undefined - } - ) - ).filter( - (envelope) => envelope !== undefined - ) as privatePreferences.PrivatePreferencesAction[] + const contentTopic = buildUserPrivatePreferencesTopic(identifier) + + const messages = await client.listEnvelopes( + contentTopic, + async ({ message }: EnvelopeWithMessage) => message + ) + + // decrypt messages + const { responses } = await client.keystore.selfDecrypt({ + requests: messages.map((message) => ({ payload: message })), + }) - envelopes.forEach((envelope) => { - envelope.allow?.walletAddresses.forEach((address) => { + // decoded actions + const actions = responses.reduce((result, response) => { + return response.result?.decrypted + ? result.concat( + privatePreferences.PrivatePreferencesAction.decode( + response.result.decrypted + ) + ) + : result + }, [] as privatePreferences.PrivatePreferencesAction[]) + + actions.forEach((action) => { + action.allow?.walletAddresses.forEach((address) => { allowList.allow(address) }) - envelope.block?.walletAddresses.forEach((address) => { + action.block?.walletAddresses.forEach((address) => { allowList.block(address) }) }) @@ -107,45 +108,52 @@ export class AllowList { static async publish(entries: AllowListEntry[], client: Client) { const identifier = await this.getIdentifier(client) - // TODO: preserve order - const rawEnvelopes = await Promise.all( - entries.map(async (entry) => { - if (entry.entryType === 'address') { - const action: privatePreferences.PrivatePreferencesAction = { - allow: - entry.permissionType === 'allow' - ? { - walletAddresses: [entry.value], - } - : undefined, - block: - entry.permissionType === 'block' - ? { - walletAddresses: [entry.value], - } - : undefined, - } - const payload = - privatePreferences.PrivatePreferencesAction.encode(action).finish() - const result = await client.keystore.selfEncrypt({ - requests: [{ payload }], - }) - const message = result.responses[0].result?.encrypted - if (message) { - return { - contentTopic: buildUserPrivatePreferencesTopic(identifier), - message, - timestamp: new Date(), - } - } + // encoded actions + const actions = entries.reduce((result, entry) => { + if (entry.entryType === 'address') { + const action: privatePreferences.PrivatePreferencesAction = { + allow: + entry.permissionType === 'allow' + ? { + walletAddresses: [entry.value], + } + : undefined, + block: + entry.permissionType === 'block' + ? { + walletAddresses: [entry.value], + } + : undefined, } - return undefined - }) - ) + return result.concat( + privatePreferences.PrivatePreferencesAction.encode(action).finish() + ) + } + return result + }, [] as Uint8Array[]) + + const payloads = actions.map((action) => ({ payload: action })) + + const { responses } = await client.keystore.selfEncrypt({ + requests: payloads, + }) - const envelopes = rawEnvelopes.filter( - (envelope) => envelope !== undefined - ) as PublishParams[] + // encrypted messages + const messages = responses.reduce((result, response) => { + return response.result?.encrypted + ? result.concat(response.result?.encrypted) + : result + }, [] as Uint8Array[]) + + const contentTopic = buildUserPrivatePreferencesTopic(identifier) + const timestamp = new Date() + + // envelopes to publish + const envelopes = messages.map((message) => ({ + contentTopic, + message, + timestamp, + })) await client.publishEnvelopes(envelopes) } diff --git a/src/utils/async.ts b/src/utils/async.ts index 9bb98bd23..38242a5e2 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -1,4 +1,5 @@ import { messageApi } from '@xmtp/proto' +import { Flatten } from './typedefs' export type IsRetryable = (err?: Error) => boolean @@ -49,6 +50,13 @@ export async function retry any>( } } +export type EnvelopeWithMessage = Flatten< + messageApi.Envelope & Required> +> +export type EnvelopeMapperWithMessage = ( + env: EnvelopeWithMessage +) => Promise + export type EnvelopeMapper = (env: messageApi.Envelope) => Promise // Takes an async generator returning pages of envelopes and converts to an async From d1217ff4f087fc39a74fb97c53110e5d0a474bb4 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 13:59:13 -0500 Subject: [PATCH 3/9] refactor: update naming --- src/Client.ts | 22 +++++--- src/Contacts.ts | 86 +++++++++++++++--------------- src/conversations/Conversation.ts | 4 +- src/conversations/Conversations.ts | 4 +- 4 files changed, 60 insertions(+), 56 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index d3c8334be..cd336cbe8 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -197,8 +197,8 @@ export type PreEventCallbackOptions = { preEnableIdentityCallback?: PreEventCallback } -export type AllowListOptions = { - enableAllowList: boolean +export type ConsentListOptions = { + enableConsentList: boolean } /** @@ -211,7 +211,7 @@ export type ClientOptions = Flatten< ContentOptions & LegacyOptions & PreEventCallbackOptions & - AllowListOptions + ConsentListOptions > /** @@ -234,7 +234,7 @@ export function defaultOptions(opts?: Partial): ClientOptions { disablePersistenceEncryption: false, keystoreProviders: defaultKeystoreProviders(), apiClientFactory: createHttpApiClientFromOptions, - enableAllowList: false, + enableConsentList: false, } if (opts?.codecs) { @@ -272,14 +272,14 @@ export default class Client { // eslint-disable-next-line @typescript-eslint/no-explicit-any private _codecs: Map> private _maxContentSize: number - readonly _enableAllowList: boolean + readonly _enableConsentList: boolean constructor( publicKeyBundle: PublicKeyBundle, apiClient: ApiClient, backupClient: BackupClient, keystore: Keystore, - enableAllowList: boolean = false + enableConsentList: boolean = false ) { this.contacts = new Contacts(this) this.knownPublicKeyBundles = new Map< @@ -295,7 +295,7 @@ export default class Client { this._maxContentSize = MaxContentSize this.apiClient = apiClient this._backupClient = backupClient - this._enableAllowList = enableAllowList + this._enableConsentList = enableConsentList } /** @@ -340,7 +340,13 @@ export default class Client { const backupClient = await Client.setupBackupClient(address, options.env) const client = new Client< ExtractDecodedType<[...ContentCodecs, TextCodec][number]> | undefined - >(publicKeyBundle, apiClient, backupClient, keystore, opts?.enableAllowList) + >( + publicKeyBundle, + apiClient, + backupClient, + keystore, + opts?.enableConsentList + ) await client.init(options) return client } diff --git a/src/Contacts.ts b/src/Contacts.ts index 1fcad44af..01fe4196f 100644 --- a/src/Contacts.ts +++ b/src/Contacts.ts @@ -2,19 +2,19 @@ import Client from './Client' import { privatePreferences } from '@xmtp/proto' import { EnvelopeWithMessage, buildUserPrivatePreferencesTopic } from './utils' -export type AllowListPermissionType = 'allow' | 'block' | 'unknown' +export type ConsentState = 'allowed' | 'blocked' | 'unknown' -export type AllowListEntryType = 'address' +export type ConsentListEntryType = 'address' -export class AllowListEntry { +export class ConsentListEntry { value: string - entryType: AllowListEntryType - permissionType: AllowListPermissionType + entryType: ConsentListEntryType + permissionType: ConsentState constructor( value: string, - entryType: AllowListEntryType, - permissionType: AllowListPermissionType + entryType: ConsentListEntryType, + permissionType: ConsentState ) { this.value = value this.entryType = entryType @@ -27,34 +27,34 @@ export class AllowListEntry { static fromAddress( address: string, - permissionType: AllowListPermissionType = 'unknown' - ): AllowListEntry { - return new AllowListEntry(address, 'address', permissionType) + permissionType: ConsentState = 'unknown' + ): ConsentListEntry { + return new ConsentListEntry(address, 'address', permissionType) } } -export class AllowList { - entries: Map +export class ConsentList { + entries: Map static _identifier: string constructor() { - this.entries = new Map() + this.entries = new Map() } allow(address: string) { - const entry = AllowListEntry.fromAddress(address, 'allow') - this.entries.set(entry.key, 'allow') + const entry = ConsentListEntry.fromAddress(address, 'allowed') + this.entries.set(entry.key, 'allowed') return entry } block(address: string) { - const entry = AllowListEntry.fromAddress(address, 'block') - this.entries.set(entry.key, 'block') + const entry = ConsentListEntry.fromAddress(address, 'blocked') + this.entries.set(entry.key, 'blocked') return entry } state(address: string) { - const entry = AllowListEntry.fromAddress(address) + const entry = ConsentListEntry.fromAddress(address) return this.entries.get(entry.key) ?? 'unknown' } @@ -67,8 +67,8 @@ export class AllowList { return this._identifier } - static async load(client: Client): Promise { - const allowList = new AllowList() + static async load(client: Client): Promise { + const consentList = new ConsentList() const identifier = await this.getIdentifier(client) const contentTopic = buildUserPrivatePreferencesTopic(identifier) @@ -95,17 +95,17 @@ export class AllowList { actions.forEach((action) => { action.allow?.walletAddresses.forEach((address) => { - allowList.allow(address) + consentList.allow(address) }) action.block?.walletAddresses.forEach((address) => { - allowList.block(address) + consentList.block(address) }) }) - return allowList + return consentList } - static async publish(entries: AllowListEntry[], client: Client) { + static async publish(entries: ConsentListEntry[], client: Client) { const identifier = await this.getIdentifier(client) // encoded actions @@ -113,13 +113,13 @@ export class AllowList { if (entry.entryType === 'address') { const action: privatePreferences.PrivatePreferencesAction = { allow: - entry.permissionType === 'allow' + entry.permissionType === 'allowed' ? { walletAddresses: [entry.value], } : undefined, block: - entry.permissionType === 'block' + entry.permissionType === 'blocked' ? { walletAddresses: [entry.value], } @@ -132,10 +132,8 @@ export class AllowList { return result }, [] as Uint8Array[]) - const payloads = actions.map((action) => ({ payload: action })) - const { responses } = await client.keystore.selfEncrypt({ - requests: payloads, + requests: actions.map((action) => ({ payload: action })), }) // encrypted messages @@ -164,46 +162,46 @@ export class Contacts { * Addresses that the client has connected to */ addresses: Set - allowList: AllowList + consentList: ConsentList client: Client constructor(client: Client) { this.addresses = new Set() - this.allowList = new AllowList() + this.consentList = new ConsentList() this.client = client } - async refreshAllowList() { - if (this.client._enableAllowList) { - this.allowList = await AllowList.load(this.client) + async refreshConsentList() { + if (this.client._enableConsentList) { + this.consentList = await ConsentList.load(this.client) } } isAllowed(address: string) { - return this.allowList.state(address) === 'allow' + return this.consentList.state(address) === 'allowed' } isBlocked(address: string) { - return this.allowList.state(address) === 'block' + return this.consentList.state(address) === 'blocked' } - allowState(address: string) { - return this.allowList.state(address) + consentState(address: string) { + return this.consentList.state(address) } async allow(addresses: string[]) { - if (this.client._enableAllowList) { - await AllowList.publish( - addresses.map((address) => this.allowList.allow(address)), + if (this.client._enableConsentList) { + await ConsentList.publish( + addresses.map((address) => this.consentList.allow(address)), this.client ) } } async block(addresses: string[]) { - if (this.client._enableAllowList) { - await AllowList.publish( - addresses.map((address) => this.allowList.block(address)), + if (this.client._enableConsentList) { + await ConsentList.publish( + addresses.map((address) => this.consentList.block(address)), this.client ) } diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 9042e902f..82c4e5e2e 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -491,8 +491,8 @@ export class ConversationV2 return this.client.contacts.isBlocked(this.peerAddress) } - get allowState() { - return this.client.contacts.allowState(this.peerAddress) + get consentState() { + return this.client.contacts.consentState(this.peerAddress) } /** diff --git a/src/conversations/Conversations.ts b/src/conversations/Conversations.ts index b8661211b..74433ed02 100644 --- a/src/conversations/Conversations.ts +++ b/src/conversations/Conversations.ts @@ -50,7 +50,7 @@ export default class Conversations { ]) // fetch allow list if enabled - this.client.contacts.refreshAllowList() + this.client.contacts.refreshConsentList() const conversations = v1Convos.concat(v2Convos) @@ -571,7 +571,7 @@ export default class Conversations { ]) // add peer address to allow list - if (this.client._enableAllowList) { + if (this.client._enableConsentList) { this.client.contacts.allow([peerAddress]) } From 2a417833d9ab21043046a6975e3a8173d14a0599 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 15:37:10 -0500 Subject: [PATCH 4/9] fix: fix encryption keys --- src/keystore/InMemoryKeystore.ts | 12 ++---------- src/keystore/encryption.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/keystore/InMemoryKeystore.ts b/src/keystore/InMemoryKeystore.ts index 6fd6ff0a2..174c59ddf 100644 --- a/src/keystore/InMemoryKeystore.ts +++ b/src/keystore/InMemoryKeystore.ts @@ -215,12 +215,8 @@ export default class InMemoryKeystore implements Keystore { ) } - const publicKey = - this.v1Keys.getPublicKeyBundle().preKey.secp256k1Uncompressed.bytes - const privateKey = this.v1Keys.identityKey.secp256k1.bytes - return { - encrypted: await selfEncrypt(publicKey, privateKey, payload), + encrypted: await selfEncrypt(this.v1Keys.identityKey, payload), } }, ErrorCode.ERROR_CODE_INVALID_INPUT @@ -246,12 +242,8 @@ export default class InMemoryKeystore implements Keystore { ) } - const publicKey = - this.v1Keys.getPublicKeyBundle().preKey.secp256k1Uncompressed.bytes - const privateKey = this.v1Keys.identityKey.secp256k1.bytes - return { - decrypted: await selfDecrypt(publicKey, privateKey, payload), + decrypted: await selfDecrypt(this.v1Keys.identityKey, payload), } }, ErrorCode.ERROR_CODE_INVALID_INPUT diff --git a/src/keystore/encryption.ts b/src/keystore/encryption.ts index 9d849accb..15d349bed 100644 --- a/src/keystore/encryption.ts +++ b/src/keystore/encryption.ts @@ -3,6 +3,7 @@ import { encrypt, PrivateKeyBundleV1, decrypt, + PrivateKey, } from '../crypto' import { ciphertext } from '@xmtp/proto' import { @@ -56,17 +57,19 @@ export const encryptV2 = ( ) => encrypt(payload, secret, headerBytes) export async function selfEncrypt( - publicKey: Uint8Array, - privateKey: Uint8Array, + identityKey: PrivateKey, payload: Uint8Array ) { + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes return ecies_encrypt_k256_sha3_256(publicKey, privateKey, payload) } export async function selfDecrypt( - publicKey: Uint8Array, - privateKey: Uint8Array, + identityKey: PrivateKey, payload: Uint8Array ) { + const publicKey = identityKey.publicKey.secp256k1Uncompressed.bytes + const privateKey = identityKey.secp256k1.bytes return ecies_decrypt_k256_sha3_256(publicKey, privateKey, payload) } From 559900d9f2b4431ae55c295031a4142d0d888781 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 15:38:59 -0500 Subject: [PATCH 5/9] test: add tests --- test/Contacts.test.ts | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 test/Contacts.test.ts diff --git a/test/Contacts.test.ts b/test/Contacts.test.ts new file mode 100644 index 000000000..51fa81519 --- /dev/null +++ b/test/Contacts.test.ts @@ -0,0 +1,70 @@ +import Client from '../src/Client' +import { Contacts } from '../src/Contacts' +import { newWallet } from './helpers' + +const alice = newWallet() +const bob = newWallet() +const carol = newWallet() + +let aliceClient: Client +let bobClient: Client +let carolClient: Client + +describe('Contacts', () => { + beforeAll(async () => { + aliceClient = await Client.create(alice, { + env: 'local', + enableConsentList: true, + }) + bobClient = await Client.create(bob, { + env: 'local', + enableConsentList: true, + }) + carolClient = await Client.create(carol, { + env: 'local', + enableConsentList: true, + }) + }) + + it('should initialize with client', async () => { + expect(aliceClient.contacts).toBeInstanceOf(Contacts) + expect(aliceClient.contacts.addresses).toBeInstanceOf(Set) + expect(Array.from(aliceClient.contacts.addresses.keys()).length).toBe(0) + }) + + it('should allow and block addresses', async () => { + await aliceClient.contacts.allow([bob.address]) + expect(aliceClient.contacts.consentState(bob.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(bob.address)).toBe(true) + expect(aliceClient.contacts.isBlocked(bob.address)).toBe(false) + + await aliceClient.contacts.block([carol.address]) + expect(aliceClient.contacts.consentState(carol.address)).toBe('blocked') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(false) + expect(aliceClient.contacts.isBlocked(carol.address)).toBe(true) + + await aliceClient.contacts.block([bob.address]) + expect(aliceClient.contacts.consentState(bob.address)).toBe('blocked') + expect(aliceClient.contacts.isAllowed(bob.address)).toBe(false) + expect(aliceClient.contacts.isBlocked(bob.address)).toBe(true) + }) + + it('should retrieve consent state', async () => { + aliceClient = await Client.create(alice, { + env: 'local', + enableConsentList: true, + }) + + expect(aliceClient.contacts.consentState(bob.address)).toBe('unknown') + expect(aliceClient.contacts.consentState(carol.address)).toBe('unknown') + + await aliceClient.contacts.refreshConsentList() + + expect(aliceClient.contacts.consentState(carol.address)).toBe('blocked') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(false) + expect(aliceClient.contacts.isBlocked(carol.address)).toBe(true) + expect(aliceClient.contacts.consentState(bob.address)).toBe('blocked') + expect(aliceClient.contacts.isAllowed(bob.address)).toBe(false) + expect(aliceClient.contacts.isBlocked(bob.address)).toBe(true) + }) +}) From a06ce531db0e06320124defc445d37f4459e5820 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 16:08:40 -0500 Subject: [PATCH 6/9] refactor: add Conversation types --- src/conversations/Conversation.ts | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index 82c4e5e2e..b1a16ce48 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -32,6 +32,7 @@ import { PreparedMessage } from '../PreparedMessage' import { sha256 } from '../crypto/encryption' import { buildDecryptV1Request, getResultOrThrow } from '../utils/keystore' import { ContentTypeText } from '../codecs/Text' +import { ConsentState } from '../Contacts' /** * Conversation represents either a V1 or V2 conversation with a common set of methods. @@ -66,6 +67,28 @@ export interface Conversation { */ context?: InvitationContext | undefined + /** + * Add conversation peer address to allow list + */ + allow(): Promise + /** + * Add conversation peer address to block list + */ + block(): Promise + + /** + * Returns true if conversation peer address is on the allow list + */ + isAllowed: boolean + /** + * Returns true if conversation peer address is on the block list + */ + isBlocked: boolean + /** + * Returns the consent state of the conversation peer address + */ + consentState: ConsentState + /** * Retrieve messages in this conversation. Default to returning all messages. * @@ -164,6 +187,26 @@ export class ConversationV1 return this.client.address } + async allow() { + await this.client.contacts.allow([this.peerAddress]) + } + + async block() { + await this.client.contacts.block([this.peerAddress]) + } + + get isAllowed() { + return this.client.contacts.isAllowed(this.peerAddress) + } + + get isBlocked() { + return this.client.contacts.isBlocked(this.peerAddress) + } + + get consentState() { + return this.client.contacts.consentState(this.peerAddress) + } + get topic(): string { return buildDirectMessageTopic(this.peerAddress, this.client.address) } From 79a3dc0888a5126613e2a6a006ec52937b25c8c9 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 16:09:18 -0500 Subject: [PATCH 7/9] test: add more tests --- test/Contacts.test.ts | 57 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/test/Contacts.test.ts b/test/Contacts.test.ts index 51fa81519..5c3335dbf 100644 --- a/test/Contacts.test.ts +++ b/test/Contacts.test.ts @@ -11,7 +11,7 @@ let bobClient: Client let carolClient: Client describe('Contacts', () => { - beforeAll(async () => { + beforeEach(async () => { aliceClient = await Client.create(alice, { env: 'local', enableConsentList: true, @@ -38,18 +38,56 @@ describe('Contacts', () => { expect(aliceClient.contacts.isAllowed(bob.address)).toBe(true) expect(aliceClient.contacts.isBlocked(bob.address)).toBe(false) - await aliceClient.contacts.block([carol.address]) - expect(aliceClient.contacts.consentState(carol.address)).toBe('blocked') - expect(aliceClient.contacts.isAllowed(carol.address)).toBe(false) - expect(aliceClient.contacts.isBlocked(carol.address)).toBe(true) - await aliceClient.contacts.block([bob.address]) expect(aliceClient.contacts.consentState(bob.address)).toBe('blocked') expect(aliceClient.contacts.isAllowed(bob.address)).toBe(false) expect(aliceClient.contacts.isBlocked(bob.address)).toBe(true) }) + it('should allow an address when a conversation is started', async () => { + const conversation = await aliceClient.conversations.newConversation( + carol.address + ) + + expect(aliceClient.contacts.consentState(carol.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(true) + expect(aliceClient.contacts.isBlocked(carol.address)).toBe(false) + + expect(conversation.isAllowed).toBe(true) + expect(conversation.isBlocked).toBe(false) + expect(conversation.consentState).toBe('allowed') + }) + + it('should allow or block an address from a conversation', async () => { + const conversation = await aliceClient.conversations.newConversation( + carol.address + ) + + await conversation.block() + + expect(aliceClient.contacts.consentState(carol.address)).toBe('blocked') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(false) + expect(aliceClient.contacts.isBlocked(carol.address)).toBe(true) + + expect(conversation.isAllowed).toBe(false) + expect(conversation.isBlocked).toBe(true) + expect(conversation.consentState).toBe('blocked') + + await conversation.allow() + + expect(aliceClient.contacts.consentState(carol.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(true) + expect(aliceClient.contacts.isBlocked(carol.address)).toBe(false) + + expect(conversation.isAllowed).toBe(true) + expect(conversation.isBlocked).toBe(false) + expect(conversation.consentState).toBe('allowed') + }) + it('should retrieve consent state', async () => { + await aliceClient.contacts.block([bob.address]) + await aliceClient.contacts.allow([carol.address]) + aliceClient = await Client.create(alice, { env: 'local', enableConsentList: true, @@ -60,11 +98,12 @@ describe('Contacts', () => { await aliceClient.contacts.refreshConsentList() - expect(aliceClient.contacts.consentState(carol.address)).toBe('blocked') - expect(aliceClient.contacts.isAllowed(carol.address)).toBe(false) - expect(aliceClient.contacts.isBlocked(carol.address)).toBe(true) expect(aliceClient.contacts.consentState(bob.address)).toBe('blocked') expect(aliceClient.contacts.isAllowed(bob.address)).toBe(false) expect(aliceClient.contacts.isBlocked(bob.address)).toBe(true) + + expect(aliceClient.contacts.consentState(carol.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(true) + expect(aliceClient.contacts.isBlocked(carol.address)).toBe(false) }) }) From 4d1407f811da308c33b2693251ab4558e0825bce Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 16:11:51 -0500 Subject: [PATCH 8/9] refactor: make consentList private --- src/Contacts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Contacts.ts b/src/Contacts.ts index 01fe4196f..74bfa6d7f 100644 --- a/src/Contacts.ts +++ b/src/Contacts.ts @@ -162,7 +162,7 @@ export class Contacts { * Addresses that the client has connected to */ addresses: Set - consentList: ConsentList + private consentList: ConsentList client: Client constructor(client: Client) { From 0eefdb5be9bb082181b7e7bbbc2edd350552b812 Mon Sep 17 00:00:00 2001 From: Ry Racherbaumer Date: Thu, 26 Oct 2023 16:27:11 -0500 Subject: [PATCH 9/9] test: update test to confirm ordering --- test/Contacts.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/Contacts.test.ts b/test/Contacts.test.ts index 5c3335dbf..c19839d0b 100644 --- a/test/Contacts.test.ts +++ b/test/Contacts.test.ts @@ -87,6 +87,18 @@ describe('Contacts', () => { it('should retrieve consent state', async () => { await aliceClient.contacts.block([bob.address]) await aliceClient.contacts.allow([carol.address]) + await aliceClient.contacts.allow([bob.address]) + await aliceClient.contacts.block([carol.address]) + await aliceClient.contacts.block([bob.address]) + await aliceClient.contacts.allow([carol.address]) + + expect(aliceClient.contacts.consentState(bob.address)).toBe('blocked') + expect(aliceClient.contacts.isAllowed(bob.address)).toBe(false) + expect(aliceClient.contacts.isBlocked(bob.address)).toBe(true) + + expect(aliceClient.contacts.consentState(carol.address)).toBe('allowed') + expect(aliceClient.contacts.isAllowed(carol.address)).toBe(true) + expect(aliceClient.contacts.isBlocked(carol.address)).toBe(false) aliceClient = await Client.create(alice, { env: 'local',