diff --git a/Sources/XXMessengerClient/Messenger/Functions/MessengerSearchUsers.swift b/Sources/XXMessengerClient/Messenger/Functions/MessengerSearchUsers.swift
new file mode 100644
index 0000000000000000000000000000000000000000..a24a7652de827130a01f145c7df99dcdf7d172dd
--- /dev/null
+++ b/Sources/XXMessengerClient/Messenger/Functions/MessengerSearchUsers.swift
@@ -0,0 +1,93 @@
+import Foundation
+import XCTestDynamicOverlay
+import XXClient
+
+public struct MessengerSearchUsers {
+  public struct Query: Equatable {
+    public init(
+      username: String? = nil,
+      email: String? = nil,
+      phone: String? = nil
+    ) {
+      self.username = username
+      self.email = email
+      self.phone = phone
+    }
+
+    public var username: String?
+    public var email: String?
+    public var phone: String?
+  }
+
+  public enum Error: Swift.Error, Equatable {
+    case notConnected
+    case notLoggedIn
+  }
+
+  public var run: (Query) throws -> [Contact]
+
+  public func callAsFunction(query: Query) throws -> [Contact] {
+    try run(query)
+  }
+}
+
+extension MessengerSearchUsers {
+  public static func live(_ env: MessengerEnvironment) -> MessengerSearchUsers {
+    MessengerSearchUsers { query in
+      guard let e2e = env.e2e() else {
+        throw Error.notConnected
+      }
+      guard let ud = env.ud() else {
+        throw Error.notLoggedIn
+      }
+      var result: Result<[Contact], Swift.Error>!
+      let semaphore = DispatchSemaphore(value: 0)
+      _ = try env.searchUD(
+        e2eId: e2e.getId(),
+        udContact: try ud.getContact(),
+        facts: query.facts,
+        singleRequestParamsJSON: env.getSingleUseParams(),
+        callback: .init { searchResult in
+          switch searchResult {
+          case .success(let contacts):
+            result = .success(contacts)
+          case .failure(let error):
+            result = .failure(error)
+          }
+          semaphore.signal()
+        }
+      )
+      semaphore.wait()
+      return try result.get()
+    }
+  }
+}
+
+extension MessengerSearchUsers.Query {
+  public var isEmpty: Bool {
+    [username, email, phone]
+      .compactMap { $0 }
+      .map { $0.isEmpty == false }
+      .contains(where: { $0 == true }) == false
+  }
+
+  var facts: [Fact] {
+    var facts: [Fact] = []
+    if let username = username, username.isEmpty == false {
+      facts.append(Fact(fact: username, type: 0))
+    }
+    if let email = email, email.isEmpty == false {
+      facts.append(Fact(fact: email, type: 1))
+    }
+    if let phone = phone, phone.isEmpty == false {
+      facts.append(Fact(fact: phone, type: 2))
+    }
+    return facts
+  }
+}
+
+extension MessengerSearchUsers {
+  public static let unimplemented = MessengerSearchUsers(
+    run: XCTUnimplemented("\(Self.self)")
+  )
+}
diff --git a/Sources/XXMessengerClient/Messenger/Messenger.swift b/Sources/XXMessengerClient/Messenger/Messenger.swift
index 652eda396d5d619e7e98892274c88a7f851ba4a9..3fd872c6b3b4bf6ce546081dbbec93c86bd8695a 100644
--- a/Sources/XXMessengerClient/Messenger/Messenger.swift
+++ b/Sources/XXMessengerClient/Messenger/Messenger.swift
@@ -19,6 +19,7 @@ public struct Messenger {
   public var waitForNetwork: MessengerWaitForNetwork
   public var waitForNodes: MessengerWaitForNodes
   public var destroy: MessengerDestroy
+  public var searchUsers: MessengerSearchUsers
 }
 
 extension Messenger {
@@ -41,7 +42,8 @@ extension Messenger {
       logIn: .live(env),
       waitForNetwork: .live(env),
       waitForNodes: .live(env),
-      destroy: .live(env)
+      destroy: .live(env),
+      searchUsers: .live(env)
     )
   }
 }
@@ -65,6 +67,7 @@ extension Messenger {
     logIn: .unimplemented,
     waitForNetwork: .unimplemented,
     waitForNodes: .unimplemented,
-    destroy: .unimplemented
+    destroy: .unimplemented,
+    searchUsers: .unimplemented
   )
 }
