diff --git a/Sources/XMTPTestHelpers/TestHelpers.swift b/Sources/XMTPTestHelpers/TestHelpers.swift index e6d9a940..d9a43f26 100644 --- a/Sources/XMTPTestHelpers/TestHelpers.swift +++ b/Sources/XMTPTestHelpers/TestHelpers.swift @@ -88,7 +88,7 @@ public struct Fixtures { public func publishLegacyContact(client: Client) async throws { var contactBundle = ContactBundle() - contactBundle.v1.keyBundle = client.privateKeyBundleV1.toPublicKeyBundle() + contactBundle.v1.keyBundle = try client.v1keys.toPublicKeyBundle() var envelope = Envelope() envelope.contentTopic = Topic.contact(client.address).description diff --git a/Sources/XMTPiOS/Client.swift b/Sources/XMTPiOS/Client.swift index 62071acf..e0da501d 100644 --- a/Sources/XMTPiOS/Client.swift +++ b/Sources/XMTPiOS/Client.swift @@ -14,6 +14,7 @@ public typealias PreEventCallback = () async throws -> Void public enum ClientError: Error, CustomStringConvertible, LocalizedError { case creationError(String) case noV3Client(String) + case noV2Client(String) public var description: String { switch self { @@ -21,6 +22,8 @@ public enum ClientError: Error, CustomStringConvertible, LocalizedError { return "ClientError.creationError: \(err)" case .noV3Client(let err): return "ClientError.noV3Client: \(err)" + case .noV2Client(let err): + return "ClientError.noV2Client: \(err)" } } @@ -111,13 +114,15 @@ public struct ClientOptions { public final class Client { /// The wallet address of the ``SigningKey`` used to create this Client. public let address: String - let privateKeyBundleV1: PrivateKeyBundleV1 - let apiClient: ApiClient - let v3Client: LibXMTP.FfiXmtpClient? + var privateKeyBundleV1: PrivateKeyBundleV1? = nil + var apiClient: ApiClient? = nil + public let v3Client: LibXMTP.FfiXmtpClient? public let libXMTPVersion: String = getVersionInfo() public let dbPath: String public let installationID: String public let inboxID: String + public var hasV2Client: Bool = true + /// Access ``Conversations`` for this Client. public lazy var conversations: Conversations = .init(client: self) @@ -126,9 +131,7 @@ public final class Client { public lazy var contacts: Contacts = .init(client: self) /// The XMTP environment which specifies which network this Client is connected to. - public var environment: XMTPEnvironment { - apiClient.environment - } + public lazy var environment: XMTPEnvironment = apiClient?.environment ?? .dev var codecRegistry = CodecRegistry() @@ -157,11 +160,44 @@ public final class Client { throw ClientError.creationError(detailedErrorMessage) } } + + // This is a V3 only feature + public static func createOrBuild(account: SigningKey, options: ClientOptions) async throws -> Client { + let inboxId = try await getOrCreateInboxId(options: options, address: account.address) + + let (libxmtpClient, dbPath) = try await initV3Client( + accountAddress: account.address, + options: options, + privateKeyBundleV1: nil, + signingKey: account, + inboxId: inboxId + ) + guard let v3Client = libxmtpClient else { + throw ClientError.noV3Client("Error no V3 client initialized") + } + + let client = try Client( + address: account.address, + v3Client: v3Client, + dbPath: dbPath, + installationID: v3Client.installationId().toHex, + inboxID: v3Client.inboxId(), + environment: options.api.env + ) + + let conversations = client.conversations + let contacts = client.contacts + + for codec in (options.codecs) { + client.register(codec: codec) + } + return client + } static func initV3Client( accountAddress: String, options: ClientOptions?, - privateKeyBundleV1: PrivateKeyBundleV1, + privateKeyBundleV1: PrivateKeyBundleV1?, signingKey: SigningKey?, inboxId: String ) async throws -> (FfiXmtpClient?, String) { @@ -202,7 +238,7 @@ public final class Client { inboxId: inboxId, accountAddress: address, nonce: 0, - legacySignedPrivateKeyProto: try privateKeyBundleV1.toV2().identityKey.serializedData(), + legacySignedPrivateKeyProto: try privateKeyBundleV1?.toV2().identityKey.serializedData(), historySyncUrl: options?.historySyncUrl ) @@ -377,22 +413,45 @@ public final class Client { self.dbPath = dbPath self.installationID = installationID self.inboxID = inboxID + self.hasV2Client = true + self.environment = apiClient.environment + } + + init(address: String, v3Client: LibXMTP.FfiXmtpClient, dbPath: String, installationID: String, inboxID: String, environment: XMTPEnvironment) throws { + self.address = address + self.v3Client = v3Client + self.dbPath = dbPath + self.installationID = installationID + self.inboxID = inboxID + self.hasV2Client = false + self.environment = environment } public var privateKeyBundle: PrivateKeyBundle { - PrivateKeyBundle(v1: privateKeyBundleV1) + get throws { + try PrivateKeyBundle(v1: v1keys) + } } public var publicKeyBundle: SignedPublicKeyBundle { - privateKeyBundleV1.toV2().getPublicKeyBundle() + get throws { + try v1keys.toV2().getPublicKeyBundle() + } } public var v1keys: PrivateKeyBundleV1 { - privateKeyBundleV1 + get throws { + guard let keys = privateKeyBundleV1 else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + return keys + } } public var keys: PrivateKeyBundleV2 { - privateKeyBundleV1.toV2() + get throws { + try v1keys.toV2() + } } public func canMessage(_ peerAddress: String) async throws -> Bool { @@ -472,7 +531,7 @@ public final class Client { func ensureUserContactPublished() async throws { if let contact = try await getUserContact(peerAddress: address), case .v2 = contact.version, - keys.getPublicKeyBundle().equals(contact.v2.keyBundle) + try keys.getPublicKeyBundle().equals(contact.v2.keyBundle) { return } @@ -485,7 +544,7 @@ public final class Client { if legacy { var contactBundle = ContactBundle() - contactBundle.v1.keyBundle = privateKeyBundleV1.toPublicKeyBundle() + contactBundle.v1.keyBundle = try v1keys.toPublicKeyBundle() var envelope = Envelope() envelope.contentTopic = Topic.contact(address).description @@ -496,7 +555,7 @@ public final class Client { } var contactBundle = ContactBundle() - contactBundle.v2.keyBundle = keys.getPublicKeyBundle() + contactBundle.v2.keyBundle = try keys.getPublicKeyBundle() contactBundle.v2.keyBundle.identityKey.signature.ensureWalletSignature() var envelope = Envelope() @@ -509,23 +568,32 @@ public final class Client { } public func query(topic: Topic, pagination: Pagination? = nil) async throws -> QueryResponse { - return try await apiClient.query( + guard let client = apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + return try await client.query( topic: topic, pagination: pagination ) } public func batchQuery(request: BatchQueryRequest) async throws -> BatchQueryResponse { - return try await apiClient.batchQuery(request: request) + guard let client = apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + return try await client.batchQuery(request: request) } public func publish(envelopes: [Envelope]) async throws { - let authorized = AuthorizedIdentity(address: address, authorized: privateKeyBundleV1.identityKey.publicKey, identity: privateKeyBundleV1.identityKey) + guard let client = apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + let authorized = try AuthorizedIdentity(address: address, authorized: v1keys.identityKey.publicKey, identity: v1keys.identityKey) let authToken = try await authorized.createAuthToken() - apiClient.setAuthToken(authToken) + client.setAuthToken(authToken) - try await apiClient.publish(envelopes: envelopes) + try await client.publish(envelopes: envelopes) } public func subscribe( @@ -539,7 +607,10 @@ public final class Client { request: FfiV2SubscribeRequest, callback: FfiV2SubscriptionCallback ) async throws -> FfiV2Subscription { - return try await apiClient.subscribe(request: request, callback: callback) + guard let client = apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + return try await client.subscribe(request: request, callback: callback) } public func deleteLocalDatabase() throws { diff --git a/Sources/XMTPiOS/Contacts.swift b/Sources/XMTPiOS/Contacts.swift index 3f1d91f3..ebe42b0e 100644 --- a/Sources/XMTPiOS/Contacts.swift +++ b/Sources/XMTPiOS/Contacts.swift @@ -58,63 +58,66 @@ public actor EntriesManager { public class ConsentList { public let entriesManager = EntriesManager() - var publicKey: Data - var privateKey: Data - var identifier: String? var lastFetched: Date? var client: Client init(client: Client) { self.client = client - privateKey = client.privateKeyBundleV1.identityKey.secp256K1.bytes - publicKey = client.privateKeyBundleV1.identityKey.publicKey.secp256K1Uncompressed.bytes - identifier = try? LibXMTP.generatePrivatePreferencesTopicIdentifier(privateKey: privateKey) } func load() async throws -> [ConsentListEntry] { - guard let identifier = identifier else { - throw ContactError.invalidIdentifier - } - let newDate = Date() - - let pagination = Pagination( - limit: 500, - after: lastFetched, - direction: .ascending - ) - let envelopes = try await client.apiClient.envelopes(topic: Topic.preferenceList(identifier).description, pagination: pagination) - lastFetched = newDate - - var preferences: [PrivatePreferencesAction] = [] - - for envelope in envelopes { - let payload = try LibXMTP.userPreferencesDecrypt(publicKey: publicKey, privateKey: privateKey, message: envelope.message) - - try preferences.append(PrivatePreferencesAction(serializedData: Data(payload))) - } - for preference in preferences { - for address in preference.allowAddress.walletAddresses { - _ = await allow(address: address) - } - - for address in preference.denyAddress.walletAddresses { - _ = await deny(address: address) - } - - for groupId in preference.allowGroup.groupIds { - _ = await allowGroup(groupId: groupId) + if (client.hasV2Client) { + let privateKey = try client.v1keys.identityKey.secp256K1.bytes + let publicKey = try client.v1keys.identityKey.publicKey.secp256K1Uncompressed.bytes + let identifier = try? LibXMTP.generatePrivatePreferencesTopicIdentifier(privateKey: privateKey) + + guard let identifier = identifier else { + throw ContactError.invalidIdentifier } - - for groupId in preference.denyGroup.groupIds { - _ = await denyGroup(groupId: groupId) + let newDate = Date() + + let pagination = Pagination( + limit: 500, + after: lastFetched, + direction: .ascending + ) + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") } + let envelopes = try await apiClient.envelopes(topic: Topic.preferenceList(identifier).description, pagination: pagination) + lastFetched = newDate + + var preferences: [PrivatePreferencesAction] = [] - for inboxId in preference.allowInboxID.inboxIds { - _ = await allowInboxId(inboxId: inboxId) + for envelope in envelopes { + let payload = try LibXMTP.userPreferencesDecrypt(publicKey: publicKey, privateKey: privateKey, message: envelope.message) + + try preferences.append(PrivatePreferencesAction(serializedData: Data(payload))) } - - for inboxId in preference.denyInboxID.inboxIds { - _ = await denyInboxId(inboxId: inboxId) + for preference in preferences { + for address in preference.allowAddress.walletAddresses { + _ = await allow(address: address) + } + + for address in preference.denyAddress.walletAddresses { + _ = await deny(address: address) + } + + for groupId in preference.allowGroup.groupIds { + _ = await allowGroup(groupId: groupId) + } + + for groupId in preference.denyGroup.groupIds { + _ = await denyGroup(groupId: groupId) + } + + for inboxId in preference.allowInboxID.inboxIds { + _ = await allowInboxId(inboxId: inboxId) + } + + for inboxId in preference.denyInboxID.inboxIds { + _ = await denyInboxId(inboxId: inboxId) + } } } @@ -122,56 +125,61 @@ public class ConsentList { } func publish(entries: [ConsentListEntry]) async throws { - guard let identifier = identifier else { - throw ContactError.invalidIdentifier - } - var payload = PrivatePreferencesAction() - - for entry in entries { - switch entry.entryType { - case .address: - switch entry.consentType { - case .allowed: - payload.allowAddress.walletAddresses.append(entry.value) - case .denied: - payload.denyAddress.walletAddresses.append(entry.value) - case .unknown: - payload.messageType = nil - } - case .group_id: - switch entry.consentType { - case .allowed: - payload.allowGroup.groupIds.append(entry.value) - case .denied: - payload.denyGroup.groupIds.append(entry.value) - case .unknown: - payload.messageType = nil - } - case .inbox_id: - switch entry.consentType { - case .allowed: - payload.allowInboxID.inboxIds.append(entry.value) - case .denied: - payload.denyInboxID.inboxIds.append(entry.value) - case .unknown: - payload.messageType = nil + if (client.hasV2Client) { + let privateKey = try client.v1keys.identityKey.secp256K1.bytes + let publicKey = try client.v1keys.identityKey.publicKey.secp256K1Uncompressed.bytes + let identifier = try? LibXMTP.generatePrivatePreferencesTopicIdentifier(privateKey: privateKey) + guard let identifier = identifier else { + throw ContactError.invalidIdentifier } + var payload = PrivatePreferencesAction() + + for entry in entries { + switch entry.entryType { + case .address: + switch entry.consentType { + case .allowed: + payload.allowAddress.walletAddresses.append(entry.value) + case .denied: + payload.denyAddress.walletAddresses.append(entry.value) + case .unknown: + payload.messageType = nil + } + case .group_id: + switch entry.consentType { + case .allowed: + payload.allowGroup.groupIds.append(entry.value) + case .denied: + payload.denyGroup.groupIds.append(entry.value) + case .unknown: + payload.messageType = nil + } + case .inbox_id: + switch entry.consentType { + case .allowed: + payload.allowInboxID.inboxIds.append(entry.value) + case .denied: + payload.denyInboxID.inboxIds.append(entry.value) + case .unknown: + payload.messageType = nil + } + } + } + + let message = try LibXMTP.userPreferencesEncrypt( + publicKey: publicKey, + privateKey: privateKey, + message: payload.serializedData() + ) + + let envelope = Envelope( + topic: Topic.preferenceList(identifier), + timestamp: Date(), + message: Data(message) + ) + + try await client.publish(envelopes: [envelope]) } - } - - let message = try LibXMTP.userPreferencesEncrypt( - publicKey: publicKey, - privateKey: privateKey, - message: payload.serializedData() - ) - - let envelope = Envelope( - topic: Topic.preferenceList(identifier), - timestamp: Date(), - message: Data(message) - ) - - try await client.publish(envelopes: [envelope]) } func allow(address: String) async -> ConsentListEntry { diff --git a/Sources/XMTPiOS/ConversationV1.swift b/Sources/XMTPiOS/ConversationV1.swift index 2b187ded..8e62f037 100644 --- a/Sources/XMTPiOS/ConversationV1.swift +++ b/Sources/XMTPiOS/ConversationV1.swift @@ -52,7 +52,7 @@ public struct ConversationV1 { let date = sentAt let message = try MessageV1.encode( - sender: client.privateKeyBundleV1, + sender: client.v1keys, recipient: recipient, message: try encodedContent.serializedData(), timestamp: date @@ -217,8 +217,10 @@ public struct ConversationV1 { func decryptedMessages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecryptedMessage] { let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - - let envelopes = try await client.apiClient.envelopes( + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + let envelopes = try await apiClient.envelopes( topic: Topic.directMessageV1(client.address, peerAddress).description, pagination: pagination ) @@ -229,7 +231,10 @@ public struct ConversationV1 { func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] { let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - let envelopes = try await client.apiClient.envelopes( + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + let envelopes = try await apiClient.envelopes( topic: Topic.directMessageV1(client.address, peerAddress).description, pagination: pagination ) @@ -246,7 +251,7 @@ public struct ConversationV1 { func decrypt(envelope: Envelope) throws -> DecryptedMessage { let message = try Message(serializedData: envelope.message) - let decrypted = try message.v1.decrypt(with: client.privateKeyBundleV1) + let decrypted = try message.v1.decrypt(with: client.v1keys) let encodedMessage = try EncodedContent(serializedData: decrypted) let header = try message.v1.header diff --git a/Sources/XMTPiOS/ConversationV2.swift b/Sources/XMTPiOS/ConversationV2.swift index 86c81974..cb479b2e 100644 --- a/Sources/XMTPiOS/ConversationV2.swift +++ b/Sources/XMTPiOS/ConversationV2.swift @@ -37,7 +37,7 @@ public struct ConversationV2 { private var header: SealedInvitationHeaderV1 static func create(client: Client, invitation: InvitationV1, header: SealedInvitationHeaderV1) throws -> ConversationV2 { - let myKeys = client.keys.getPublicKeyBundle() + let myKeys = try client.keys.getPublicKeyBundle() let peer = try myKeys.walletAddress == (try header.sender.walletAddress) ? header.recipient : header.sender let peerAddress = try peer.walletAddress @@ -133,7 +133,10 @@ public struct ConversationV2 { func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] { let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - let envelopes = try await client.apiClient.envelopes(topic: topic.description, pagination: pagination) + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + let envelopes = try await apiClient.envelopes(topic: topic.description, pagination: pagination) return envelopes.compactMap { envelope in do { @@ -146,8 +149,11 @@ public struct ConversationV2 { } func decryptedMessages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecryptedMessage] { + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - let envelopes = try await client.apiClient.envelopes(topic: topic.description, pagination: pagination) + let envelopes = try await apiClient.envelopes(topic: topic.description, pagination: pagination) return try envelopes.map { envelope in try decrypt(envelope: envelope) diff --git a/Sources/XMTPiOS/Conversations.swift b/Sources/XMTPiOS/Conversations.swift index f35637c7..04c70cee 100644 --- a/Sources/XMTPiOS/Conversations.swift +++ b/Sources/XMTPiOS/Conversations.swift @@ -296,8 +296,11 @@ public actor Conversations { .map { requests in BatchQueryRequest.with { $0.requests = requests } } var messages: [DecodedMessage] = [] // TODO: consider using a task group here for parallel batch calls + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } for batch in batches { - messages += try await client.apiClient.batchQuery(request: batch) + messages += try await apiClient.batchQuery(request: batch) .responses.flatMap { res in res.envelopes.compactMap { envelope in let conversation = conversationsByTopic[envelope.contentTopic] @@ -327,8 +330,11 @@ public actor Conversations { .map { requests in BatchQueryRequest.with { $0.requests = requests } } var messages: [DecryptedMessage] = [] // TODO: consider using a task group here for parallel batch calls + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } for batch in batches { - messages += try await client.apiClient.batchQuery(request: batch) + messages += try await apiClient.batchQuery(request: batch) .responses.flatMap { res in res.envelopes.compactMap { envelope in let conversation = conversationsByTopic[envelope.contentTopic] @@ -833,7 +839,10 @@ public actor Conversations { } private func listIntroductionPeers(pagination: Pagination?) async throws -> [String: Date] { - let envelopes = try await client.apiClient.query( + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + let envelopes = try await apiClient.query( topic: .userIntro(client.address), pagination: pagination ).envelopes @@ -841,7 +850,7 @@ public actor Conversations { do { let message = try MessageV1.fromBytes(envelope.message) // Attempt to decrypt, just to make sure we can - _ = try message.decrypt(with: client.privateKeyBundleV1) + _ = try message.decrypt(with: client.v1keys) return message } catch { return nil @@ -867,7 +876,10 @@ public actor Conversations { } private func listInvitations(pagination: Pagination?) async throws -> [SealedInvitation] { - var envelopes = try await client.apiClient.envelopes( + guard let apiClient = client.apiClient else { + throw ClientError.noV2Client("Error no V2 client initialized") + } + var envelopes = try await apiClient.envelopes( topic: Topic.userInvite(client.address).description, pagination: pagination ) diff --git a/Sources/XMTPiOS/Frames/FramesClient.swift b/Sources/XMTPiOS/Frames/FramesClient.swift index e234e53b..0fea2726 100644 --- a/Sources/XMTPiOS/Frames/FramesClient.swift +++ b/Sources/XMTPiOS/Frames/FramesClient.swift @@ -62,14 +62,14 @@ public class FramesClient { } private func signDigest(digest: Data) async throws -> Signature { - let key = self.xmtpClient.keys.identityKey + let key = try self.xmtpClient.keys.identityKey let privateKey = try PrivateKey(key) let signature = try await privateKey.sign(Data(digest)) return signature } private func getPublicKeyBundle() async throws -> PublicKeyBundle { - let bundleBytes = self.xmtpClient.publicKeyBundle; + let bundleBytes = try self.xmtpClient.publicKeyBundle; return try PublicKeyBundle(bundleBytes); } diff --git a/Sources/XMTPiOS/Messages/MessageV2.swift b/Sources/XMTPiOS/Messages/MessageV2.swift index 66e1816f..3e5d0090 100644 --- a/Sources/XMTPiOS/Messages/MessageV2.swift +++ b/Sources/XMTPiOS/Messages/MessageV2.swift @@ -88,10 +88,10 @@ extension MessageV2 { let headerBytes = try header.serializedData() let digest = SHA256.hash(data: headerBytes + payload) - let preKey = client.keys.preKeys[0] + let preKey = try client.keys.preKeys[0] let signature = try await preKey.sign(Data(digest)) - let bundle = client.privateKeyBundleV1.toV2().getPublicKeyBundle() + let bundle = try client.v1keys.toV2().getPublicKeyBundle() let signedContent = SignedContent(payload: payload, sender: bundle, signature: signature) let signedBytes = try signedContent.serializedData() diff --git a/Tests/XMTPTests/ClientTests.swift b/Tests/XMTPTests/ClientTests.swift index 27c0381a..234e6b7d 100644 --- a/Tests/XMTPTests/ClientTests.swift +++ b/Tests/XMTPTests/ClientTests.swift @@ -15,9 +15,9 @@ import XMTPTestHelpers @available(iOS 15, *) class ClientTests: XCTestCase { func testTakesAWallet() async throws { - try TestConfig.skip(because: "run manually against dev") + let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) let fakeWallet = try PrivateKey.generate() - _ = try await Client.create(account: fakeWallet) + _ = try await Client.create(account: fakeWallet, options: opts) } func testPassingSavedKeysWithNoSignerWithMLSErrors() async throws { @@ -50,7 +50,7 @@ class ClientTests: XCTestCase { ) ) - let keys = client.privateKeyBundle + let keys = try client.privateKeyBundle let otherClient = try await Client.from( bundle: keys, options: .init( @@ -204,57 +204,59 @@ class ClientTests: XCTestCase { let fakeWallet = try PrivateKey.generate() let client = try await Client.create(account: fakeWallet, options: opts) - XCTAssertEqual(1, client.privateKeyBundleV1.preKeys.count) + XCTAssertEqual(1, try client.v1keys.preKeys.count) - let preKey = client.privateKeyBundleV1.preKeys[0] + let preKey = try client.v1keys.preKeys[0] XCTAssert(preKey.publicKey.hasSignature, "prekey not signed") } func testCanBeCreatedWithBundle() async throws { - try TestConfig.skip(because: "run manually against dev") + let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet) + let client = try await Client.create(account: fakeWallet, options: opts) - let bundle = client.privateKeyBundle - let clientFromV1Bundle = try await Client.from(bundle: bundle) + let bundle = try client.privateKeyBundle + let clientFromV1Bundle = try await Client.from(bundle: bundle, options: opts) XCTAssertEqual(client.address, clientFromV1Bundle.address) - XCTAssertEqual(client.privateKeyBundleV1.identityKey, clientFromV1Bundle.privateKeyBundleV1.identityKey) - XCTAssertEqual(client.privateKeyBundleV1.preKeys, clientFromV1Bundle.privateKeyBundleV1.preKeys) + XCTAssertEqual(try client.v1keys.identityKey, try clientFromV1Bundle.v1keys.identityKey) + XCTAssertEqual(try client.v1keys.preKeys, try clientFromV1Bundle.v1keys.preKeys) } func testCanBeCreatedWithV1Bundle() async throws { - try TestConfig.skip(because: "run manually against dev") + let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet) + let client = try await Client.create(account: fakeWallet, options: opts) - let bundleV1 = client.v1keys - let clientFromV1Bundle = try await Client.from(v1Bundle: bundleV1) + let bundleV1 = try client.v1keys + let clientFromV1Bundle = try await Client.from(v1Bundle: bundleV1, options: opts) XCTAssertEqual(client.address, clientFromV1Bundle.address) - XCTAssertEqual(client.privateKeyBundleV1.identityKey, clientFromV1Bundle.privateKeyBundleV1.identityKey) - XCTAssertEqual(client.privateKeyBundleV1.preKeys, clientFromV1Bundle.privateKeyBundleV1.preKeys) + XCTAssertEqual(try client.v1keys.identityKey, try clientFromV1Bundle.v1keys.identityKey) + XCTAssertEqual(try client.v1keys.preKeys, try clientFromV1Bundle.v1keys.preKeys) } func testCanAccessPublicKeyBundle() async throws { + let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet) + let client = try await Client.create(account: fakeWallet, options: opts) - let publicKeyBundle = client.keys.getPublicKeyBundle() - XCTAssertEqual(publicKeyBundle, client.publicKeyBundle) + let publicKeyBundle = try client.keys.getPublicKeyBundle() + XCTAssertEqual(publicKeyBundle, try client.publicKeyBundle) } func testCanSignWithPrivateIdentityKey() async throws { + let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet) + let client = try await Client.create(account: fakeWallet, options: opts) let digest = Util.keccak256(Data("hello world".utf8)) let signature = try await client.keys.identityKey.sign(digest) let recovered = try KeyUtilx.recoverPublicKeyKeccak256(from: signature.rawData, message: Data("hello world".utf8)) - - XCTAssertEqual(recovered, client.keys.identityKey.publicKey.secp256K1Uncompressed.bytes) + let bytes = try client.keys.identityKey.publicKey.secp256K1Uncompressed.bytes + XCTAssertEqual(recovered, bytes) } func testPreEnableIdentityCallback() async throws { @@ -331,7 +333,7 @@ class ClientTests: XCTestCase { ) ) - let keys = client.privateKeyBundle + let keys = try client.privateKeyBundle let bundleClient = try await Client.from( bundle: keys, options: .init( @@ -456,6 +458,25 @@ class ClientTests: XCTestCase { XCTAssertEqual(inboxId, alixClient.inboxID) } + func testCreatesAPureV3Client() async throws { + let key = try Crypto.secureRandomBytes(count: 32) + let alix = try PrivateKey.generate() + let options = ClientOptions.init( + api: .init(env: .local, isSecure: false), + enableV3: true, + encryptionKey: key + ) + + + let inboxId = try await Client.getOrCreateInboxId(options: options, address: alix.address) + let alixClient = try await Client.createOrBuild( + account: alix, + options: options + ) + + XCTAssertEqual(inboxId, alixClient.inboxID) + } + func testRevokesAllOtherInstallations() async throws { let key = try Crypto.secureRandomBytes(count: 32) let alix = try PrivateKey.generate() diff --git a/Tests/XMTPTests/ConversationTests.swift b/Tests/XMTPTests/ConversationTests.swift index 04721db8..713829e0 100644 --- a/Tests/XMTPTests/ConversationTests.swift +++ b/Tests/XMTPTests/ConversationTests.swift @@ -97,7 +97,7 @@ class ConversationTests: XCTestCase { } func testCanStreamConversationsV2() async throws { - let options = ClientOptions(api: ClientOptions.Api(env: .dev, isSecure: true)) + let options = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) let wallet = try PrivateKey.generate() let client = try await Client.create(account: wallet, options: options) @@ -145,7 +145,7 @@ class ConversationTests: XCTestCase { func publishLegacyContact(client: Client) async throws { var contactBundle = ContactBundle() - contactBundle.v1.keyBundle = client.privateKeyBundleV1.toPublicKeyBundle() + contactBundle.v1.keyBundle = try client.v1keys.toPublicKeyBundle() var envelope = Envelope() envelope.contentTopic = Topic.contact(client.address).description @@ -215,10 +215,10 @@ class ConversationTests: XCTestCase { let headerBytes = try header.serializedData() let digest = SHA256.hash(data: headerBytes + tamperedPayload) - let preKey = aliceClient.keys.preKeys[0] + let preKey = try aliceClient.keys.preKeys[0] let signature = try await preKey.sign(Data(digest)) - let bundle = aliceClient.privateKeyBundleV1.toV2().getPublicKeyBundle() + let bundle = try aliceClient.v1keys.toV2().getPublicKeyBundle() let signedContent = SignedContent(payload: originalPayload, sender: bundle, signature: signature) let signedBytes = try signedContent.serializedData() @@ -375,7 +375,7 @@ class ConversationTests: XCTestCase { try await bobConversation.send(content: "Hello") // Now we send some garbage and expect it to be properly ignored. - try await bobClient.apiClient.publish(envelopes: [ + try await bobClient.apiClient!.publish(envelopes: [ Envelope( topic: bobConversation.topic, timestamp: Date(), diff --git a/Tests/XMTPTests/ConversationsTest.swift b/Tests/XMTPTests/ConversationsTest.swift index a1c0823d..90325267 100644 --- a/Tests/XMTPTests/ConversationsTest.swift +++ b/Tests/XMTPTests/ConversationsTest.swift @@ -21,7 +21,7 @@ class ConversationsTests: XCTestCase { let created = Date() let message = try MessageV1.encode( - sender: fixtures.bobClient.privateKeyBundleV1, + sender: try fixtures.bobClient.v1keys, recipient: fixtures.aliceClient.v1keys.toPublicKeyBundle(), message: try TextCodec().encode(content: "hello", client: client).serializedData(), timestamp: created diff --git a/Tests/XMTPTests/IntegrationTests.swift b/Tests/XMTPTests/IntegrationTests.swift index 06a1231d..8cccdd17 100644 --- a/Tests/XMTPTests/IntegrationTests.swift +++ b/Tests/XMTPTests/IntegrationTests.swift @@ -52,7 +52,7 @@ final class IntegrationTests: XCTestCase { try await delayToPropagate() let contact = try await alice.getUserContact(peerAddress: alice.address) - XCTAssertEqual(contact!.v2.keyBundle.identityKey.secp256K1Uncompressed, alice.privateKeyBundleV1.identityKey.publicKey.secp256K1Uncompressed) + XCTAssertEqual(contact!.v2.keyBundle.identityKey.secp256K1Uncompressed, try alice.v1keys.identityKey.publicKey.secp256K1Uncompressed) XCTAssert(contact!.v2.keyBundle.identityKey.hasSignature == true, "no signature") XCTAssert(contact!.v2.keyBundle.preKey.hasSignature == true, "pre key not signed") @@ -474,7 +474,7 @@ final class IntegrationTests: XCTestCase { key.publicKey.secp256K1Uncompressed.bytes = Data(try LibXMTP.publicKeyFromPrivateKeyK256(privateKeyBytes: keyBytes)) let client = try await XMTPiOS.Client.create(account: key) - XCTAssertEqual(client.apiClient.environment, .dev) + XCTAssertEqual(client.environment, .dev) let conversations = try await client.conversations.list() XCTAssertEqual(1, conversations.count) @@ -542,7 +542,7 @@ final class IntegrationTests: XCTestCase { key.publicKey.secp256K1Uncompressed.bytes = Data(try LibXMTP.publicKeyFromPrivateKeyK256(privateKeyBytes: keyBytes)) let client = try await XMTPiOS.Client.create(account: key) - XCTAssertEqual(client.apiClient.environment, .dev) + XCTAssertEqual(client.environment, .dev) let convo = try await client.conversations.list()[0] let message = try await convo.messages()[0] @@ -565,7 +565,7 @@ final class IntegrationTests: XCTestCase { key.publicKey.secp256K1Uncompressed.bytes = Data(try LibXMTP.publicKeyFromPrivateKeyK256(privateKeyBytes: keyBytes)) let client = try await XMTPiOS.Client.create(account: key) - XCTAssertEqual(client.apiClient.environment, .dev) + XCTAssertEqual(client.environment, .dev) let convo = try await client.conversations.list()[0] let message = try await convo.messages().last! diff --git a/Tests/XMTPTests/V3ClientTests.swift b/Tests/XMTPTests/V3ClientTests.swift new file mode 100644 index 00000000..1cbd5a6e --- /dev/null +++ b/Tests/XMTPTests/V3ClientTests.swift @@ -0,0 +1,135 @@ +// +// File.swift +// +// +// Created by Naomi Plasterer on 9/19/24. +// + +import CryptoKit +import XCTest +@testable import XMTPiOS +import LibXMTP +import XMTPTestHelpers + +@available(iOS 16, *) +class V3ClientTests: XCTestCase { + // Use these fixtures to talk to the local node + struct LocalFixtures { + var alixV2: PrivateKey! + var boV3: PrivateKey! + var caroV2V3: PrivateKey! + var alixV2Client: Client! + var boV3Client: Client! + var caroV2V3Client: Client! + } + + func localFixtures() async throws -> LocalFixtures { + let key = try Crypto.secureRandomBytes(count: 32) + let alixV2 = try PrivateKey.generate() + let alixV2Client = try await Client.create( + account: alixV2, + options: .init( + api: .init(env: .local, isSecure: false) + ) + ) + let boV3 = try PrivateKey.generate() + let boV3Client = try await Client.createOrBuild( + account: boV3, + options: .init( + api: .init(env: .local, isSecure: false), + enableV3: true, + encryptionKey: key + ) + ) + let caroV2V3 = try PrivateKey.generate() + let caroV2V3Client = try await Client.create( + account: caroV2V3, + options: .init( + api: .init(env: .local, isSecure: false), + enableV3: true, + encryptionKey: key + ) + ) + + return .init( + alixV2: alixV2, + boV3: boV3, + caroV2V3: caroV2V3, + alixV2Client: alixV2Client, + boV3Client: boV3Client, + caroV2V3Client: caroV2V3Client + ) + } + + func testsCanCreateGroup() async throws { + let fixtures = try await localFixtures() + let group = try await fixtures.boV3Client.conversations.newGroup(with: [fixtures.caroV2V3.address]) + let members = try group.members.map(\.inboxId).sorted() + XCTAssertEqual([fixtures.caroV2V3Client.inboxID, fixtures.boV3Client.inboxID].sorted(), members) + + await assertThrowsAsyncError( + try await fixtures.boV3Client.conversations.newGroup(with: [fixtures.alixV2.address]) + ) + } + + func testsCanSendMessages() async throws { + let fixtures = try await localFixtures() + let group = try await fixtures.boV3Client.conversations.newGroup(with: [fixtures.caroV2V3.address]) + try await group.send(content: "howdy") + let messageId = try await group.send(content: "gm") + try await group.sync() + + let groupMessages = try await group.messages() + XCTAssertEqual(groupMessages.first?.body, "gm") + XCTAssertEqual(groupMessages.first?.id, messageId) + XCTAssertEqual(groupMessages.first?.deliveryStatus, .published) + XCTAssertEqual(groupMessages.count, 3) + + + try await fixtures.caroV2V3Client.conversations.sync() + let sameGroup = try await fixtures.caroV2V3Client.conversations.groups().last + try await sameGroup?.sync() + + let sameGroupMessages = try await sameGroup?.messages() + XCTAssertEqual(sameGroupMessages?.count, 2) + XCTAssertEqual(sameGroupMessages?.first?.body, "gm") + } + + func testCanStreamAllMessagesFromV2andV3Users() async throws { + let fixtures = try await localFixtures() + + let expectation1 = XCTestExpectation(description: "got a conversation") + expectation1.expectedFulfillmentCount = 2 + let convo = try await fixtures.alixV2Client.conversations.newConversation(with: fixtures.caroV2V3.address) + let group = try await fixtures.boV3Client.conversations.newGroup(with: [fixtures.caroV2V3.address]) + try await fixtures.caroV2V3Client.conversations.sync() + Task(priority: .userInitiated) { + for try await _ in await fixtures.caroV2V3Client.conversations.streamAllMessages(includeGroups: true) { + expectation1.fulfill() + } + } + + _ = try await group.send(content: "hi") + _ = try await convo.send(content: "hi") + + await fulfillment(of: [expectation1], timeout: 3) + } + + func testCanStreamGroupsAndConversationsFromV2andV3Users() async throws { + let fixtures = try await localFixtures() + + let expectation1 = XCTestExpectation(description: "got a conversation") + expectation1.expectedFulfillmentCount = 2 + + Task(priority: .userInitiated) { + for try await _ in await fixtures.caroV2V3Client.conversations.streamAll() { + expectation1.fulfill() + } + } + + _ = try await fixtures.boV3Client.conversations.newGroup(with: [fixtures.caroV2V3.address]) + _ = try await fixtures.alixV2Client.conversations.newConversation(with: fixtures.caroV2V3.address) + + await fulfillment(of: [expectation1], timeout: 3) + } +} diff --git a/XMTP.podspec b/XMTP.podspec index b9741002..efe9d3b0 100644 --- a/XMTP.podspec +++ b/XMTP.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |spec| # spec.name = "XMTP" - spec.version = "0.14.13" + spec.version = "0.14.14" spec.summary = "XMTP SDK Cocoapod" # This description is used to generate tags and improve search results. diff --git a/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4d3d866a..73812576 100644 --- a/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/xmtp/libxmtp-swift.git", "state" : { - "revision" : "abd4f896f539e5bb090c85022177d775ad08dcb1", - "version" : "0.5.8-beta4" + "revision" : "9d5153926ac1bfcab76802d5a7626c2cf47212a4", + "version" : "0.5.8-beta5" } }, { diff --git a/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift b/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift index c3f86636..99ca3171 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift @@ -91,7 +91,7 @@ struct ConversationListView: View { } .task { do { - for try await conversation in await client.conversations.stream() { + for try await conversation in try await client.conversations.stream() { conversations.insert(.conversation(conversation), at: 0) await add(conversations: [.conversation(conversation)]) diff --git a/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift b/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift index 8ff68aff..e538603b 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift @@ -25,7 +25,7 @@ enum ConversationOrGroup: Hashable { case .conversation(let conversation): return conversation.topic case .group(let group): - return group.id.toHexString() + return group.id.toHexEncodedString() } }