Skip to content

Commit

Permalink
feat: add PPPP support
Browse files Browse the repository at this point in the history
  • Loading branch information
rygine committed Oct 26, 2023
1 parent d9bdfc6 commit cbd9f2b
Show file tree
Hide file tree
Showing 11 changed files with 389 additions and 14 deletions.
14 changes: 10 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@
},
"dependencies": {
"@noble/secp256k1": "^1.5.2",
"@xmtp/proto": "^3.29.0",
"@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",
Expand Down
20 changes: 15 additions & 5 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -203,7 +208,8 @@ export type ClientOptions = Flatten<
KeyStoreOptions &
ContentOptions &
LegacyOptions &
PreEventCallbackOptions
PreEventCallbackOptions &
AllowListOptions
>

/**
Expand All @@ -226,6 +232,7 @@ export function defaultOptions(opts?: Partial<ClientOptions>): ClientOptions {
disablePersistenceEncryption: false,
keystoreProviders: defaultKeystoreProviders(),
apiClientFactory: createHttpApiClientFromOptions,
enableAllowList: false,
}

if (opts?.codecs) {
Expand All @@ -251,7 +258,7 @@ export default class Client<ContentTypes = any> {
address: string
keystore: Keystore
apiClient: ApiClient
contacts: Set<string> // address which we have connected to
contacts: Contacts
publicKeyBundle: PublicKeyBundle
private knownPublicKeyBundles: Map<
string,
Expand All @@ -263,14 +270,16 @@ export default class Client<ContentTypes = any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private _codecs: Map<string, ContentCodec<any>>
private _maxContentSize: number
readonly _enableAllowList: boolean

constructor(
publicKeyBundle: PublicKeyBundle,
apiClient: ApiClient,
backupClient: BackupClient,
keystore: Keystore
keystore: Keystore,
enableAllowList: boolean = false
) {
this.contacts = new Set<string>()
this.contacts = new Contacts(this)
this.knownPublicKeyBundles = new Map<
string,
PublicKeyBundle | SignedPublicKeyBundle
Expand All @@ -284,6 +293,7 @@ export default class Client<ContentTypes = any> {
this._maxContentSize = MaxContentSize
this.apiClient = apiClient
this._backupClient = backupClient
this._enableAllowList = enableAllowList
}

/**
Expand Down Expand Up @@ -328,7 +338,7 @@ export default class Client<ContentTypes = any> {
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
}
Expand Down
203 changes: 203 additions & 0 deletions src/Contacts.ts
Original file line number Diff line number Diff line change
@@ -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<string, AllowListPermissionType>
static _identifier: string

constructor() {
this.entries = new Map<string, AllowListPermissionType>()
}

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<string> {
if (!this._identifier) {
const { identifier } =
await client.keystore.getPrivatePreferencesTopicIdentifier()
this._identifier = identifier
}
return this._identifier
}

static async load(client: Client): Promise<AllowList> {
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<string>
allowList: AllowList
client: Client

constructor(client: Client) {
this.addresses = new Set<string>()
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
)
}
}
}
28 changes: 24 additions & 4 deletions src/conversations/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,13 @@ export class ConversationV1<ContentTypes>

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]
}
Expand Down Expand Up @@ -345,13 +345,13 @@ export class ConversationV1<ContentTypes>

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]
}
Expand Down Expand Up @@ -475,6 +475,26 @@ export class ConversationV2<ContentTypes>
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
*/
Expand Down
Loading

0 comments on commit cbd9f2b

Please sign in to comment.