Skip to content

Commit

Permalink
feat: moved consent proof handling to consent list load instead
Browse files Browse the repository at this point in the history
Updated consent list to handle consent proofs
  • Loading branch information
Alex Risch authored and Alex Risch committed May 1, 2024
1 parent 854779e commit 99b08ea
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 158 deletions.
57 changes: 55 additions & 2 deletions src/Contacts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { privatePreferences } from '@xmtp/proto'
import { privatePreferences, type invitation } from '@xmtp/proto'
import { hashMessage, hexToBytes } from 'viem'
import { ecdsaSignerKey, WalletSigner } from '@/crypto/Signature'
import { splitSignature } from '@/crypto/utils'
import type { EnvelopeWithMessage } from '@/utils/async'
import { fromNanoString } from '@/utils/date'
import { buildUserPrivatePreferencesTopic } from '@/utils/topic'
Expand Down Expand Up @@ -252,10 +255,60 @@ export class Contacts {
this.jobRunner = new JobRunner('user-preferences', client.keystore)
}

private async validateConsentSignature(
signature: `0x${string}`,
timestampMs: number,
peerAddress: string
): Promise<boolean> {
const signatureData = splitSignature(signature)
const message = WalletSigner.consentProofRequestText(
peerAddress,
timestampMs
)
const digest = hexToBytes(hashMessage(message))
// Recover public key
const publicKey = ecdsaSignerKey(digest, signatureData)
return publicKey?.getEthereumAddress() === this.client.address
}

private async handleConsentProof(
consentProof: invitation.ConsentProofPayload,
peerAddress: string
): Promise<void> {
const { signature, timestamp } = consentProof
const isValid = await this.validateConsentSignature(
signature as `0x${string}`,
Number(timestamp),
peerAddress
)
if (!isValid) {
return
}
await this.client.contacts.allow([peerAddress])
}

async loadConsentList(startTime?: Date) {
return this.jobRunner.run(async (lastRun) => {
// allow for override of startTime
return this.consentList.load(startTime ?? lastRun)
const entries = await this.consentList.load(startTime ?? lastRun)
try {
const conversations = await this.client.conversations.list()
conversations.forEach((conversation) => {
if (
conversation.consentProof &&
this.consentState(conversation.peerAddress) === 'unknown'
) {
this.handleConsentProof(
conversation.consentProof,
conversation.peerAddress
)
}
})
} catch (err) {
console.log(err)
}

return entries
})
}

Expand Down
46 changes: 1 addition & 45 deletions src/conversations/Conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@ import type {
messageApi,
} from '@xmtp/proto'
import Long from 'long'
import { hashMessage, hexToBytes } from 'viem'
import { SortDirection, type OnConnectionLostCallback } from '@/ApiClient'
import type { ListMessagesOptions } from '@/Client'
import type Client from '@/Client'
import {
PublicKeyBundle,
SignedPublicKeyBundle,
} from '@/crypto/PublicKeyBundle'
import { ecdsaSignerKey, WalletSigner } from '@/crypto/Signature'
import { splitSignature } from '@/crypto/utils'
import type { InvitationContext } from '@/Invitation'
import { DecodedMessage, MessageV1 } from '@/Message'
import Stream from '@/Stream'
Expand Down Expand Up @@ -156,48 +153,7 @@ export default class Conversations<ContentTypes = any> {
startTime,
direction: SortDirection.SORT_DIRECTION_ASCENDING,
})
const newConversations = await this.decodeInvites(envelopes)
newConversations.forEach((convo) => {
if (convo.consentProofPayload) {
this.handleConsentProof(convo.consentProofPayload, convo.peerAddress)
}
})
return newConversations
}

private async validateConsentSignature(
signature: `0x${string}`,
timestampMs: number,
peerAddress: string
): Promise<boolean> {
const signatureData = splitSignature(signature)
const message = WalletSigner.consentProofRequestText(
peerAddress,
timestampMs
)
const digest = hexToBytes(hashMessage(message))
// Recover public key
const publicKey = ecdsaSignerKey(digest, signatureData)
return publicKey?.getEthereumAddress() === this.client.address
}

