diff --git a/bench/decode.ts b/bench/decode.ts index e87b131bd..7e99e2769 100644 --- a/bench/decode.ts +++ b/bench/decode.ts @@ -22,9 +22,10 @@ const decodeV1 = () => { const bob = await newPrivateKeyBundle() const message = randomBytes(size) + const { payload } = await alice.encodeContent(message) const encodedMessage = await MessageV1.encode( alice.keystore, - await alice.encodeContent(message), + payload, alice.publicKeyBundle, bob.getPublicKeyBundle(), new Date() @@ -75,8 +76,8 @@ const decodeV2 = () => { new Date(), undefined ) - const payload = await alice.encodeContent(message) - const encodedMessage = await convo.createMessage(payload) + const { payload, shouldPush } = await alice.encodeContent(message) + const encodedMessage = await convo.createMessage(payload, shouldPush) const messageBytes = encodedMessage.toBytes() const envelope = { diff --git a/bench/encode.ts b/bench/encode.ts index 7c52b9959..fa712bcb3 100644 --- a/bench/encode.ts +++ b/bench/encode.ts @@ -22,7 +22,7 @@ const encodeV1 = () => { // The returned function is the actual benchmark. Everything above is setup return async () => { - const encodedMessage = await alice.encodeContent(message) + const { payload: encodedMessage } = await alice.encodeContent(message) await MessageV1.encode( alice.keystore, encodedMessage, @@ -57,11 +57,11 @@ const encodeV2 = () => { undefined ) const message = randomBytes(size) - const payload = await alice.encodeContent(message) + const { payload, shouldPush } = await alice.encodeContent(message) // The returned function is the actual benchmark. Everything above is setup return async () => { - await convo.createMessage(payload) + await convo.createMessage(payload, shouldPush) } }) ) diff --git a/package-lock.json b/package-lock.json index 6b33c6873..2612dab40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.37.0-beta.2", + "@xmtp/proto": "^3.39.0-beta.2", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", @@ -5510,9 +5510,9 @@ } }, "node_modules/@xmtp/proto": { - "version": "3.37.0-beta.2", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.37.0-beta.2.tgz", - "integrity": "sha512-D8vqMe9MCpbKx6ECw01VxLaiMRK4jtoeYuCTCwunsc/Z44SgxEKi+bO7q6T7EsIcaBBdz06rLvyjSdj/s1CCQA==", + "version": "3.39.0-beta.2", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.39.0-beta.2.tgz", + "integrity": "sha512-pHqbZUrd62qcWZj6MryOKfdeo/lirJrxt1Pv0c1hdpCEyz2J19j+TkGLkoiq7f5wrU3z9Yci+HooAj9BfHZ8og==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", diff --git a/package.json b/package.json index 3246c39eb..3e5e40c41 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ }, "dependencies": { "@noble/secp256k1": "^1.5.2", - "@xmtp/proto": "^3.37.0-beta.2", + "@xmtp/proto": "^3.39.0-beta.2", "@xmtp/user-preferences-bindings-wasm": "^0.3.4", "async-mutex": "^0.4.0", "elliptic": "^6.5.4", diff --git a/src/Client.ts b/src/Client.ts index cb32c09b1..a6bb8a009 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -635,7 +635,10 @@ export default class Client { async encodeContent( content: ContentTypes, options?: SendOptions - ): Promise { + ): Promise<{ + payload: Uint8Array + shouldPush: boolean + }> { const contentType = options?.contentType || ContentTypeText const codec = this.codecFor(contentType) if (!codec) { @@ -651,7 +654,10 @@ export default class Client { encoded.compression = options.compression } await compress(encoded) - return proto.EncodedContent.encode(encoded).finish() + return { + payload: proto.EncodedContent.encode(encoded).finish(), + shouldPush: codec.shouldPush(content), + } } async decodeContent(contentBytes: Uint8Array): Promise<{ diff --git a/src/Message.ts b/src/Message.ts index 3b22ff19c..3e1025131 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -192,28 +192,32 @@ export class MessageV2 extends MessageBase implements proto.MessageV2 { senderAddress: string | undefined private header: proto.MessageHeaderV2 senderHmac: Uint8Array + shouldPush: boolean constructor( id: string, bytes: Uint8Array, obj: proto.Message, header: proto.MessageHeaderV2, - senderHmac: Uint8Array + senderHmac: Uint8Array, + shouldPush: boolean ) { super(id, bytes, obj) this.header = header this.senderHmac = senderHmac + this.shouldPush = shouldPush } static async create( obj: proto.Message, header: proto.MessageHeaderV2, bytes: Uint8Array, - senderHmac: Uint8Array + senderHmac: Uint8Array, + shouldPush: boolean ): Promise { const id = bytesToHex(await sha256(bytes)) - return new MessageV2(id, bytes, obj, header, senderHmac) + return new MessageV2(id, bytes, obj, header, senderHmac, shouldPush) } get sent(): Date { diff --git a/src/conversations/Conversation.ts b/src/conversations/Conversation.ts index f36c5a517..74826e8fd 100644 --- a/src/conversations/Conversation.ts +++ b/src/conversations/Conversation.ts @@ -284,7 +284,7 @@ export class ConversationV1 } else { topics = [topic] } - const payload = await this.client.encodeContent(content, options) + const { payload } = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, recipient, options?.timestamp) const msgBytes = msg.toBytes() @@ -393,7 +393,7 @@ export class ConversationV1 topics = [topic] } const contentType = options?.contentType || ContentTypeText - const payload = await this.client.encodeContent(content, options) + const { payload } = await this.client.encodeContent(content, options) const msg = await this.createMessage(payload, recipient, options?.timestamp) await this.client.publishEnvelopes( @@ -602,8 +602,15 @@ export class ConversationV2 content: Exclude, options?: SendOptions ): Promise> { - const payload = await this.client.encodeContent(content, options) - const msg = await this.createMessage(payload, options?.timestamp) + const { payload, shouldPush } = await this.client.encodeContent( + content, + options + ) + const msg = await this.createMessage( + payload, + shouldPush, + options?.timestamp + ) const topic = options?.ephemeral ? this.ephemeralTopic : this.topic @@ -637,6 +644,7 @@ export class ConversationV2 async createMessage( // Payload is expected to have already gone through `client.encodeContent` payload: Uint8Array, + shouldPush: boolean, timestamp?: Date ): Promise { const header: message.MessageHeaderV2 = { @@ -660,13 +668,14 @@ export class ConversationV2 signedBytes, headerBytes ) + const protoMsg = { v1: undefined, - v2: { headerBytes, ciphertext, senderHmac }, + v2: { headerBytes, ciphertext, senderHmac, shouldPush }, } const bytes = message.Message.encode(protoMsg).finish() - return MessageV2.create(protoMsg, header, bytes, senderHmac) + return MessageV2.create(protoMsg, header, bytes, senderHmac, shouldPush) } private async decryptBatch( @@ -779,8 +788,15 @@ export class ConversationV2 content: any, // eslint-disable-line @typescript-eslint/no-explicit-any options?: SendOptions ): Promise { - const payload = await this.client.encodeContent(content, options) - const msg = await this.createMessage(payload, options?.timestamp) + const { payload, shouldPush } = await this.client.encodeContent( + content, + options + ) + const msg = await this.createMessage( + payload, + shouldPush, + options?.timestamp + ) const msgBytes = msg.toBytes() const topic = options?.ephemeral ? this.ephemeralTopic : this.topic @@ -827,7 +843,13 @@ export class ConversationV2 throw new Error('topic mismatch') } - return MessageV2.create(msg, header, env.message, msg.v2.senderHmac) + return MessageV2.create( + msg, + header, + env.message, + msg.v2.senderHmac, + msg.v2.shouldPush + ) } async decodeMessage( diff --git a/test/Client.test.ts b/test/Client.test.ts index ae4eab112..0e926d3fa 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -6,7 +6,7 @@ import { waitForUserContact, newLocalHostClientWithCustomWallet, } from './helpers' -import { buildUserContactTopic } from '../src/utils' +import { EnvelopeWithMessage, buildUserContactTopic } from '../src/utils' import Client, { ClientOptions } from '../src/Client' import { ApiUrls, @@ -20,15 +20,17 @@ import { } from '../src' import NetworkKeyManager from '../src/keystore/providers/NetworkKeyManager' import TopicPersistence from '../src/keystore/persistence/TopicPersistence' -import { PrivateKeyBundleV1 } from '../src/crypto' +import { PrivateKey, PrivateKeyBundleV1 } from '../src/crypto' import { Wallet } from 'ethers' import { NetworkKeystoreProvider } from '../src/keystore/providers' import { PublishResponse } from '@xmtp/proto/ts/dist/types/message_api/v1/message_api.pb' import LocalStoragePonyfill from '../src/keystore/persistence/LocalStoragePonyfill' +import { message } from '@xmtp/proto' import { createWalletClient, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains' import { generatePrivateKey } from 'viem/accounts' +import { ContentTypeTestKey, TestKeyCodec } from './ContentTypeTestKey' type TestCase = { name: string @@ -196,11 +198,25 @@ describe('encodeContent', () => { 173, 229, ]) - const payload = await c.encodeContent(uncompressed, { + const { payload } = await c.encodeContent(uncompressed, { compression: Compression.COMPRESSION_DEFLATE, }) assert.deepEqual(Uint8Array.from(payload), compressed) }) + + it('returns shouldPush based on content codec', async () => { + const alice = await newLocalHostClient() + alice.registerCodec(new TestKeyCodec()) + + const { shouldPush: result1 } = await alice.encodeContent('gm') + expect(result1).toBe(true) + + const key = PrivateKey.generate().publicKey + const { shouldPush: result2 } = await alice.encodeContent(key, { + contentType: ContentTypeTestKey, + }) + expect(result2).toBe(false) + }) }) describe('canMessage', () => { @@ -296,6 +312,43 @@ describe('canMessageMultipleBatches', () => { }) }) +describe('listEnvelopes', () => { + it('has envelopes with senderHmac and shouldPush', async () => { + const alice = await newLocalHostClient() + const bob = await newLocalHostClient() + alice.registerCodec(new TestKeyCodec()) + const convo = await alice.conversations.newConversation(bob.address) + await convo.send('hi') + const key = PrivateKey.generate().publicKey + await convo.send(key, { + contentType: ContentTypeTestKey, + }) + + const envelopes = await alice.listEnvelopes( + convo.topic, + (env: EnvelopeWithMessage) => Promise.resolve(env) + ) + + const msg1 = message.Message.decode(envelopes[0].message) + if (!msg1.v2) { + throw new Error('unknown message version') + } + const header1 = message.MessageHeaderV2.decode(msg1.v2.headerBytes) + expect(header1.topic).toEqual(convo.topic) + expect(msg1.v2.senderHmac).toBeDefined() + expect(msg1.v2.shouldPush).toBe(true) + + const msg2 = message.Message.decode(envelopes[1].message) + if (!msg2.v2) { + throw new Error('unknown message version') + } + const header2 = message.MessageHeaderV2.decode(msg2.v2.headerBytes) + expect(header2.topic).toEqual(convo.topic) + expect(msg2.v2.senderHmac).toBeDefined() + expect(msg2.v2.shouldPush).toBe(false) + }) +}) + describe('publishEnvelopes', () => { it('can send a valid envelope', async () => { const c = await newLocalHostClient() diff --git a/test/Message.test.ts b/test/Message.test.ts index 8b91ca638..ddafd437f 100644 --- a/test/Message.test.ts +++ b/test/Message.test.ts @@ -154,7 +154,7 @@ describe('Message', function () { env: 'local', privateKeyOverride: alice.encode(), }) - const payload = await aliceClient.encodeContent(text) + const { payload } = await aliceClient.encodeContent(text) const timestamp = new Date() const sender = alice.getPublicKeyBundle() const recipient = bob.getPublicKeyBundle() diff --git a/test/conversations/Conversation.test.ts b/test/conversations/Conversation.test.ts index e7e354768..e6cf1e202 100644 --- a/test/conversations/Conversation.test.ts +++ b/test/conversations/Conversation.test.ts @@ -1,4 +1,4 @@ -import { DecodedMessage, MessageV1 } from './../../src/Message' +import { DecodedMessage, MessageV1, MessageV2 } from './../../src/Message' import { buildDirectMessageTopic } from './../../src/utils' import { Client, Compression, ContentTypeId, ContentTypeText } from '../../src' import { SortDirection } from '../../src/ApiClient' @@ -531,6 +531,17 @@ describe('conversation', () => { await bs.return() await as.return() + + const messages = await alice.listEnvelopes( + ac.topic, + ac.processEnvelope.bind(ac) + ) + + expect(messages).toHaveLength(2) + expect(messages[0].shouldPush).toBe(true) + expect(messages[0].senderHmac).toBeDefined() + expect(messages[1].shouldPush).toBe(true) + expect(messages[1].senderHmac).toBeDefined() }) // it('rejects spoofed contact bundles', async () => { @@ -663,6 +674,9 @@ describe('conversation', () => { metadata: {}, } ) + if (!(aliceConvo instanceof ConversationV2)) { + fail() + } await sleep(100) const bobConvo = await bob.conversations.newConversation(alice.address, { conversationId: 'xmtp.org/key', @@ -674,7 +688,6 @@ describe('conversation', () => { // alice doesn't recognize the type expect( - // @ts-expect-error aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -682,7 +695,6 @@ describe('conversation', () => { // bob doesn't recognize the type alice.registerCodec(new TestKeyCodec()) - // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -704,7 +716,6 @@ describe('conversation', () => { // both recognize the type bob.registerCodec(new TestKeyCodec()) - // @ts-expect-error await aliceConvo.send(key, { contentType: ContentTypeTestKey, }) @@ -719,13 +730,23 @@ describe('conversation', () => { ...ContentTypeTestKey, versionMajor: 2, }) - // @ts-expect-error expect(aliceConvo.send(key, { contentType: type2 })).rejects.toThrow( 'unknown content type xmtp.test/public-key:2.0' ) await bobStream.return() await aliceStream.return() + + const messages = await alice.listEnvelopes( + aliceConvo.topic, + aliceConvo.processEnvelope.bind(aliceConvo) + ) + + expect(messages).toHaveLength(2) + expect(messages[0].shouldPush).toBe(false) + expect(messages[0].senderHmac).toBeDefined() + expect(messages[1].shouldPush).toBe(false) + expect(messages[1].senderHmac).toBeDefined() }) }) })