diff options
author | Michael J <37635304+buttercat1791@users.noreply.github.com> | 2024-05-09 08:28:50 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-05-09 08:28:50 -0500 |
commit | 663fb4e7199e1b4318a5bc107096f6a529823e02 (patch) | |
tree | 7d92570de852f97550f84140bd794fa5d64c567b | |
parent | 0d87b4053983ec8edaff5b73491b717866876586 (diff) | |
parent | d6faf6c815611450d1b61045b53525d7f25ac5c9 (diff) |
Merge pull request #3 from buttercat1791/relay-readv0.0.2
Full Relay Read/Write Support
-rw-r--r-- | .gitignore | 13 | ||||
-rw-r--r-- | CMakeLists.txt | 81 | ||||
-rw-r--r-- | CMakePresets.json | 26 | ||||
-rw-r--r-- | README.md | 45 | ||||
-rw-r--r-- | include/client/web_socket_client.hpp | 25 | ||||
-rw-r--r-- | include/nostr.hpp | 269 | ||||
-rw-r--r-- | src/client/websocketpp_client.cpp | 30 | ||||
-rw-r--r-- | src/event.cpp | 154 | ||||
-rw-r--r-- | src/filters.cpp | 67 | ||||
-rw-r--r-- | src/nostr_service.cpp | 504 | ||||
-rw-r--r-- | test/nostr_service_test.cpp | 811 | ||||
-rw-r--r-- | vcpkg-configuration.json | 14 | ||||
-rw-r--r-- | vcpkg.json | 8 |
13 files changed, 1853 insertions, 194 deletions
@@ -36,3 +36,16 @@ build/ # VS Code Settings .vscode/ + +# CMake outputs +_deps/ +CMakeFiles/ +out/ +Testing/ +Makefile +CTestTestfile.cmake +CMakeCache.txt +cmake_install.cmake + +# vcpkg +vcpkg_installed/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d472c1..ce940bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,18 +1,61 @@ -cmake_minimum_required(VERSION 3.14) -project(NostrSDK VERSION 0.0.1) +cmake_minimum_required(VERSION 3.19) +cmake_policy(SET CMP0135 NEW) +project(aedile VERSION 0.0.2) + +include(ExternalProject) +include(FetchContent) # Specify the C++ standard set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -march=native") + +get_directory_property(HAS_PARENT PARENT_DIRECTORY) +if(HAS_PARENT) + message(STATUS "Configuring as a subproject.") -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/../out/${CMAKE_BUILD_TYPE}/bin/) + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/../out/${CMAKE_BUILD_TYPE}/lib/) + set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/../out/${CMAKE_BUILD_TYPE}/lib/) + set(CMAKE_INSTALL_PREFIX ${CMAKE_SOURCE_DIR}/../env/) -# Build the project. + if(DEFINED ENV{WORKSPACE}) + list(APPEND CMAKE_PREFIX_PATH $ENV{WORKSPACE}/env) + else() + list(APPEND CMAKE_PREFIX_PATH ${CMAKE_SOURCE_DIR}/../env) + endif() +else() + message(STATUS "Configuring as a standalone project.") + + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/${CMAKE_BUILD_TYPE}/bin/) + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/${CMAKE_BUILD_TYPE}/lib/) + set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/${CMAKE_BUILD_TYPE}/lib/) +endif() + +#======== Find dependencies ========# +find_package(nlohmann_json CONFIG REQUIRED) +find_package(OpenSSL REQUIRED) +find_package(plog CONFIG REQUIRED) +find_package(websocketpp CONFIG REQUIRED) + +#======== Configure uuid_v4 ========# +FetchContent_Declare( + uuid_v4 + GIT_REPOSITORY git@github.com:crashoz/uuid_v4.git + GIT_TAG v1.0.0 +) +FetchContent_Populate(uuid_v4) +set(uuid_v4_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/_deps/uuid_v4-src/) + +find_path(uuid_v4_INCLUDE_DIR uuid_v4.h) +include_directories(${uuid_v4_INCLUDE_DIR}) + +#======== Build the project ========# set(INCLUDE_DIR ./include) set(CLIENT_INCLUDE_DIR ./include/client) include_directories(${INCLUDE_DIR}) include_directories(${CLIENT_INCLUDE_DIR}) + set(HEADERS ${INCLUDE_DIR}/nostr.hpp ${CLIENT_INCLUDE_DIR}/web_socket_client.hpp @@ -22,33 +65,25 @@ set(SOURCE_DIR ./src) set(CLIENT_SOURCE_DIR ./src/client) set(SOURCES ${SOURCE_DIR}/event.cpp + ${SOURCE_DIR}/filters.cpp ${SOURCE_DIR}/nostr_service.cpp ${CLIENT_SOURCE_DIR}/websocketpp_client.cpp ) -find_package(Boost REQUIRED COMPONENTS random system) -find_package(nlohmann_json CONFIG REQUIRED) -find_package(OpenSSL REQUIRED) -find_package(plog CONFIG REQUIRED) -find_package(websocketpp CONFIG REQUIRED) - -add_library(NostrSDK ${SOURCES} ${HEADERS}) -target_link_libraries(NostrSDK PRIVATE - Boost::random - Boost::system +add_library(aedile ${SOURCES} ${HEADERS}) +target_link_libraries(aedile PRIVATE nlohmann_json::nlohmann_json OpenSSL::SSL OpenSSL::Crypto plog::plog websocketpp::websocketpp ) -set_target_properties(NostrSDK PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) +set_target_properties(aedile PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) -# Build the tests. +#======== Build the tests ========# enable_testing() include(GoogleTest) -include(FetchContent) FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip @@ -64,15 +99,15 @@ set(TEST_SOURCES ${TEST_DIR}/nostr_service_test.cpp ) -add_executable(NostrSDKTest ${TEST_SOURCES} ${HEADERS}) -target_link_libraries(NostrSDKTest PRIVATE +add_executable(aedile_test ${TEST_SOURCES} ${HEADERS}) +target_link_libraries(aedile_test PRIVATE GTest::gmock GTest::gtest GTest::gtest_main - NostrSDK + aedile plog::plog websocketpp::websocketpp ) -set_target_properties(NostrSDKTest PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) +set_target_properties(aedile_test PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES) -gtest_add_tests(TARGET NostrSDKTest) +gtest_add_tests(TARGET aedile_test) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..d28f1a5 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,26 @@ +{ + "version": 2, + "configurePresets": [ + { + "name": "linux", + "generator": "Unix Makefiles", + "binaryDir": "${sourceDir}/build/linux", + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" + } + } + ], + "buildPresets": [ + { + "name": "linux", + "configurePreset": "linux", + "jobs": 4 + } + ], + "testPresets": [ + { + "name": "linux", + "configurePreset": "linux" + } + ] + } @@ -1,2 +1,43 @@ -# NostrSDK -C++ System Development Kit for Nostr +# Aedile + +A Nostr System Development Kit written in C++. + +## Behind the Name + +In the ancient Roman Republic, the aediles were officials elected from among the plebians and charged with caring for Rome's public infrastructure and ensuring an accurate system of weights and measures. + +The aim of the Aedile SDK is in the spirit of that ancient office: + +- Provide a fast and efficient service for interacting with Nostr relays via WebSocket connections. +- Offer stable, well-tested implementations of commonly-used [Nostr Implementation Possibilities (NIPs)](https://github.com/nostr-protocol/nips/tree/master). +- Open up Nostr development by taking care of the basics so developers can focus on solving problems, rather than reimplementing the protocol. + +## Building the SDK + +### Prerequisites + +This project uses CMake as its build system, and vcpkg as its dependency manager. Thus, to build the SDK, you will need the following: + +- CMake 3.19 or later +- A C++17 compiler +- vcpkg + +CMake invokes vcpkg at the start of the configure process to install some of the project's dependencies. For this step to succeed, ensure that the `VCPKG_ROOT` environment variable is set to the path of your vcpkg installation. + +### Building and Testing + +The SDK aims to support Linux, Windows, and macOS build targets. It currently supplies a CMake preset for Linux. + +#### Linux + +To build the SDK on Linux, run the following commands from the project root: + +```bash +cmake --build --preset linux +``` + +To run unit tests, use the following command: + +```bash +ctest --preset linux +``` diff --git a/include/client/web_socket_client.hpp b/include/client/web_socket_client.hpp index 0f58749..63fa634 100644 --- a/include/client/web_socket_client.hpp +++ b/include/client/web_socket_client.hpp @@ -1,7 +1,11 @@ #pragma once +#include <functional> #include <string> +#include <websocketpp/client.hpp> +#include <websocketpp/config/asio_client.hpp> + namespace client { /** @@ -42,6 +46,27 @@ public: virtual std::tuple<std::string, bool> send(std::string message, std::string uri) = 0; /** + * @brief Sends the given message to the given server and sets up a message handler for + * messages received from the server. + * @returns A tuple indicating the server URI and whether the message was successfully + * sent. + * @remark Use this method to send a message and set up a message handler for responses in the + * same call. + */ + virtual std::tuple<std::string, bool> send( + std::string message, + std::string uri, + std::function<void(const std::string&)> messageHandler) = 0; + + /** + * @brief Sets up a message handler for the given server. + * @param uri The URI of the server to which the message handler should be attached. + * @param messageHandler A callable object that will be invoked with the payload the client + * receives from the server. + */ + virtual void receive(std::string uri, std::function<void(const std::string&)> messageHandler) = 0; + + /** * @brief Closes the connection to the given server. */ virtual void closeConnection(std::string uri) = 0; diff --git a/include/nostr.hpp b/include/nostr.hpp index 47b56f9..e5b29c7 100644 --- a/include/nostr.hpp +++ b/include/nostr.hpp @@ -1,22 +1,29 @@ #pragma once +#include <algorithm> +#include <functional> #include <mutex> #include <string> #include <tuple> +#include <unordered_map> #include <vector> #include <nlohmann/json.hpp> +#include <openssl/evp.h> +#include <openssl/sha.h> +#include <plog/Init.h> #include <plog/Log.h> #include <websocketpp/client.hpp> #include <websocketpp/config/asio_client.hpp> +#include <uuid_v4.h> #include "client/web_socket_client.hpp" namespace nostr { -typedef std::vector<std::string> RelayList; +class ISigner; +class NostrService; -// TODO: Add null checking to seralization and deserialization methods. /** * @brief A Nostr event. * @remark All data transmitted over the Nostr protocol is encoded in JSON blobs. This struct @@ -27,39 +34,126 @@ struct Event { std::string id; ///< SHA-256 hash of the event data. std::string pubkey; ///< Public key of the event creator. - std::string created_at; ///< Unix timestamp of the event creation. + std::time_t createdAt; ///< Unix timestamp of the event creation. int kind; ///< Event kind. std::vector<std::vector<std::string>> tags; ///< Arbitrary event metadata. std::string content; ///< Event content. std::string sig; ///< Event signature created with the private key of the event creator. - nlohmann::json serialize() const; - void deserialize(std::string jsonString); + /** + * @brief Serializes the event to a JSON object. + * @returns A stringified JSON object representing the event. + * @throws `std::invalid_argument` if the event object is invalid. + */ + std::string serialize(); + + /** + * @brief Deserializes the event from a JSON string. + * @param jsonString A stringified JSON object representing the event. + * @returns An event instance created from the JSON string. + */ + static Event fromString(std::string jsonString); + + /** + * @brief Deserializes the event from a JSON object. + * @param j A JSON object representing the event. + * @returns An event instance created from the JSON object. + */ + static Event fromJson(nlohmann::json j); + + /** + * @brief Compares two events for equality. + * @remark Two events are considered equal if they have the same ID, since the ID is uniquely + * generated from the event data. If the `id` field is empty for either event, the comparison + * function will throw an exception. + */ + bool operator==(const Event& other) const; + +private: + /** + * @brief Validates the event. + * @throws `std::invalid_argument` if the event object is invalid. + * @remark The `createdAt` field defaults to the present if it is not already set. + */ + void validate(); + + /** + * @brief Generates an ID for the event. + * @param serializedData The serialized JSON string of all of the event data except the ID and + * the signature. + * @return A valid Nostr event ID. + * @remark The ID is a 32-bytes lowercase hex-encoded sha256 of the serialized event data. + */ + std::string generateId(std::string serializedData) const; +}; + +/** + * @brief A set of filters for querying Nostr relays. + * @remark The `limit` field should always be included to keep the response size reasonable. The + * `since` field is not required, and the `until` field will default to the present. At least one + * of the other fields must be set for a valid filter. + */ +struct Filters +{ + std::vector<std::string> ids; ///< Event IDs. + std::vector<std::string> authors; ///< Event author npubs. + std::vector<int> kinds; ///< Kind numbers. + std::unordered_map<std::string, std::vector<std::string>> tags; ///< Tag names mapped to lists of tag values. + std::time_t since; ///< Unix timestamp. Matching events must be newer than this. + std::time_t until; ///< Unix timestamp. Matching events must be older than this. + int limit; ///< The maximum number of events the relay should return on the initial query. + + /** + * @brief Serializes the filters to a JSON object. + * @param subscriptionId A string up to 64 chars in length that is unique per relay connection. + * @returns A stringified JSON object representing the filters. + * @throws `std::invalid_argument` if the filter object is invalid. + * @remarks The Nostr client is responsible for managing subscription IDs. Responses from the + * relay will be organized by subscription ID. + */ + std::string serialize(std::string& subscriptionId); + +private: + /** + * @brief Validates the filters. + * @throws `std::invalid_argument` if the filter object is invalid. + * @remark The `until` field defaults to the present if it is not already set. + */ + void validate(); }; class NostrService { public: - NostrService(plog::IAppender* appender, client::IWebSocketClient* client); - NostrService(plog::IAppender* appender, client::IWebSocketClient* client, RelayList relays); + NostrService( + std::shared_ptr<plog::IAppender> appender, + std::shared_ptr<client::IWebSocketClient> client, + std::shared_ptr<ISigner> signer); + NostrService( + std::shared_ptr<plog::IAppender> appender, + std::shared_ptr<client::IWebSocketClient> client, + std::shared_ptr<ISigner> signer, + std::vector<std::string> relays); ~NostrService(); - RelayList defaultRelays() const; + std::vector<std::string> defaultRelays() const; - RelayList activeRelays() const; + std::vector<std::string> activeRelays() const; + + std::unordered_map<std::string, std::vector<std::string>> subscriptions() const; /** * @brief Opens connections to the default Nostr relays of the instance, as specified in * the constructor. * @return A list of the relay URLs to which connections were successfully opened. */ - RelayList openRelayConnections(); + std::vector<std::string> openRelayConnections(); /** * @brief Opens connections to the specified Nostr relays. * @returns A list of the relay URLs to which connections were successfully opened. */ - RelayList openRelayConnections(RelayList relays); + std::vector<std::string> openRelayConnections(std::vector<std::string> relays); /** * @brief Closes all open relay connections. @@ -69,35 +163,107 @@ public: /** * @brief Closes any open connections to the specified Nostr relays. */ - void closeRelayConnections(RelayList relays); + void closeRelayConnections(std::vector<std::string> relays); /** * @brief Publishes a Nostr event to all open relay connections. - * @returns A tuple of `RelayList` objects, of the form `<successes, failures>`, indicating + * @returns A tuple of `std::vector<std::string>` objects, of the form `<successes, failures>`, indicating * to which relays the event was published successfully, and to which relays the event failed * to publish. - */ - std::tuple<RelayList, RelayList> publishEvent(Event event); + */ + std::tuple<std::vector<std::string>, std::vector<std::string>> publishEvent(std::shared_ptr<Event> event); - // TODO: Add methods for reading events from relays. + /** + * @brief Queries all open relay connections for events matching the given set of filters, and + * returns all stored matching events returned by the relays. + * @param filters The filters to use for the query. + * @returns A vector of all events matching the filters from all open relay connections. + * @remark This method runs until the relays send an EOSE message, indicating they have no more + * stored events matching the given filters. When the EOSE message is received, the method + * will close the subscription for each relay and return the received events. + * @remark Use this method to fetch a batch of events from the relays. A `limit` value must be + * set on the filters in the range 1-64, inclusive. If no valid limit is given, it will be + * defaulted to 16. + */ + std::vector<std::shared_ptr<Event>> queryRelays(std::shared_ptr<Filters> filters); + + /** + * @brief Queries all open relay connections for events matching the given set of filters. + * @param filters The filters to use for the query. + * @param eventHandler A callable object that will be invoked each time the client receives + * an event matching the filters. + * @param eoseHandler A callable object that will be invoked when the relay sends an EOSE + * message. + * @param closeHandler A callable object that will be invoked when the relay sends a CLOSE + * message. + * @returns The ID of the subscription created for the query. + * @remark By providing a response handler, the caller assumes responsibility for handling all + * events returned from the relay for the given filters. The service will not store the + * events, and they will not be accessible via `getNewEvents`. + */ + std::string queryRelays( + std::shared_ptr<Filters> filters, + std::function<void(const std::string&, std::shared_ptr<Event>)> eventHandler, + std::function<void(const std::string&)> eoseHandler, + std::function<void(const std::string&, const std::string&)> closeHandler); + + /** + * @brief Closes the subscription with the given ID on all open relay connections. + * @returns A tuple of `std::vector<std::string>` objects, of the form `<successes, failures>`, indicating + * to which relays the message was sent successfully, and which relays failed to receive the + * message. + */ + std::tuple<std::vector<std::string>, std::vector<std::string>> closeSubscription(std::string subscriptionId); + + /** + * @brief Closes the subscription with the given ID on the given relay. + * @returns True if the relay received the CLOSE message, false otherwise. + * @remark If the subscription does not exist on the given relay, or if the relay is not + * connected, the method will do nothing and return false. + */ + bool closeSubscription(std::string subscriptionId, std::string relay); + + /** + * @brief Closes all open subscriptions on all open relay connections. + * @returns A list of any subscription IDs that failed to close. + */ + std::vector<std::string> closeSubscriptions(); + + /** + * @brief Closes all open subscriptions on the given relays. + * @returns A list of any subscription IDs that failed to close. + */ + std::vector<std::string> closeSubscriptions(std::vector<std::string> relays); private: + ///< The maximum number of events the service will store for each subscription. + const int MAX_EVENTS_PER_SUBSCRIPTION = 128; + + ///< The WebSocket client used to communicate with relays. + std::shared_ptr<client::IWebSocketClient> _client; + ///< The signer used to sign Nostr events. + std::shared_ptr<ISigner> _signer; + + ///< A mutex to protect the instance properties. std::mutex _propertyMutex; - RelayList _defaultRelays; - RelayList _activeRelays; - client::IWebSocketClient* _client; + ///< The default set of Nostr relays to which the service will attempt to connect. + std::vector<std::string> _defaultRelays; + ///< The set of Nostr relays to which the service is currently connected. + std::vector<std::string> _activeRelays; + ///< A map from subscription IDs to the relays on which each subscription is open. + std::unordered_map<std::string, std::vector<std::string>> _subscriptions; /** * @brief Determines which of the given relays are currently connected. * @returns A list of the URIs of currently-open relay connections from the given list. */ - RelayList getConnectedRelays(RelayList relays); + std::vector<std::string> getConnectedRelays(std::vector<std::string> relays); /** * @brief Determines which of the given relays are not currently connected. * @returns A list of the URIs of currently-unconnected relays from the given list. */ - RelayList getUnconnectedRelays(RelayList relays); + std::vector<std::string> getUnconnectedRelays(std::vector<std::string> relays); /** * @brief Determines whether the given relay is currently connected. @@ -119,5 +285,66 @@ private: * @brief Closes the connection from the client to the given relay. */ void disconnect(std::string relay); + + /** + * @brief Generates a unique subscription ID that may be used to identify event requests. + * @returns A stringified UUID. + */ + std::string generateSubscriptionId(); + + /** + * @brief Generates a message requesting a relay to close the subscription with the given ID. + * @returns A stringified JSON object representing the close request. + */ + std::string generateCloseRequest(std::string subscriptionId); + + /** + * @brief Indicates whether the the service has an open subscription with the given ID. + * @returns True if the service has the subscription, false otherwise. + */ + bool hasSubscription(std::string subscriptionId); + + /** + * @brief Indicates whether the service has an open subscription with the given ID on the given + * relay. + * @returns True if the subscription exists on the relay, false otherwise. + */ + bool hasSubscription(std::string subscriptionId, std::string relay); + + /** + * @brief Parses EVENT messages received from the relay and invokes the given event handler. + * @param message The raw message received from the relay. + * @param eventHandler A callable object that will be invoked with the subscription ID and the + * payload of the event. + * @param eoseHandler A callable object that will be invoked with the subscription ID when the + * relay sends an EOSE message, indicating it has reached the end of stored events for the + * given query. + * @param closeHandler A callable object that will be invoked with the subscription ID and the + * message sent by the relay if the subscription is ended by the relay. + */ + void onSubscriptionMessage( + std::string message, + std::function<void(const std::string&, std::shared_ptr<Event>)> eventHandler, + std::function<void(const std::string&)> eoseHandler, + std::function<void(const std::string&, const std::string&)> closeHandler); + + /** + * @brief Parses OK messages received from the relay and invokes the given acceptance handler. + * @remark The OK message type is sent to indicate whether the relay has accepted an event sent + * by the client. Note that this is distinct from whether the message was successfully sent to + * the relay over the WebSocket connection. + */ + void onAcceptance(std::string message, std::function<void(const bool)> acceptanceHandler); +}; + +class ISigner +{ +public: + /** + * @brief Signs the given Nostr event. + * @param event The event to sign. + * @remark The event's `sig` field will be updated in-place with the signature. + */ + virtual void sign(std::shared_ptr<Event> event) = 0; }; } // namespace nostr diff --git a/src/client/websocketpp_client.cpp b/src/client/websocketpp_client.cpp index 1386e1a..baae054 100644 --- a/src/client/websocketpp_client.cpp +++ b/src/client/websocketpp_client.cpp @@ -1,9 +1,7 @@ -#include <websocketpp/client.hpp> -#include <websocketpp/config/asio_client.hpp> - #include "web_socket_client.hpp" using std::error_code; +using std::function; using std::lock_guard; using std::make_tuple; using std::mutex; @@ -76,13 +74,33 @@ public: if (error.value() == -1) { - // PLOG_ERROR << "Error publishing event to relay " << relay << ": " << error.message(); return make_tuple(uri, false); } return make_tuple(uri, true); }; + tuple<string, bool> send(string message, string uri, function<void(const string&)> messageHandler) override + { + auto successes = this->send(message, uri); + this->receive(uri, messageHandler); + return successes; + }; + + void receive(string uri, function<void(const string&)> messageHandler) override + { + lock_guard<mutex> lock(this->_propertyMutex); + auto connectionHandle = this->_connectionHandles[uri]; + auto connection = this->_client.get_con_from_hdl(connectionHandle); + + connection->set_message_handler([messageHandler]( + websocketpp::connection_hdl connectionHandle, + websocketpp_client::message_ptr message) + { + messageHandler(message->get_payload()); + }); + }; + void closeConnection(string uri) override { lock_guard<mutex> lock(this->_propertyMutex); @@ -103,5 +121,9 @@ private: websocketpp_client _client; unordered_map<string, websocketpp::connection_hdl> _connectionHandles; mutex _propertyMutex; + + void onMessage(websocketpp::connection_hdl handle, websocketpp_client::message_ptr message) + { + }; }; } // namespace client diff --git a/src/event.cpp b/src/event.cpp index 75f2ee8..703efae 100644 --- a/src/event.cpp +++ b/src/event.cpp @@ -1,38 +1,126 @@ -#pragma once - -#include <string> -#include <vector> -#include <nlohmann/json.hpp> +#include <ctime> #include "nostr.hpp" -using std::string; +using namespace nlohmann; +using namespace std; + +namespace nostr +{ +string Event::serialize() +{ + try + { + this->validate(); + } + catch (const invalid_argument& e) + { + throw e; + } + + json j = { + {"pubkey", this->pubkey}, + {"created_at", this->createdAt}, + {"kind", this->kind}, + {"tags", this->tags}, + {"content", this->content}, + {"sig", this->sig}}; + + j["id"] = this->generateId(j.dump()); + + return j.dump(); +}; + +Event Event::fromString(string jstr) +{ + json j = json::parse(jstr); + Event event; + + try + { + event = Event::fromJson(j); + } + catch (const invalid_argument& e) + { + throw e; + } + + return event; +}; + +Event Event::fromJson(json j) +{ + Event event; + + try { + event.id = j.at("id"); + event.pubkey = j.at("pubkey"); + event.createdAt = j.at("created_at"); + event.kind = j.at("kind"); + event.tags = j.at("tags"); + event.content = j.at("content"); + event.sig = j.at("sig"); + } catch (const json::out_of_range& e) { + ostringstream oss; + oss << "Event::fromJson: Tried to access an out-of-range element: " << e.what(); + throw invalid_argument(oss.str()); + } -namespace nostr + return event; +}; + +void Event::validate() +{ + bool hasPubkey = this->pubkey.length() > 0; + if (!hasPubkey) + { + throw std::invalid_argument("Event::validate: The pubkey of the event author is required."); + } + + bool hasCreatedAt = this->createdAt > 0; + if (!hasCreatedAt) + { + this->createdAt = time(nullptr); + } + + bool hasKind = this->kind >= 0 && this->kind < 40000; + if (!hasKind) + { + throw std::invalid_argument("Event::validate: A valid event kind is required."); + } + + bool hasSignature = this->sig.length() > 0; + if (!hasSignature) + { + throw std::invalid_argument("Event::validate: The event must be signed."); + } +}; + +string Event::generateId(string serializedData) const { - nlohmann::json Event::serialize() const - { - nlohmann::json j = { - {"id", this->id}, - {"pubkey", this->pubkey}, - {"created_at", this->created_at}, - {"kind", this->kind}, - {"tags", this->tags}, - {"content", this->content}, - {"sig", this->sig} - }; - return j.dump(); - }; - - void Event::deserialize(string jsonString) - { - nlohmann::json j = nlohmann::json::parse(jsonString); - this->id = j["id"]; - this->pubkey = j["pubkey"]; - this->created_at = j["created_at"]; - this->kind = j["kind"]; - this->tags = j["tags"]; - this->content = j["content"]; - this->sig = j["sig"]; - }; -} + unsigned char hash[SHA256_DIGEST_LENGTH]; + EVP_Digest(serializedData.c_str(), serializedData.length(), hash, NULL, EVP_sha256(), NULL); + + stringstream ss; + for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) + { + ss << hex << setw(2) << setfill('0') << (int)hash[i]; + } + + return ss.str(); +}; + +bool Event::operator==(const Event& other) const +{ + if (this->id.empty()) + { + throw invalid_argument("Event::operator==: Cannot check equality, the left-side argument is undefined."); + } + if (other.id.empty()) + { + throw invalid_argument("Event::operator==: Cannot check equality, the right-side argument is undefined."); + } + + return this->id == other.id; +}; +} // namespace nostr diff --git a/src/filters.cpp b/src/filters.cpp new file mode 100644 index 0000000..40596eb --- /dev/null +++ b/src/filters.cpp @@ -0,0 +1,67 @@ +#include "nostr.hpp" + +using namespace nlohmann; +using namespace std; + +namespace nostr +{ +string Filters::serialize(string& subscriptionId) +{ + try + { + this->validate(); + } + catch (const invalid_argument& e) + { + throw e; + } + + json j = { + {"ids", this->ids}, + {"authors", this->authors}, + {"kinds", this->kinds}, + {"since", this->since}, + {"until", this->until}, + {"limit", this->limit}}; + + for (auto& tag : this->tags) + { + stringstream ss; + ss << "#" << tag.first; + string tagname = ss.str(); + + j[tagname] = tag.second; + } + + json jarr = json::array({ "REQ", subscriptionId, j }); + + return jarr.dump(); +}; + +void Filters::validate() +{ + bool hasLimit = this->limit > 0; + if (!hasLimit) + { + throw invalid_argument("Filters::validate: The limit must be greater than 0."); + } + + bool hasUntil = this->until > 0; + if (!hasUntil) + { + this->until = time(nullptr); + } + + bool hasIds = this->ids.size() > 0; + bool hasAuthors = this->authors.size() > 0; + bool hasKinds = this->kinds.size() > 0; + bool hasTags = this->tags.size() > 0; + + bool hasFilter = hasIds || hasAuthors || hasKinds || hasTags; + + if (!hasFilter) + { + throw invalid_argument("Filters::validate: At least one filter must be set."); + } +}; +} // namespace nostr diff --git a/src/nostr_service.cpp b/src/nostr_service.cpp index 09be6e3..664243f 100644 --- a/src/nostr_service.cpp +++ b/src/nostr_service.cpp @@ -1,53 +1,48 @@ -#include <plog/Init.h> -#include <plog/Log.h> -#include <websocketpp/client.hpp> -#include <websocketpp/config/asio_client.hpp> - #include "nostr.hpp" #include "client/web_socket_client.hpp" -using std::async; -using std::future; -using std::lock_guard; -using std::make_tuple; -using std::move; -using std::mutex; -using std::string; -using std::thread; -using std::tuple; -using std::vector; +using namespace nlohmann; +using namespace std; namespace nostr { -NostrService::NostrService(plog::IAppender* appender, client::IWebSocketClient* client) - : NostrService(appender, client, {}) { }; - -NostrService::NostrService(plog::IAppender* appender, client::IWebSocketClient* client, RelayList relays) - : _defaultRelays(relays), _client(client) -{ - plog::init(plog::debug, appender); +NostrService::NostrService( + shared_ptr<plog::IAppender> appender, + shared_ptr<client::IWebSocketClient> client, + shared_ptr<ISigner> signer) +: NostrService(appender, client, signer, {}) { }; + +NostrService::NostrService( + shared_ptr<plog::IAppender> appender, + shared_ptr<client::IWebSocketClient> client, + shared_ptr<ISigner> signer, + vector<string> relays) +: _defaultRelays(relays), _client(client), _signer(signer) +{ + plog::init(plog::debug, appender.get()); client->start(); }; NostrService::~NostrService() { this->_client->stop(); - delete this->_client; }; -RelayList NostrService::defaultRelays() const { return this->_defaultRelays; }; +vector<string> NostrService::defaultRelays() const { return this->_defaultRelays; }; -RelayList NostrService::activeRelays() const { return this->_activeRelays; }; +vector<string> NostrService::activeRelays() const { return this->_activeRelays; }; -RelayList NostrService::openRelayConnections() +unordered_map<string, vector<string>> NostrService::subscriptions() const { return this->_subscriptions; }; + +vector<string> NostrService::openRelayConnections() { return this->openRelayConnections(this->_defaultRelays); }; -RelayList NostrService::openRelayConnections(RelayList relays) +vector<string> NostrService::openRelayConnections(vector<string> relays) { PLOG_INFO << "Attempting to connect to Nostr relays."; - RelayList unconnectedRelays = this->getUnconnectedRelays(relays); + vector<string> unconnectedRelays = this->getUnconnectedRelays(relays); vector<thread> connectionThreads; for (string relay : unconnectedRelays) @@ -82,10 +77,10 @@ void NostrService::closeRelayConnections() this->closeRelayConnections(this->_activeRelays); }; -void NostrService::closeRelayConnections(RelayList relays) +void NostrService::closeRelayConnections(vector<string> relays) { PLOG_INFO << "Disconnecting from Nostr relays."; - RelayList connectedRelays = getConnectedRelays(relays); + vector<string> connectedRelays = getConnectedRelays(relays); vector<thread> disconnectionThreads; for (string relay : connectedRelays) @@ -94,6 +89,10 @@ void NostrService::closeRelayConnections(RelayList relays) this->disconnect(relay); }); disconnectionThreads.push_back(move(disconnectionThread)); + + // TODO: Close subscriptions before disconnecting. + lock_guard<mutex> lock(this->_propertyMutex); + this->_subscriptions.erase(relay); } for (thread& disconnectionThread : disconnectionThreads) @@ -102,23 +101,64 @@ void NostrService::closeRelayConnections(RelayList relays) } }; -tuple<RelayList, RelayList> NostrService::publishEvent(Event event) +// TODO: Make this method return a promise. +tuple<vector<string>, vector<string>> NostrService::publishEvent(shared_ptr<Event> event) { - // TODO: Add validation function. - - RelayList successfulRelays; - RelayList failedRelays; + vector<string> successfulRelays; + vector<string> failedRelays; PLOG_INFO << "Attempting to publish event to Nostr relays."; + json message; + try + { + this->_signer->sign(event); + message = json::array({ "EVENT", event->serialize() }); + } + catch (const std::invalid_argument& e) + { + PLOG_ERROR << "Failed to sign event: " << e.what(); + throw e; + } + catch (const json::exception& je) + { + PLOG_ERROR << "Failed to serialize event: " << je.what(); + throw je; + } + + lock_guard<mutex> lock(this->_propertyMutex); + vector<string> targetRelays = this->_activeRelays; vector<future<tuple<string, bool>>> publishFutures; - for (string relay : this->_activeRelays) + for (const string& relay : targetRelays) { - future<tuple<string, bool>> publishFuture = async([this, relay, event]() { - return this->_client->send(event.serialize(), relay); - }); - - publishFutures.push_back(move(publishFuture)); + promise<tuple<string, bool>> publishPromise; + publishFutures.push_back(move(publishPromise.get_future())); + + auto [uri, success] = this->_client->send( + message.dump(), + relay, + [this, &relay, &event, &publishPromise](string response) + { + this->onAcceptance(response, [this, &relay, &event, &publishPromise](bool isAccepted) + { + if (isAccepted) + { + PLOG_INFO << "Relay " << relay << " accepted event: " << event->id; + publishPromise.set_value(make_tuple(relay, true)); + } + else + { + PLOG_WARNING << "Relay " << relay << " rejected event: " << event->id; + publishPromise.set_value(make_tuple(relay, false)); + } + }); + }); + + if (!success) + { + PLOG_WARNING << "Failed to send event to relay " << relay; + publishPromise.set_value(make_tuple(relay, false)); + } } for (auto& publishFuture : publishFutures) @@ -134,17 +174,288 @@ tuple<RelayList, RelayList> NostrService::publishEvent(Event event) } } - size_t targetCount = this->_activeRelays.size(); + size_t targetCount = targetRelays.size(); size_t successfulCount = successfulRelays.size(); PLOG_INFO << "Published event to " << successfulCount << "/" << targetCount << " target relays."; return make_tuple(successfulRelays, failedRelays); }; -RelayList NostrService::getConnectedRelays(RelayList relays) +// TODO: Make this method return a promise. +// TODO: Add a timeout to this method to prevent hanging while waiting for the relay. +vector<shared_ptr<Event>> NostrService::queryRelays(shared_ptr<Filters> filters) +{ + if (filters->limit > 64 || filters->limit < 1) + { + PLOG_WARNING << "Filters limit must be between 1 and 64, inclusive. Setting limit to 16."; + filters->limit = 16; + } + + vector<shared_ptr<Event>> events; + + string subscriptionId = this->generateSubscriptionId(); + string request; + + try + { + request = filters->serialize(subscriptionId); + } + catch (const invalid_argument& e) + { + PLOG_ERROR << "Failed to serialize filters - invalid object: " << e.what(); + throw e; + } + catch (const json::exception& je) + { + PLOG_ERROR << "Failed to serialize filters - JSON exception: " << je.what(); + throw je; + } + + vector<future<tuple<string, bool>>> requestFutures; + + // Send the same query to each relay. As events trickle in from each relay, they will be added + // to the events vector. Multiple copies of an event may be received if the same event is + // stored on multiple relays. The function will block until all of the relays send an EOSE or + // CLOSE message. + for (const string relay : this->_activeRelays) + { + promise<tuple<string, bool>> eosePromise; + requestFutures.push_back(move(eosePromise.get_future())); + + auto [uri, success] = this->_client->send( + request, + relay, + [this, &relay, &events, &eosePromise](string payload) + { + this->onSubscriptionMessage( + payload, + [&events](const string&, shared_ptr<Event> event) + { + events.push_back(event); + }, + [relay, &eosePromise](const string&) + { + eosePromise.set_value(make_tuple(relay, true)); + }, + [relay, &eosePromise](const string&, const string&) + { + eosePromise.set_value(make_tuple(relay, false)); + }); + }); + + if (success) + { + PLOG_INFO << "Sent query to relay " << relay; + lock_guard<mutex> lock(this->_propertyMutex); + this->_subscriptions[subscriptionId].push_back(relay); + } + else + { + PLOG_WARNING << "Failed to send query to relay " << relay; + eosePromise.set_value(make_tuple(uri, false)); + } + } + + // Close open subscriptions and disconnect from relays after events are received. + for (auto& publishFuture : requestFutures) + { + auto [relay, isEose] = publishFuture.get(); + if (isEose) + { + PLOG_INFO << "Received EOSE message from relay " << relay; + } + else + { + PLOG_WARNING << "Received CLOSE message from relay " << relay; + this->closeRelayConnections({ relay }); + } + } + this->closeSubscription(subscriptionId); + + // TODO: De-duplicate events in the vector before returning. + + return events; +}; + +string NostrService::queryRelays( + shared_ptr<Filters> filters, + function<void(const string&, shared_ptr<Event>)> eventHandler, + function<void(const string&)> eoseHandler, + function<void(const string&, const string&)> closeHandler) +{ + vector<string> successfulRelays; + vector<string> failedRelays; + + string subscriptionId = this->generateSubscriptionId(); + string request = filters->serialize(subscriptionId); + vector<future<tuple<string, bool>>> requestFutures; + for (const string relay : this->_activeRelays) + { + unique_lock<mutex> lock(this->_propertyMutex); + this->_subscriptions[subscriptionId].push_back(relay); + lock.unlock(); + + future<tuple<string, bool>> requestFuture = async( + [this, &relay, &request, &eventHandler, &eoseHandler, &closeHandler]() + { + return this->_client->send( + request, + relay, + [this, &eventHandler, &eoseHandler, &closeHandler](string payload) + { + this->onSubscriptionMessage(payload, eventHandler, eoseHandler, closeHandler); + }); + }); + requestFutures.push_back(move(requestFuture)); + } + + for (auto& publishFuture : requestFutures) + { + auto [relay, isSuccess] = publishFuture.get(); + if (isSuccess) + { + successfulRelays.push_back(relay); + } + else + { + failedRelays.push_back(relay); + } + } + + size_t targetCount = this->_activeRelays.size(); + size_t successfulCount = successfulRelays.size(); + PLOG_INFO << "Sent query to " << successfulCount << "/" << targetCount << " open relay connections."; + + return subscriptionId; +}; + +tuple<vector<string>, vector<string>> NostrService::closeSubscription(string subscriptionId) +{ + vector<string> successfulRelays; + vector<string> failedRelays; + + vector<string> subscriptionRelays; + size_t subscriptionRelayCount; + vector<future<tuple<string, bool>>> closeFutures; + + try + { + unique_lock<mutex> lock(this->_propertyMutex); + subscriptionRelays = this->_subscriptions.at(subscriptionId); + subscriptionRelayCount = subscriptionRelays.size(); + lock.unlock(); + } + catch (const out_of_range& oor) + { + PLOG_WARNING << "Subscription " << subscriptionId << " not found."; + return make_tuple(successfulRelays, failedRelays); + } + + for (const string relay : subscriptionRelays) + { + future<tuple<string, bool>> closeFuture = async([this, subscriptionId, relay]() + { + bool success = this->closeSubscription(subscriptionId, relay); + + return make_tuple(relay, success); + }); + closeFutures.push_back(move(closeFuture)); + } + + for (auto& closeFuture : closeFutures) + { + auto [uri, success] = closeFuture.get(); + if (success) + { + successfulRelays.push_back(uri); + } + else + { + failedRelays.push_back(uri); + } + } + + size_t successfulCount = successfulRelays.size(); + PLOG_INFO << "Sent CLOSE request for subscription " << subscriptionId << " to " << successfulCount << "/" << subscriptionRelayCount << " open relay connections."; + + // If there were no failures, and the subscription has been closed on all of its relays, forget + // about the subscription. + if (failedRelays.empty()) + { + lock_guard<mutex> lock(this->_propertyMutex); + this->_subscriptions.erase(subscriptionId); + } + + return make_tuple(successfulRelays, failedRelays); +}; + +bool NostrService::closeSubscription(string subscriptionId, string relay) +{ + if (!this->hasSubscription(subscriptionId, relay)) + { + PLOG_WARNING << "Subscription " << subscriptionId << " not found on relay " << relay; + return false; + } + + if (!this->isConnected(relay)) + { + PLOG_WARNING << "Relay " << relay << " is not connected."; + return false; + } + + string request = this->generateCloseRequest(subscriptionId); + auto [uri, success] = this->_client->send(request, relay); + + if (success) + { + lock_guard<mutex> lock(this->_propertyMutex); + auto it = find( + this->_subscriptions[subscriptionId].begin(), + this->_subscriptions[subscriptionId].end(), + relay); + + if (it != this->_subscriptions[subscriptionId].end()) + { + this->_subscriptions[subscriptionId].erase(it); + } + + PLOG_INFO << "Sent close request for subscription " << subscriptionId << " to relay " << relay; + } + else + { + PLOG_WARNING << "Failed to send close request to relay " << relay; + } + + return success; +}; + +vector<string> NostrService::closeSubscriptions() +{ + unique_lock<mutex> lock(this->_propertyMutex); + vector<string> subscriptionIds; + for (auto& [subscriptionId, relays] : this->_subscriptions) + { + subscriptionIds.push_back(subscriptionId); + } + lock.unlock(); + + vector<string> remainingSubscriptions; + for (const string& subscriptionId : subscriptionIds) + { + auto [successes, failures] = this->closeSubscription(subscriptionId); + if (!failures.empty()) + { + remainingSubscriptions.push_back(subscriptionId); + } + } + + return remainingSubscriptions; +}; + +vector<string> NostrService::getConnectedRelays(vector<string> relays) { PLOG_VERBOSE << "Identifying connected relays."; - RelayList connectedRelays; + vector<string> connectedRelays; for (string relay : relays) { bool isActive = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay) @@ -169,10 +480,10 @@ RelayList NostrService::getConnectedRelays(RelayList relays) return connectedRelays; }; -RelayList NostrService::getUnconnectedRelays(RelayList relays) +vector<string> NostrService::getUnconnectedRelays(vector<string> relays) { PLOG_VERBOSE << "Identifying unconnected relays."; - RelayList unconnectedRelays; + vector<string> unconnectedRelays; for (string relay : relays) { bool isActive = find(this->_activeRelays.begin(), this->_activeRelays.end(), relay) @@ -245,4 +556,105 @@ void NostrService::disconnect(string relay) lock_guard<mutex> lock(this->_propertyMutex); this->eraseActiveRelay(relay); }; + +string NostrService::generateSubscriptionId() +{ + UUIDv4::UUIDGenerator<std::mt19937_64> uuidGenerator; + UUIDv4::UUID uuid = uuidGenerator.getUUID(); + return uuid.str(); +}; + +string NostrService::generateCloseRequest(string subscriptionId) +{ + json jarr = json::array({ "CLOSE", subscriptionId }); + return jarr.dump(); +}; + +bool NostrService::hasSubscription(string subscriptionId) +{ + lock_guard<mutex> lock(this->_propertyMutex); + auto it = this->_subscriptions.find(subscriptionId); + + return it != this->_subscriptions.end(); +}; + +bool NostrService::hasSubscription(string subscriptionId, string relay) +{ + lock_guard<mutex> lock(this->_propertyMutex); + auto subscriptionIt = this->_subscriptions.find(subscriptionId); + + if (subscriptionIt == this->_subscriptions.end()) + { + return false; + } + + auto relays = this->_subscriptions[subscriptionId]; + auto relayIt = find(relays.begin(), relays.end(), relay); + + return relayIt != relays.end(); +}; + +void NostrService::onSubscriptionMessage( + string message, + function<void(const string&, shared_ptr<Event>)> eventHandler, + function<void(const string&)> eoseHandler, + function<void(const string&, const string&)> closeHandler) +{ + try + { + json jMessage = json::parse(message); + string messageType = jMessage.at(0); + if (messageType == "EVENT") + { + string subscriptionId = jMessage.at(1); + Event event = Event::fromString(jMessage.at(2)); + eventHandler(subscriptionId, make_shared<Event>(event)); + } + else if (messageType == "EOSE") + { + string subscriptionId = jMessage.at(1); + eoseHandler(subscriptionId); + } + else if (messageType == "CLOSE") + { + string subscriptionId = jMessage.at(1); + string reason = jMessage.at(2); + closeHandler(subscriptionId, reason); + } + } + catch (const json::out_of_range& joor) + { + PLOG_ERROR << "JSON out-of-range exception: " << joor.what(); + throw joor; + } + catch (const json::exception& je) + { + PLOG_ERROR << "JSON handling exception: " << je.what(); + throw je; + } + catch (const invalid_argument& ia) + { + PLOG_ERROR << "Invalid argument exception: " << ia.what(); + throw ia; + } +}; + +void NostrService::onAcceptance(string message, function<void(const bool)> acceptanceHandler) +{ + try + { + json jMessage = json::parse(message); + string messageType = jMessage[0]; + if (messageType == "OK") + { + bool isAccepted = jMessage[2]; + acceptanceHandler(isAccepted); + } + } + catch (const json::exception& je) + { + PLOG_ERROR << "JSON handling exception: " << je.what(); + throw je; + } +}; } // namespace nostr diff --git a/test/nostr_service_test.cpp b/test/nostr_service_test.cpp index 83de3be..b3b9b28 100644 --- a/test/nostr_service_test.cpp +++ b/test/nostr_service_test.cpp @@ -1,21 +1,19 @@ +#include <chrono> #include <gmock/gmock.h> #include <gtest/gtest.h> +#include <nlohmann/json.hpp> #include <plog/Appenders/ConsoleAppender.h> #include <plog/Formatters/TxtFormatter.h> +#include <iostream> #include <websocketpp/client.hpp> #include <client/web_socket_client.hpp> #include <nostr.hpp> -using std::lock_guard; -using std::make_shared; -using std::mutex; -using std::shared_ptr; -using std::string; -using std::unordered_map; -using ::testing::_; -using ::testing::Invoke; -using ::testing::Return; +using namespace std; +using namespace ::testing; + +using nlohmann::json; namespace nostr_test { @@ -23,42 +21,172 @@ class MockWebSocketClient : public client::IWebSocketClient { public: MOCK_METHOD(void, start, (), (override)); MOCK_METHOD(void, stop, (), (override)); - MOCK_METHOD(void, openConnection, (std::string uri), (override)); - MOCK_METHOD(bool, isConnected, (std::string uri), (override)); - MOCK_METHOD((std::tuple<std::string, bool>), send, (std::string message, std::string uri), (override)); - MOCK_METHOD(void, closeConnection, (std::string uri), (override)); + MOCK_METHOD(void, openConnection, (string uri), (override)); + MOCK_METHOD(bool, isConnected, (string uri), (override)); + MOCK_METHOD((tuple<string, bool>), send, (string message, string uri), (override)); + MOCK_METHOD((tuple<string, bool>), send, (string message, string uri, function<void(const string&)> messageHandler), (override)); + MOCK_METHOD(void, receive, (string uri, function<void(const string&)> messageHandler), (override)); + MOCK_METHOD(void, closeConnection, (string uri), (override)); +}; + +class FakeSigner : public nostr::ISigner +{ +public: + void sign(shared_ptr<nostr::Event> event) override + { + event->sig = "fake_signature"; + } }; class NostrServiceTest : public testing::Test { public: - inline static const nostr::RelayList defaultTestRelays = + inline static const vector<string> defaultTestRelays = { "wss://relay.damus.io", "wss://nostr.thesamecat.io" }; + static const nostr::Event getTextNoteTestEvent() + { + nostr::Event event; + event.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; + event.kind = 1; + event.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event.content = "Hello, World!"; + + return event; + }; + + static const vector<nostr::Event> getMultipleTextNoteTestEvents() + { + auto now = std::chrono::system_clock::now(); + std::time_t currentTime = std::chrono::system_clock::to_time_t(now); + + nostr::Event event1; + event1.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; + event1.kind = 1; + event1.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event1.content = "Hello, World!"; + event1.createdAt = currentTime; + + nostr::Event event2; + event2.pubkey = "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42"; + event2.kind = 1; + event2.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event2.content = "Welcome to Nostr!"; + event2.createdAt = currentTime; + + nostr::Event event3; + event3.pubkey = "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla"; + event3.kind = 1; + event3.tags = + { + { "e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event3.content = "Time for some introductions!"; + event3.createdAt = currentTime; + + return { event1, event2, event3 }; + }; + + static const nostr::Event getLongFormTestEvent() + { + nostr::Event event; + event.pubkey = "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask"; + event.kind = 30023; + event.tags = + { + { "event", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com" }, + { "pubkey", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca" }, + { "author", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com" } + }; + event.content = "Hello, World!"; + + return event; + } + + static const string getTestEventMessage(shared_ptr<nostr::Event> event, string subscriptionId) + { + auto signer = make_unique<FakeSigner>(); + signer->sign(event); + + json jarr = json::array(); + jarr.push_back("EVENT"); + jarr.push_back(subscriptionId); + jarr.push_back(event->serialize()); + + return jarr.dump(); + } + + static const nostr::Filters getKind0And1TestFilters() + { + nostr::Filters filters; + filters.authors = { + "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask", + "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42", + "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla" + }; + filters.kinds = { 0, 1 }; + filters.limit = 10; + + return filters; + } + + static const nostr::Filters getKind30023TestFilters() + { + nostr::Filters filters; + filters.authors = { + "13tn5ccv2guflxgffq4aj0hw5x39pz70zcdrfd6vym887gry38zys28dask", + "1l9d9jh67rkwayalrxcy686aujyz5pper5kzjv8jvg8pu9v9ns4ls0xvq42", + "187ujhtmnv82ftg03h4heetwk3dd9mlfkf8th3fvmrk20nxk9mansuzuyla" + }; + filters.kinds = { 30023 }; + filters.limit = 5; + + return filters; + } + protected: shared_ptr<plog::ConsoleAppender<plog::TxtFormatter>> testAppender; - shared_ptr<MockWebSocketClient> testClient; + shared_ptr<MockWebSocketClient> mockClient; + shared_ptr<FakeSigner> fakeSigner; void SetUp() override { testAppender = make_shared<plog::ConsoleAppender<plog::TxtFormatter>>(); - testClient = make_shared<MockWebSocketClient>(); + mockClient = make_shared<MockWebSocketClient>(); + fakeSigner = make_shared<FakeSigner>(); }; }; TEST_F(NostrServiceTest, Constructor_StartsClient) { - EXPECT_CALL(*testClient, start()).Times(1); + EXPECT_CALL(*mockClient, start()).Times(1); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get()); + auto nostrService = make_unique<nostr::NostrService>(testAppender, mockClient, fakeSigner); }; TEST_F(NostrServiceTest, Constructor_InitializesService_WithNoDefaultRelays) { - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get()); + auto nostrService = make_unique<nostr::NostrService>(testAppender, mockClient, fakeSigner); auto defaultRelays = nostrService->defaultRelays(); auto activeRelays = nostrService->activeRelays(); @@ -68,7 +196,11 @@ TEST_F(NostrServiceTest, Constructor_InitializesService_WithNoDefaultRelays) TEST_F(NostrServiceTest, Constructor_InitializesService_WithProvidedDefaultRelays) { - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); auto defaultRelays = nostrService->defaultRelays(); auto activeRelays = nostrService->activeRelays(); @@ -82,9 +214,9 @@ TEST_F(NostrServiceTest, Constructor_InitializesService_WithProvidedDefaultRelay TEST_F(NostrServiceTest, Destructor_StopsClient) { - EXPECT_CALL(*testClient, start()).Times(1); + EXPECT_CALL(*mockClient, start()).Times(1); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get()); + auto nostrService = make_unique<nostr::NostrService>(testAppender, mockClient, fakeSigner); }; TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToDefaultRelays) @@ -94,10 +226,10 @@ TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToDefaultRelays) connectionStatus->insert({ defaultTestRelays[0], false }); connectionStatus->insert({ defaultTestRelays[1], false }); - EXPECT_CALL(*testClient, openConnection(defaultTestRelays[0])).Times(1); - EXPECT_CALL(*testClient, openConnection(defaultTestRelays[1])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(1); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -109,7 +241,11 @@ TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToDefaultRelays) return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); nostrService->openRelayConnections(); auto activeRelays = nostrService->activeRelays(); @@ -122,17 +258,17 @@ TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToDefaultRelays) TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToProvidedRelays) { - nostr::RelayList testRelays = { "wss://nos.lol" }; + vector<string> testRelays = { "wss://nos.lol" }; mutex connectionStatusMutex; auto connectionStatus = make_shared<unordered_map<string, bool>>(); connectionStatus -> insert({ testRelays[0], false }); - EXPECT_CALL(*testClient, openConnection(testRelays[0])).Times(1); - EXPECT_CALL(*testClient, openConnection(defaultTestRelays[0])).Times(0); - EXPECT_CALL(*testClient, openConnection(defaultTestRelays[1])).Times(0); + EXPECT_CALL(*mockClient, openConnection(testRelays[0])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(0); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(0); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -144,7 +280,11 @@ TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToProvidedRelays) return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); nostrService->openRelayConnections(testRelays); auto activeRelays = nostrService->activeRelays(); @@ -157,7 +297,7 @@ TEST_F(NostrServiceTest, OpenRelayConnections_OpensConnections_ToProvidedRelays) TEST_F(NostrServiceTest, OpenRelayConnections_AddsOpenConnections_ToActiveRelays) { - nostr::RelayList testRelays = { "wss://nos.lol" }; + vector<string> testRelays = { "wss://nos.lol" }; mutex connectionStatusMutex; auto connectionStatus = make_shared<unordered_map<string, bool>>(); @@ -165,11 +305,11 @@ TEST_F(NostrServiceTest, OpenRelayConnections_AddsOpenConnections_ToActiveRelays connectionStatus->insert({ defaultTestRelays[1], false }); connectionStatus->insert({ testRelays[0], false }); - EXPECT_CALL(*testClient, openConnection(defaultTestRelays[0])).Times(1); - EXPECT_CALL(*testClient, openConnection(defaultTestRelays[1])).Times(1); - EXPECT_CALL(*testClient, openConnection(testRelays[0])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*mockClient, openConnection(defaultTestRelays[1])).Times(1); + EXPECT_CALL(*mockClient, openConnection(testRelays[0])).Times(1); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -181,7 +321,11 @@ TEST_F(NostrServiceTest, OpenRelayConnections_AddsOpenConnections_ToActiveRelays return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); nostrService->openRelayConnections(); auto activeRelays = nostrService->activeRelays(); @@ -212,7 +356,7 @@ TEST_F(NostrServiceTest, CloseRelayConnections_ClosesConnections_ToActiveRelays) connectionStatus->insert({ defaultTestRelays[0], false }); connectionStatus->insert({ defaultTestRelays[1], false }); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -224,11 +368,15 @@ TEST_F(NostrServiceTest, CloseRelayConnections_ClosesConnections_ToActiveRelays) return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); nostrService->openRelayConnections(); - EXPECT_CALL(*testClient, closeConnection(defaultTestRelays[0])).Times(1); - EXPECT_CALL(*testClient, closeConnection(defaultTestRelays[1])).Times(1); + EXPECT_CALL(*mockClient, closeConnection(defaultTestRelays[0])).Times(1); + EXPECT_CALL(*mockClient, closeConnection(defaultTestRelays[1])).Times(1); nostrService->closeRelayConnections(); @@ -238,8 +386,8 @@ TEST_F(NostrServiceTest, CloseRelayConnections_ClosesConnections_ToActiveRelays) TEST_F(NostrServiceTest, CloseRelayConnections_RemovesClosedConnections_FromActiveRelays) { - nostr::RelayList testRelays = { "wss://nos.lol" }; - nostr::RelayList allTestRelays = { defaultTestRelays[0], defaultTestRelays[1], testRelays[0] }; + vector<string> testRelays = { "wss://nos.lol" }; + vector<string> allTestRelays = { defaultTestRelays[0], defaultTestRelays[1], testRelays[0] }; mutex connectionStatusMutex; auto connectionStatus = make_shared<unordered_map<string, bool>>(); @@ -247,7 +395,7 @@ TEST_F(NostrServiceTest, CloseRelayConnections_RemovesClosedConnections_FromActi connectionStatus->insert({ defaultTestRelays[1], false }); connectionStatus->insert({ testRelays[0], false }); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -259,10 +407,14 @@ TEST_F(NostrServiceTest, CloseRelayConnections_RemovesClosedConnections_FromActi return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), allTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + allTestRelays); nostrService->openRelayConnections(); - EXPECT_CALL(*testClient, closeConnection(testRelays[0])).Times(1); + EXPECT_CALL(*mockClient, closeConnection(testRelays[0])).Times(1); nostrService->closeRelayConnections(testRelays); @@ -285,7 +437,7 @@ TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_AllSuccesses) connectionStatus->insert({ defaultTestRelays[0], false }); connectionStatus->insert({ defaultTestRelays[1], false }); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -297,17 +449,28 @@ TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_AllSuccesses) return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); nostrService->openRelayConnections(); - EXPECT_CALL(*testClient, send(_, _)) + EXPECT_CALL(*mockClient, send(_, _, _)) .Times(2) - .WillRepeatedly(Invoke([](string message, string uri) + .WillRepeatedly(Invoke([](string message, string uri, function<void(const string&)> messageHandler) { + json messageArr = json::parse(message); + auto event = nostr::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, true, "Event accepted" }); + messageHandler(jarr.dump()); + return make_tuple(uri, true); })); - auto [successes, failures] = nostrService->publishEvent(nostr::Event()); + auto testEvent = make_shared<nostr::Event>(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); ASSERT_EQ(successes.size(), defaultTestRelays.size()); for (auto relay : successes) @@ -325,7 +488,7 @@ TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_AllFailures) connectionStatus->insert({ defaultTestRelays[0], false }); connectionStatus->insert({ defaultTestRelays[1], false }); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -337,17 +500,23 @@ TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_AllFailures) return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); nostrService->openRelayConnections(); - EXPECT_CALL(*testClient, send(_, _)) + // Simulate a case where the message failed to send to all relays. + EXPECT_CALL(*mockClient, send(_, _, _)) .Times(2) - .WillRepeatedly(Invoke([](string message, string uri) + .WillRepeatedly(Invoke([](string message, string uri, function<void(const string&)> messageHandler) { return make_tuple(uri, false); })); - auto [successes, failures] = nostrService->publishEvent(nostr::Event()); + auto testEvent = make_shared<nostr::Event>(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); ASSERT_EQ(successes.size(), 0); @@ -365,7 +534,7 @@ TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_MixedSuccessesAndFailur connectionStatus->insert({ defaultTestRelays[0], false }); connectionStatus->insert({ defaultTestRelays[1], false }); - EXPECT_CALL(*testClient, isConnected(_)) + EXPECT_CALL(*mockClient, isConnected(_)) .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) { lock_guard<mutex> lock(connectionStatusMutex); @@ -377,23 +546,149 @@ TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_MixedSuccessesAndFailur return status; })); - auto nostrService = new nostr::NostrService(testAppender.get(), testClient.get(), defaultTestRelays); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); nostrService->openRelayConnections(); - EXPECT_CALL(*testClient, send(_, defaultTestRelays[0])) + // Simulate a scenario where the message fails to send to one relay, but sends successfully to + // the other, and the relay accepts it. + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[0], _)) .Times(1) - .WillRepeatedly(Invoke([](string message, string uri) + .WillRepeatedly(Invoke([](string message, string uri, function<void(const string&)> messageHandler) + { + return make_tuple(uri, false); + })); + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[1], _)) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri, function<void(const string&)> messageHandler) + { + json messageArr = json::parse(message); + auto event = nostr::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, true, "Event accepted" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + auto testEvent = make_shared<nostr::Event>(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); + + ASSERT_EQ(successes.size(), 1); + ASSERT_EQ(successes[0], defaultTestRelays[1]); + + ASSERT_EQ(failures.size(), 1); + ASSERT_EQ(failures[0], defaultTestRelays[0]); +}; + +TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_RejectedEvent) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared<unordered_map<string, bool>>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard<mutex> lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); + nostrService->openRelayConnections(); + + // Simulate a scenario where the message is rejected by all target relays. + EXPECT_CALL(*mockClient, send(_, _, _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri, function<void(const string&)> messageHandler) { + json messageArr = json::parse(message); + auto event = nostr::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, false, "Event rejected" }); + messageHandler(jarr.dump()); + return make_tuple(uri, true); })); - EXPECT_CALL(*testClient, send(_, defaultTestRelays[1])) + + auto testEvent = make_shared<nostr::Event>(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); + + ASSERT_EQ(failures.size(), defaultTestRelays.size()); + for (auto relay : failures) + { + ASSERT_NE(find(defaultTestRelays.begin(), defaultTestRelays.end(), relay), defaultTestRelays.end()); + } +}; + +TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_EventRejectedBySomeRelays) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared<unordered_map<string, bool>>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard<mutex> lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); + nostrService->openRelayConnections(); + + // Simulate a scenario where the message fails to send to one relay, but sends successfully to + // the other, and the relay accepts it. + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[0], _)) .Times(1) - .WillRepeatedly(Invoke([](string message, string uri) + .WillRepeatedly(Invoke([](string message, string uri, function<void(const string&)> messageHandler) { - return make_tuple(uri, false); + json messageArr = json::parse(message); + auto event = nostr::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, true, "Event accepted" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + EXPECT_CALL(*mockClient, send(_, defaultTestRelays[1], _)) + .Times(1) + .WillRepeatedly(Invoke([](string message, string uri, function<void(const string&)> messageHandler) + { + json messageArr = json::parse(message); + auto event = nostr::Event::fromString(messageArr[1]); + + json jarr = json::array({ "OK", event.id, false, "Event rejected" }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); })); - auto [successes, failures] = nostrService->publishEvent(nostr::Event()); + auto testEvent = make_shared<nostr::Event>(getTextNoteTestEvent()); + auto [successes, failures] = nostrService->publishEvent(testEvent); ASSERT_EQ(successes.size(), 1); ASSERT_EQ(successes[0], defaultTestRelays[0]); @@ -401,4 +696,390 @@ TEST_F(NostrServiceTest, PublishEvent_CorrectlyIndicates_MixedSuccessesAndFailur ASSERT_EQ(failures.size(), 1); ASSERT_EQ(failures[0], defaultTestRelays[1]); }; + +TEST_F(NostrServiceTest, QueryRelays_ReturnsEvents_UpToEOSE) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared<unordered_map<string, bool>>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard<mutex> lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto signer = make_unique<FakeSigner>(); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); + nostrService->openRelayConnections(); + + auto testEvents = getMultipleTextNoteTestEvents(); + vector<shared_ptr<nostr::Event>> signedTestEvents; + for (nostr::Event testEvent : testEvents) + { + auto signedEvent = make_shared<nostr::Event>(testEvent); + signer->sign(signedEvent); + + auto serializedEvent = signedEvent->serialize(); + auto deserializedEvent = nostr::Event::fromString(serializedEvent); + + signedEvent = make_shared<nostr::Event>(deserializedEvent); + signedTestEvents.push_back(signedEvent); + } + + // Expect the query messages. + EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) + .Times(2) + .WillRepeatedly(Invoke([&testEvents, &signer]( + string message, + string uri, + function<void(const string&)> messageHandler) + { + json messageArr = json::parse(message); + string subscriptionId = messageArr.at(1); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared<nostr::Event>(event); + signer->sign(sendableEvent); + json jarr = json::array({ "EVENT", subscriptionId, sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionId }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + // Expect the close subscription messages after the client receives events. + EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + + auto filters = make_shared<nostr::Filters>(getKind0And1TestFilters()); + auto results = nostrService->queryRelays(filters); + + // TODO: Check results size when the queryRelays method deduplicates results before returning. + // ASSERT_EQ(results.size(), testEvents.size()); + + // Check that the results contain the expected events. + for (auto resultEvent : results) + { + ASSERT_NE( + find_if( + signedTestEvents.begin(), + signedTestEvents.end(), + [&resultEvent](shared_ptr<nostr::Event> testEvent) + { + return *testEvent == *resultEvent; + }), + signedTestEvents.end()); + } + + auto subscriptions = nostrService->subscriptions(); + ASSERT_TRUE(subscriptions.empty()); +}; + +TEST_F(NostrServiceTest, QueryRelays_CallsHandler_WithReturnedEvents) +{ + mutex connectionStatusMutex; + auto connectionStatus = make_shared<unordered_map<string, bool>>(); + connectionStatus->insert({ defaultTestRelays[0], false }); + connectionStatus->insert({ defaultTestRelays[1], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard<mutex> lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto signer = make_unique<FakeSigner>(); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + defaultTestRelays); + nostrService->openRelayConnections(); + + auto testEvents = getMultipleTextNoteTestEvents(); + vector<shared_ptr<nostr::Event>> signedTestEvents; + for (nostr::Event testEvent : testEvents) + { + auto signedEvent = make_shared<nostr::Event>(testEvent); + signer->sign(signedEvent); + + auto serializedEvent = signedEvent->serialize(); + auto deserializedEvent = nostr::Event::fromString(serializedEvent); + + signedEvent = make_shared<nostr::Event>(deserializedEvent); + signedTestEvents.push_back(signedEvent); + } + + EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) + .Times(2) + .WillRepeatedly(Invoke([&testEvents, &signer]( + string message, + string uri, + function<void(const string&)> messageHandler) + { + json messageArr = json::parse(message); + string subscriptionId = messageArr.at(1); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared<nostr::Event>(event); + signer->sign(sendableEvent); + json jarr = json::array({ "EVENT", subscriptionId, sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionId }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + auto filters = make_shared<nostr::Filters>(getKind0And1TestFilters()); + promise<void> eoseReceivedPromise; + auto eoseReceivedFuture = eoseReceivedPromise.get_future(); + int eoseCount = 0; + + string generatedSubscriptionId = nostrService->queryRelays( + filters, + [&generatedSubscriptionId, &signedTestEvents](const string& subscriptionId, shared_ptr<nostr::Event> event) + { + ASSERT_STREQ(subscriptionId.c_str(), generatedSubscriptionId.c_str()); + ASSERT_NE( + find_if( + signedTestEvents.begin(), + signedTestEvents.end(), + [&event](shared_ptr<nostr::Event> testEvent) + { + return *testEvent == *event; + }), + signedTestEvents.end()); + }, + [&generatedSubscriptionId, &eoseReceivedPromise, &eoseCount] + (const string& subscriptionId) + { + ASSERT_STREQ(subscriptionId.c_str(), generatedSubscriptionId.c_str()); + + if (++eoseCount == 2) + { + eoseReceivedPromise.set_value(); + } + }, + [](const string&, const string&) {}); + + eoseReceivedFuture.wait(); + + // Check that the service is keeping track of its active subscriptions. + auto subscriptions = nostrService->subscriptions(); + ASSERT_NO_THROW(subscriptions.at(generatedSubscriptionId)); + ASSERT_EQ(subscriptions.at(generatedSubscriptionId).size(), 2); + + EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + + auto [successes, failures] = nostrService->closeSubscription(generatedSubscriptionId); + + ASSERT_TRUE(failures.empty()); + + // Check that the service has forgotten about the subscriptions after closing them. + subscriptions = nostrService->subscriptions(); + ASSERT_TRUE(subscriptions.empty()); +}; + +TEST_F(NostrServiceTest, Service_MaintainsMultipleSubscriptions_ThenClosesAll) +{ + // Mock connections. + mutex connectionStatusMutex; + auto connectionStatus = make_shared<unordered_map<string, bool>>(); + vector<string> testRelays = { "wss://theforest.nostr1.com" }; + connectionStatus->insert({ testRelays[0], false }); + + EXPECT_CALL(*mockClient, isConnected(_)) + .WillRepeatedly(Invoke([connectionStatus, &connectionStatusMutex](string uri) + { + lock_guard<mutex> lock(connectionStatusMutex); + bool status = connectionStatus->at(uri); + if (status == false) + { + connectionStatus->at(uri) = true; + } + return status; + })); + + auto signer = make_unique<FakeSigner>(); + auto nostrService = make_unique<nostr::NostrService>( + testAppender, + mockClient, + fakeSigner, + testRelays); + nostrService->openRelayConnections(); + + // Mock relay responses. + auto testEvents = getMultipleTextNoteTestEvents(); + vector<shared_ptr<nostr::Event>> signedTestEvents; + for (nostr::Event testEvent : testEvents) + { + auto signedEvent = make_shared<nostr::Event>(testEvent); + signer->sign(signedEvent); + + auto serializedEvent = signedEvent->serialize(); + auto deserializedEvent = nostr::Event::fromString(serializedEvent); + + signedEvent = make_shared<nostr::Event>(deserializedEvent); + signedTestEvents.push_back(signedEvent); + } + + vector<string> subscriptionIds; + EXPECT_CALL(*mockClient, send(HasSubstr("REQ"), _, _)) + .Times(2) + .WillOnce(Invoke([&testEvents, &signer, &subscriptionIds]( + string message, + string uri, + function<void(const string&)> messageHandler) + { + json messageArr = json::parse(message); + subscriptionIds.push_back(messageArr.at(1)); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared<nostr::Event>(event); + signer->sign(sendableEvent); + json jarr = json::array({ "EVENT", subscriptionIds.at(0), sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionIds.at(0), }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })) + .WillOnce(Invoke([&testEvents, &signer, &subscriptionIds]( + string message, + string uri, + function<void(const string&)> messageHandler) + { + json messageArr = json::parse(message); + subscriptionIds.push_back(messageArr.at(1)); + + for (auto event : testEvents) + { + auto sendableEvent = make_shared<nostr::Event>(event); + signer->sign(sendableEvent); + json jarr = json::array({ "EVENT", subscriptionIds.at(1), sendableEvent->serialize() }); + messageHandler(jarr.dump()); + } + + json jarr = json::array({ "EOSE", subscriptionIds.at(1), }); + messageHandler(jarr.dump()); + + return make_tuple(uri, true); + })); + + // Send queries. + auto shortFormFilters = make_shared<nostr::Filters>(getKind0And1TestFilters()); + auto longFormFilters = make_shared<nostr::Filters>(getKind30023TestFilters()); + promise<void> shortFormPromise; + promise<void> longFormPromise; + auto shortFormFuture = shortFormPromise.get_future(); + auto longFormFuture = longFormPromise.get_future(); + + string shortFormSubscriptionId = nostrService->queryRelays( + shortFormFilters, + [&shortFormSubscriptionId, &signedTestEvents](const string& subscriptionId, shared_ptr<nostr::Event> event) + { + ASSERT_STREQ(subscriptionId.c_str(), shortFormSubscriptionId.c_str()); + ASSERT_NE( + find_if( + signedTestEvents.begin(), + signedTestEvents.end(), + [&event](shared_ptr<nostr::Event> testEvent) + { + return *testEvent == *event; + }), + signedTestEvents.end()); + }, + [&shortFormSubscriptionId, &shortFormPromise] + (const string& subscriptionId) + { + ASSERT_STREQ(subscriptionId.c_str(), shortFormSubscriptionId.c_str()); + shortFormPromise.set_value(); + }, + [](const string&, const string&) {}); + string longFormSubscriptionId = nostrService->queryRelays( + shortFormFilters, + [&longFormSubscriptionId, &signedTestEvents](const string& subscriptionId, shared_ptr<nostr::Event> event) + { + ASSERT_STREQ(subscriptionId.c_str(), longFormSubscriptionId.c_str()); + ASSERT_NE( + find_if( + signedTestEvents.begin(), + signedTestEvents.end(), + [&event](shared_ptr<nostr::Event> testEvent) + { + return *testEvent == *event; + }), + signedTestEvents.end()); + }, + [&longFormSubscriptionId, &longFormPromise] + (const string& subscriptionId) + { + ASSERT_STREQ(subscriptionId.c_str(), longFormSubscriptionId.c_str()); + longFormPromise.set_value(); + }, + [](const string&, const string&) {}); + + shortFormFuture.wait(); + longFormFuture.wait(); + + // Check that the service has opened a subscription for each query. + auto subscriptions = nostrService->subscriptions(); + ASSERT_NO_THROW(subscriptions.at(shortFormSubscriptionId)); + ASSERT_EQ(subscriptions.at(shortFormSubscriptionId).size(), 1); + ASSERT_NO_THROW(subscriptions.at(longFormSubscriptionId)); + ASSERT_EQ(subscriptions.at(longFormSubscriptionId).size(), 1); + + // Mock the relay response for closing subscriptions. + EXPECT_CALL(*mockClient, send(HasSubstr("CLOSE"), _)) + .Times(2) + .WillRepeatedly(Invoke([](string message, string uri) + { + return make_tuple(uri, true); + })); + + // Close all subscriptions maintained by the service. + auto remainingSubscriptions = nostrService->closeSubscriptions(); + ASSERT_TRUE(remainingSubscriptions.empty()); + + // Check that all subscriptions have been closed. + subscriptions = nostrService->subscriptions(); + ASSERT_TRUE(subscriptions.empty()); +}; } // namespace nostr_test diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json new file mode 100644 index 0000000..4b6ae85 --- /dev/null +++ b/vcpkg-configuration.json @@ -0,0 +1,14 @@ +{
+ "default-registry": {
+ "kind": "git",
+ "baseline": "582a4de14bef91df217f4f49624cf5b2b04bd7ca",
+ "repository": "https://github.com/microsoft/vcpkg"
+ },
+ "registries": [
+ {
+ "kind": "artifact",
+ "location": "https://github.com/microsoft/vcpkg-ce-catalog/archive/refs/heads/main.zip",
+ "name": "microsoft"
+ }
+ ]
+}
diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..579ca88 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,8 @@ +{ + "dependencies": [ + "nlohmann-json", + "openssl", + "plog", + "websocketpp" + ] +} |