diff --git a/Sources/XXMessengerClient/Messenger/MessengerEnvironment.swift b/Sources/XXMessengerClient/Messenger/MessengerEnvironment.swift
index ad94b5ce8b97749231abb6b47ef4547c9c9bba03..f846117fc0b178e0f8161f50678b7967f6a1dc2c 100644
--- a/Sources/XXMessengerClient/Messenger/MessengerEnvironment.swift
+++ b/Sources/XXMessengerClient/Messenger/MessengerEnvironment.swift
@@ -11,6 +11,7 @@ public struct MessengerEnvironment {
   public var generateSecret: GenerateSecret
   public var getCMixParams: GetCMixParams
   public var getE2EParams: GetE2EParams
+  public var getSingleUseParams: GetSingleUseParams
   public var isRegisteredWithUD: IsRegisteredWithUD
   public var loadCMix: LoadCMix
   public var login: Login
@@ -18,6 +19,7 @@ public struct MessengerEnvironment {
   public var newCMix: NewCMix
   public var newOrLoadUd: NewOrLoadUd
   public var passwordStorage: PasswordStorage
+  public var searchUD: SearchUD
   public var sleep: (TimeInterval) -> Void
   public var storageDir: String
   public var ud: Stored<UserDiscovery?>
@@ -43,6 +45,7 @@ extension MessengerEnvironment {
       generateSecret: .live,
       getCMixParams: .liveDefault,
       getE2EParams: .liveDefault,
+      getSingleUseParams: .liveDefault,
       isRegisteredWithUD: .live,
       loadCMix: .live,
       login: .live,
@@ -50,6 +53,7 @@ extension MessengerEnvironment {
       newCMix: .live,
       newOrLoadUd: .live,
       passwordStorage: .keychain,
+      searchUD: .live,
       sleep: { Thread.sleep(forTimeInterval: $0) },
       storageDir: MessengerEnvironment.defaultStorageDir,
       ud: .inMemory(),
@@ -70,6 +74,7 @@ extension MessengerEnvironment {
     generateSecret: .unimplemented,
     getCMixParams: .unimplemented,
     getE2EParams: .unimplemented,
+    getSingleUseParams: .unimplemented,
     isRegisteredWithUD: .unimplemented,
     loadCMix: .unimplemented,
     login: .unimplemented,
@@ -77,6 +82,7 @@ extension MessengerEnvironment {
     newCMix: .unimplemented,
     newOrLoadUd: .unimplemented,
     passwordStorage: .unimplemented,
+    searchUD: .unimplemented,
     sleep: XCTUnimplemented("\(Self.self).sleep"),
     storageDir: "unimplemented",
     ud: .unimplemented(),
diff --git a/Tests/XXMessengerClientTests/Messenger/Functions/MessengerSearchUsersTests.swift b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerSearchUsersTests.swift
new file mode 100644
index 0000000000000000000000000000000000000000..b6eaac39a1d79cfbf665e7b9a8dc90fab2d161af
--- /dev/null
+++ b/Tests/XXMessengerClientTests/Messenger/Functions/MessengerSearchUsersTests.swift
@@ -0,0 +1,201 @@
+import CustomDump
+import XCTest
+import XXClient
+@testable import XXMessengerClient
+
+final class MessengerSearchUsersTests: XCTestCase {
+  func testSearch() throws {
+    struct SearchUdParams: Equatable {
+      var e2eId: Int
+      var udContact: Contact
+      var facts: [Fact]
+      var singleRequestParamsJSON: Data
+    }
+
+    var didSearchUdWithParams: [SearchUdParams] = []
+
+    var env: MessengerEnvironment = .unimplemented
+    env.e2e.get = {
+      var e2e: E2E = .unimplemented
+      e2e.getId.run = { 123 }
+      return e2e
+    }
+    env.ud.get = {
+      var ud: UserDiscovery = .unimplemented
+      ud.getContact.run = { .unimplemented("ud-contact".data(using: .utf8)!) }
+      return ud
+    }
+    env.getSingleUseParams.run = { "single-use-params".data(using: .utf8)! }
+    env.searchUD.run = { e2eId, udContact, facts, singleRequestParamsJSON, callback in
+      didSearchUdWithParams.append(.init(
+        e2eId: e2eId,
+        udContact: udContact,
+        facts: facts,
+        singleRequestParamsJSON: singleRequestParamsJSON
+      ))
+      callback.handle(.success([
+        .unimplemented("contact-1".data(using: .utf8)!),
+        .unimplemented("contact-2".data(using: .utf8)!),
+        .unimplemented("contact-3".data(using: .utf8)!),
+      ]))
+      return SingleUseSendReport(rounds: [], ephId: 0, receptionId: Data())
+    }
+
+    let search: MessengerSearchUsers = .live(env)
+    let query = MessengerSearchUsers.Query(
+      username: "Username",
+      email: "Email",
+      phone: "Phone"
+    )
+
+    let contacts = try search(query: query)
+
+    XCTAssertNoDifference(didSearchUdWithParams, [.init(
+      e2eId: 123,
+      udContact: .unimplemented("ud-contact".data(using: .utf8)!),
+      facts: query.facts,
+      singleRequestParamsJSON: "single-use-params".data(using: .utf8)!
+    )])
+
+    XCTAssertNoDifference(contacts, [
+      .unimplemented("contact-1".data(using: .utf8)!),
+      .unimplemented("contact-2".data(using: .utf8)!),
+      .unimplemented("contact-3".data(using: .utf8)!),
+    ])
+  }
+
+  func testSearchNotConnected() {
+    var env: MessengerEnvironment = .unimplemented
+    env.e2e.get = { nil }
+    let search: MessengerSearchUsers = .live(env)
+
+    XCTAssertThrowsError(try search(query: .init())) { error in
+      XCTAssertNoDifference(error as? MessengerSearchUsers.Error, .notConnected)
+    }
+  }
+
+  func testSearchNotLoggedIn() {
+    var env: MessengerEnvironment = .unimplemented
+    env.e2e.get = { .unimplemented }
+    env.ud.get = { nil }
+    let search: MessengerSearchUsers = .live(env)
+
+    XCTAssertThrowsError(try search(query: .init())) { error in
+      XCTAssertNoDifference(error as? MessengerSearchUsers.Error, .notLoggedIn)
+    }
+  }
+
+  func testSearchFailure() {
+    struct Failure: Error, Equatable {}
+    let error = Failure()
+
+    var env: MessengerEnvironment = .unimplemented
+    env.e2e.get = {
+      var e2e: E2E = .unimplemented
+      e2e.getId.run = { 0 }
+      return e2e
+    }
+    env.ud.get = {
+      var ud: UserDiscovery = .unimplemented
+      ud.getContact.run = { .unimplemented(Data()) }
+      return ud
+    }
+    env.getSingleUseParams.run = { Data() }
+    env.searchUD.run = { _, _, _, _, _ in throw error }
+
+    let search: MessengerSearchUsers = .live(env)
+
+    XCTAssertThrowsError(try search(query: .init())) { err in
+      XCTAssertNoDifference(err as? Failure, error)
+    }
+  }
+
+  func testSearchCallbackFailure() {
+    struct Failure: Error, Equatable {}
+    let error = Failure()
+
+    var env: MessengerEnvironment = .unimplemented
+    env.e2e.get = {
+      var e2e: E2E = .unimplemented
+      e2e.getId.run = { 0 }
+      return e2e
+    }
+    env.ud.get = {
+      var ud: UserDiscovery = .unimplemented
+      ud.getContact.run = { .unimplemented(Data()) }
+      return ud
+    }
+    env.getSingleUseParams.run = { Data() }
+    env.searchUD.run = { _, _, _, _, callback in
+      callback.handle(.failure(error as NSError))
+      return SingleUseSendReport(rounds: [], ephId: 0, receptionId: Data())
+    }
+
+    let search: MessengerSearchUsers = .live(env)
+
+    XCTAssertThrowsError(try search(query: .init())) { err in
+      XCTAssertNoDifference(err as? Failure, error)
+    }
+  }
+
+  func testQueryIsEmpty() {
+    let emptyQueries: [MessengerSearchUsers.Query] = [
+      .init(username: nil, email: nil, phone: nil),
+      .init(username: "", email: nil, phone: nil),
+      .init(username: nil, email: "", phone: nil),
+      .init(username: nil, email: nil, phone: ""),
+      .init(username: "", email: "", phone: ""),
+    ]
+
+    emptyQueries.forEach { query in
+      XCTAssertTrue(query.isEmpty, "\(query) should be empty")
+    }
+
+    let nonEmptyQueries: [MessengerSearchUsers.Query] = [
+      .init(username: "test", email: nil, phone: nil),
+      .init(username: nil, email: "test", phone: nil),
+      .init(username: nil, email: nil, phone: "test"),
+      .init(username: "a", email: "b", phone: "c"),
+    ]
+
+    nonEmptyQueries.forEach { query in
+      XCTAssertFalse(query.isEmpty, "\(query) should not be empty")
+    }
+  }
+
+  func testQueryFacts() {
+    XCTAssertNoDifference(
+      MessengerSearchUsers.Query(username: nil, email: nil, phone: nil).facts,
+      []
+    )
+
+    XCTAssertNoDifference(
+      MessengerSearchUsers.Query(username: "", email: "", phone: "").facts,
+      []
+    )
+
+    XCTAssertNoDifference(
+      MessengerSearchUsers.Query(
+        username: "username",
+        email: "email",
+        phone: "phone"
+      ).facts,
+      [
+        Fact(fact: "username", type: 0),
+        Fact(fact: "email", type: 1),
+        Fact(fact: "phone", type: 2),
+      ]
+    )
+
+    XCTAssertNoDifference(
+      MessengerSearchUsers.Query(
+        username: "username",
+        email: "",
+        phone: nil
+      ).facts,
+      [
+        Fact(fact: "username", type: 0),
+      ]
+    )
+  }
+}