aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar Michael J <37635304+buttercat1791@users.noreply.github.com>2024-05-09 08:28:50 -0500
committerLibravatar GitHub <noreply@github.com>2024-05-09 08:28:50 -0500
commit663fb4e7199e1b4318a5bc107096f6a529823e02 (patch)
tree7d92570de852f97550f84140bd794fa5d64c567b
parent0d87b4053983ec8edaff5b73491b717866876586 (diff)
parentd6faf6c815611450d1b61045b53525d7f25ac5c9 (diff)
Merge pull request #3 from buttercat1791/relay-readv0.0.2
Full Relay Read/Write Support
-rw-r--r--.gitignore13
-rw-r--r--CMakeLists.txt81
-rw-r--r--CMakePresets.json26
-rw-r--r--README.md45
-rw-r--r--include/client/web_socket_client.hpp25
-rw-r--r--include/nostr.hpp269
-rw-r--r--src/client/websocketpp_client.cpp30
-rw-r--r--src/event.cpp154
-rw-r--r--src/filters.cpp67
-rw-r--r--src/nostr_service.cpp504
-rw-r--r--test/nostr_service_test.cpp811
-rw-r--r--vcpkg-configuration.json14
-rw-r--r--vcpkg.json8
13 files changed, 1853 insertions, 194 deletions
diff --git a/.gitignore b/.gitignore
index 8138b0a..5cf2f9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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"
+ }
+ ]
+ }
diff --git a/README.md b/README.md
index 4445cee..e9189dc 100644
--- a/README.md
+++ b/README.md
@@ -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"
+ ]
+}