private async handleConsentProof(
consentProof: invitation.ConsentProofPayload,
peerAddress: string
): Promise<void> {
const { signature, timestamp } = consentProof
const isValid = await this.validateConsentSignature(
signature as `0x${string}`,
Number(timestamp),
peerAddress
)
if (!isValid) {
return
}
const consentState = await this.client.contacts.consentState(peerAddress)
if (consentState === 'unknown') {
this.client.contacts.allow([peerAddress])
}
return this.decodeInvites(envelopes)
}

private async decodeInvites(
Expand Down
116 changes: 115 additions & 1 deletion test/Contacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { invitation } from '@xmtp/proto'
import Client from '@/Client'
import { Contacts } from '@/Contacts'
import { newWallet } from './helpers'
import { WalletSigner } from '@/crypto/Signature'
import { newLocalHostClient, newWallet } from './helpers'

const alice = newWallet()
const bob = newWallet()
Expand Down Expand Up @@ -189,4 +191,116 @@ describe('Contacts', () => {
expect(numActions).toBe(1)
await aliceStream.return()
})

describe('consent proofs', () => {
it('handles consent proof on invitation', async () => {
const bo = await newLocalHostClient()
const wallet = newWallet()
const keySigner = new WalletSigner(wallet)
const alixAddress = await keySigner.wallet.getAddress()
const alix = await Client.create(wallet, {
env: 'local',
})
const timestamp = Date.now()
const consentMessage = WalletSigner.consentProofRequestText(
bo.address,
timestamp
)
const signedMessage = await keySigner.wallet.signMessage(consentMessage)
const consentProofPayload = invitation.ConsentProofPayload.fromPartial({
signature: signedMessage,
timestamp,
payloadVersion:
invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1,
})
const boConvo = await bo.conversations.newConversation(
alixAddress,
undefined,
consentProofPayload
)
await alix.contacts.refreshConsentList()
const conversations = await alix.conversations.list()
const convo = conversations.find((c) => c.topic === boConvo.topic)
expect(convo).toBeTruthy()
const isApproved = await convo?.isAllowed
expect(isApproved).toBe(true)
await alix.close()
await bo.close()
})

it('consent proof yields to network consent', async () => {
const bo = await newLocalHostClient()
const wallet = newWallet()
const keySigner = new WalletSigner(wallet)
const alixAddress = await keySigner.wallet.getAddress()
const alix1 = await Client.create(wallet, {
env: 'local',
})
alix1.contacts.deny([bo.address])
await alix1.close()
const alix2 = await Client.create(wallet, {
env: 'local',
})
const timestamp = Date.now()
const consentMessage = WalletSigner.consentProofRequestText(
bo.address,
timestamp
)
const signedMessage = await keySigner.wallet.signMessage(consentMessage)
const consentProofPayload = invitation.ConsentProofPayload.fromPartial({
signature: signedMessage,
timestamp,
payloadVersion:
invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1,
})
const boConvo = await bo.conversations.newConversation(
alixAddress,
undefined,
consentProofPayload
)
const conversations = await alix2.conversations.list()
const convo = conversations.find((c) => c.topic === boConvo.topic)
expect(convo).toBeTruthy()
await alix2.contacts.refreshConsentList()
const isDenied = await alix2.contacts.isDenied(bo.address)
expect(isDenied).toBeTruthy()
await alix2.close()
await bo.close()
})

it('consent proof correctly validates', async () => {
const bo = await newLocalHostClient()
const wallet = newWallet()
const keySigner = new WalletSigner(wallet)
const alixAddress = await keySigner.wallet.getAddress()
const alix = await Client.create(wallet, {
env: 'local',
})
const timestamp = Date.now()
const consentMessage = WalletSigner.consentProofRequestText(
bo.address,
timestamp + 1
)
const signedMessage = await keySigner.wallet.signMessage(consentMessage)
const consentProofPayload = invitation.ConsentProofPayload.fromPartial({
signature: signedMessage,
timestamp,
payloadVersion:
invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1,
})
const boConvo = await bo.conversations.newConversation(
alixAddress,
undefined,
consentProofPayload
)
const conversations = await alix.conversations.list()
const convo = conversations.find((c) => c.topic === boConvo.topic)
expect(convo).toBeTruthy()
await alix.contacts.refreshConsentList()
const isAllowed = await alix.contacts.isAllowed(bo.address)
expect(isAllowed).toBeFalsy()
await alix.close()
await bo.close()
})
})
})
112 changes: 2 additions & 110 deletions test/conversations/Conversations.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { invitation } from '@xmtp/proto'
import Client from '@/Client'
import type Client from '@/Client'
import { ConversationV1, ConversationV2 } from '@/conversations/Conversation'
import { WalletSigner } from '@/crypto/Signature'
import { sleep } from '@/utils/async'
import { buildDirectMessageTopic, buildUserIntroTopic } from '@/utils/topic'
import { newLocalHostClient, newWallet } from '@test/helpers'
import { newLocalHostClient } from '@test/helpers'

describe('conversations', () => {
describe('listConversations', () => {
Expand Down Expand Up @@ -282,112 +280,6 @@ describe('conversations', () => {
const invites = await alice.listInvitations()
expect(invites).toHaveLength(1)
})

it('handles consent proof on invitation', async () => {
const bo = await newLocalHostClient()
const wallet = newWallet()
const keySigner = new WalletSigner(wallet)
const alixAddress = await keySigner.wallet.getAddress()
const alix = await Client.create(wallet, {
env: 'local',
})
const timestamp = Date.now()
const consentMessage = WalletSigner.consentProofRequestText(
bo.address,
timestamp
)
const signedMessage = await keySigner.wallet.signMessage(consentMessage)
const consentProofPayload = invitation.ConsentProofPayload.fromPartial({
signature: signedMessage,
timestamp,
payloadVersion:
invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1,
})
const boConvo = await bo.conversations.newConversation(
alixAddress,
undefined,
consentProofPayload
)
const conversations = await alix.conversations.list()
const convo = conversations.find((c) => c.topic === boConvo.topic)
expect(convo).toBeTruthy()
await alix.contacts.refreshConsentList()
const isApproved = await alix.contacts.isAllowed(bo.address)
expect(isApproved).toBeTruthy()
await alix.close()
await bo.close()
})

it('consent proof yields to network consent', async () => {
const bo = await newLocalHostClient()
const wallet = newWallet()
const keySigner = new WalletSigner(wallet)
const alixAddress = await keySigner.wallet.getAddress()
const alix = await Client.create(wallet, {
env: 'local',
})
const timestamp = Date.now()
const consentMessage = WalletSigner.consentProofRequestText(
bo.address,
timestamp
)
const signedMessage = await keySigner.wallet.signMessage(consentMessage)
const consentProofPayload = invitation.ConsentProofPayload.fromPartial({
signature: signedMessage,
timestamp,
payloadVersion:
invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1,
})
const boConvo = await bo.conversations.newConversation(
alixAddress,
undefined,
consentProofPayload
)
await alix.contacts.deny([bo.address])
const conversations = await alix.conversations.list()
const convo = conversations.find((c) => c.topic === boConvo.topic)
expect(convo).toBeTruthy()
await alix.contacts.refreshConsentList()
const isDenied = await alix.contacts.isDenied(bo.address)
expect(isDenied).toBeTruthy()
await alix.close()
await bo.close()
})

it('consent proof correctly validates', async () => {
const bo = await newLocalHostClient()
const wallet = newWallet()
const keySigner = new WalletSigner(wallet)
const alixAddress = await keySigner.wallet.getAddress()
const alix = await Client.create(wallet, {
env: 'local',
})
const timestamp = Date.now()
const consentMessage = WalletSigner.consentProofRequestText(
bo.address,
timestamp + 1
)
const signedMessage = await keySigner.wallet.signMessage(consentMessage)
const consentProofPayload = invitation.ConsentProofPayload.fromPartial({
signature: signedMessage,
timestamp,
payloadVersion:
invitation.ConsentProofPayloadVersion.CONSENT_PROOF_PAYLOAD_VERSION_1,
})
const boConvo = await bo.conversations.newConversation(
alixAddress,
undefined,
consentProofPayload
)
const conversations = await alix.conversations.list()
const convo = conversations.find((c) => c.topic === boConvo.topic)
expect(convo).toBeTruthy()
await alix.contacts.refreshConsentList()
const isAllowed = await alix.contacts.isAllowed(bo.address)
expect(isAllowed).toBeFalsy()
await alix.close()
await bo.close()
})
})
})

Expand Down

0 comments on commit 99b08ea

Please sign in to comment.