diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..8673059 --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,58 @@ +name: unix-ci + +on: + push: + branches: [ "master", "develop", "release-*" ] + pull_request: + branches: [ "master", "develop", "release-*" ] + +env: + # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) + BUILD_TYPE: Release + APT_INSTALL_LINUX: 'sudo apt -y install build-essential cmake libboost-all-dev miniupnpc libunbound-dev libunwind8-dev pkg-config libssl-dev libzmq3-dev libsodium-dev libhidapi-dev libnorm-dev libusb-1.0-0-dev libpgm-dev libprotobuf-dev protobuf-compiler' + APT_SET_CONF: | + echo "Acquire::Retries \"3\";" | sudo tee -a /etc/apt/apt.conf.d/80-custom + echo "Acquire::http::Timeout \"120\";" | sudo tee -a /etc/apt/apt.conf.d/80-custom + echo "Acquire::ftp::Timeout \"120\";" | sudo tee -a /etc/apt/apt.conf.d/80-custom + BREW_INSTALL_MAC: 'HOMEBREW_NO_AUTO_UPDATE=1 brew install boost hidapi openssl zmq libpgm miniupnpc expat libunwind-headers protobuf unbound' + +jobs: + build-and-test: + runs-on: ${{matrix.os}} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - name: set apt conf (Debian base Linux) + if: matrix.os == 'ubuntu-latest' + run: ${{env.APT_SET_CONF}} + - name: update apt (Debian based Linux) + if: matrix.os == 'ubuntu-latest' + run: sudo apt update + - name: Install dependencies (Debian based Linux) + if: matrix.os == 'ubuntu-latest' + run: ${{env.APT_INSTALL_LINUX}} + - name: Install dependencies (MacOS) + if: matrix.os == 'macos-latest' + run: ${{env.BREW_INSTALL_MAC}} + - name: Checkout Monero Source + uses: actions/checkout@v4 + with: + repository: monero-project/monero + path: ${{github.workspace}}/monero/ + submodules: recursive + - name: Checkout LWSF Source + uses: actions/checkout@v4 + with: + path: ${{github.workspace}}/lwsf + submodules: recursive + - name: Configure LWS + # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make. + # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type + run: cmake -B ${{github.workspace}}/lwsf/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DMONERO_SOURCE_DIR=${{github.workspace}}/monero -DLWSF_BUILD_TESTS=ON ${{github.workspace}}/lwsf + - name: Build LWSF + # Build your program with the given configuration + run: (cd ${{github.workspace}}/lwsf/build && make -j$(nproc)) + - name: Run Tests + run: cd ${{github.workspace}}/lwsf/build && ctest diff --git a/CMakeLists.txt b/CMakeLists.txt index f1d1278..08bedc4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,8 @@ enable_language(CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +option(LWSF_BUILD_TESTS "Build Tests" OFF) + if (NOT MONERO_SOURCE_DIR) message(FATAL_ERROR "The argument -DMONERO_SOURCE_DIR must specify a location of a monero source tree") endif() @@ -89,4 +91,7 @@ set_property(TARGET monero::libraries PROPERTY ) add_subdirectory(src) - +if (LWSF_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 463c895..7280869 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -27,6 +27,7 @@ # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. include_directories(.) +add_subdirectory(net) add_subdirectory(wire) set(lwsf_sources @@ -84,6 +85,7 @@ target_link_libraries(lwsf-api PUBLIC monero::libraries PRIVATE + lwsf-net lwsf-wire lwsf-wire-json lwsf-wire-msgpack diff --git a/src/backend.cpp b/src/backend.cpp index f0d178b..344d3f1 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -28,6 +28,7 @@ #include "backend.h" +#include #include #include #include "crypto/crypto.h" // monero/src @@ -77,10 +78,10 @@ namespace lwsf { namespace internal { namespace backend namespace { constexpr const error default_subaddr_state = error::subaddr_disabled; - constexpr const auto rpc_unapproved = rpc::error(403); - constexpr const auto rpc_max_subaddresses = rpc::error(409); - constexpr const auto rpc_internal_error = rpc::error(500); - constexpr const auto rpc_not_implemented = rpc::error(501); + constexpr const auto rpc_unapproved = http::error(403); + constexpr const auto rpc_max_subaddresses = http::error(409); + constexpr const auto rpc_internal_error = http::error(500); + constexpr const auto rpc_not_implemented = http::error(501); template std::uint32_t add_uint32_clamp(const T index, const U lookahead) @@ -153,7 +154,7 @@ namespace lwsf { namespace internal { namespace backend { return !lookahead.major && !lookahead.minor && subaccounts.size() == 1 && !subaccounts[0].last; } - + template void map_address_book_entry(F& format, T& self) { @@ -468,17 +469,12 @@ namespace lwsf { namespace internal { namespace backend struct merge_results { std::vector> new_transactions; - boost::container::flat_set> new_subaddrs; + boost::container::flat_set new_subaddrs; + boost::optional lookahead_fail; void merge_subaddr(const rpc::address_meta& meta) { - auto elem = new_subaddrs.find(meta.maj_i); - if (elem == new_subaddrs.end()) - elem = new_subaddrs.insert(rpc::subaddrs{meta.maj_i}).first; - - const auto existing = elem->head; - std::get<0>(elem->head) = std::min(std::get<0>(existing), meta.min_i); - std::get<1>(elem->head) = std::max(std::get<1>(existing), meta.min_i); + new_subaddrs.insert(meta); } }; @@ -487,6 +483,7 @@ namespace lwsf { namespace internal { namespace backend // Remember that this function provides the strong exception guarantee. merge_results out; + out.lookahead_fail = source.lookahead_fail; /* Backend server could remove or modify txes (rescan or bug fix); the easiest way to handle this is to start a new copy of the txes. This is @@ -587,13 +584,13 @@ namespace lwsf { namespace internal { namespace backend // udpate "serer_lookahead" values later, we force lookahead from zero for (const auto& sub : out.new_subaddrs) { - if (std::numeric_limits::max() <= sub.key) + if (std::numeric_limits::max() <= sub.maj_i) throw std::runtime_error{"merge_response exceeded max size_t value"}; - if (self.primary.subaccounts.size() <= sub.key) - self.primary.subaccounts.resize(std::size_t(sub.key) + 1); + if (self.primary.subaccounts.size() <= sub.maj_i) + self.primary.subaccounts.resize(std::size_t(sub.maj_i) + 1); - auto& acct = self.primary.subaccounts.at(sub.key); - acct.last = std::max(acct.last, std::get<1>(sub.head)); + auto& acct = self.primary.subaccounts.at(sub.maj_i); + acct.last = std::max(acct.last, sub.min_i); } // Update txes _after_ subaccounts table @@ -602,6 +599,8 @@ namespace lwsf { namespace internal { namespace backend self.blockchain_height = source.blockchain_height; self.primary.restore_height = source.start_height; self.primary.scan_height = source.scanned_block_height; + self.server_lookahead.major = source.lookahead.maj_i; + self.server_lookahead.minor = source.lookahead.min_i; self.fee_mask = unspents.fee_mask; if (unspents.fees.empty()) @@ -610,118 +609,145 @@ namespace lwsf { namespace internal { namespace backend self.per_byte_fee[0] = unspents.per_byte_fee; } else - self.per_byte_fee = unspents.fees; + self.per_byte_fee = std::move(unspents.fees); return out; } - /*! Update `server_lookahead` minor fields based on `all`. The top-level - major lookahead is set to error state if `false` is returned, and set to - the server lookahead iff true is returned. - \return True iff the server has enough lookahead for all fields. */ - bool update_lookaheads(wallet& self, const boost::container::flat_set>& all) noexcept + std::error_code handle_subaddress_error(std::error_code error) noexcept { - self.server_lookahead = 0; - self.lookahead_error = {}; + if (error == rpc_max_subaddresses) + error = {error::subaddr_ahead}; + else if (error == rpc_not_implemented) + error = {error::subaddr_disabled}; + else if (error == wire::error::schema::array_max_element) + error = {error::subaddr_local}; + + return error; + } - const auto lookahead = self.primary.lookahead; - if (no_subaddresses(epee::to_span(self.primary.subaccounts), lookahead)) - return true; + void prep_primary_account(sub_account& self) + { + // Enforce special account {0,0} exists and is labeled + self.detail.try_emplace(0).first->second.label = std::string{config::default_primary_name}; + self.last = 0; + } - static_assert(std::is_samekey)>()); - const std::uint32_t server_lookahead = all.empty() ? - std::uint32_t(0) : all.rbegin()->key; + bool should_attempt_rescan(const account& self, boost::container::flat_map subaddrs, const std::uint64_t max_subaddresses) + { + return self.needed_subaddresses(std::move(subaddrs)) <= max_subaddresses; + } - // Ensure major lookahead starts at zero, has no gaps, and "far" enough. - const std::size_t last_account = std::max(self.primary.subaccounts.size(), std::size_t(1)) - 1; - bool min_met = add_uint32_clamp(last_account, lookahead.major) <= server_lookahead; - min_met &= all.size() == std::uint64_t(server_lookahead) + 1; + template + void prep_subs(std::shared_ptr self, rpc::login&& creds, F f) + { + struct frame + { + const std::shared_ptr self; + F f; + rpc::upsert_subaddrs_request request; + rpc::upsert_subaddrs_response response; + + frame(std::shared_ptr&& self, rpc::login&& creds, F&& f) + : self(std::move(self)), f(std::move(f)), request{std::move(creds)}, response{} + {} + }; - // track the min lookahead we have - if (!min_met) + struct handler : boost::asio::coroutine { - std::uint32_t last = -1; - for (const rpc::subaddrs& e : all) + std::shared_ptr frame_; + + explicit handler(std::shared_ptr in) + : boost::asio::coroutine(), frame_(std::move(in)) + {} + + void operator()(const std::error_code error = {}) { - if (last + 1 == e.key) - self.server_lookahead = ++last; - else - break; + LWSF_VERIFY(frame_ && frame_->self); + wallet& self = *frame_->self; + BOOST_ASIO_CORO_REENTER(*this) + { + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, frame_->request, std::addressof(frame_->response), std::move(*this) + ); + frame_->f(error); + } } - } - else - self.server_lookahead = server_lookahead; - - for (std::size_t i = 0; i < self.primary.subaccounts.size(); ++i) - { - auto& acct = self.primary.subaccounts[i]; - const auto elem = all.find(i); - const bool has_range = elem != all.end(); + }; - const std::uint32_t this_lookahead = has_range ? std::get<1>(elem->head) : 0; - min_met &= add_uint32_clamp(acct.last, lookahead.minor) <= this_lookahead; - min_met &= !lookahead.minor || (has_range && std::get<0>(elem->head) == 0); + LWSF_VERIFY(self); + const auto& required_subs = self->primary.subaccounts; + if (std::numeric_limits::max() < required_subs.size()) + throw std::runtime_error{"prep_subs exceeded max major addresses"}; - acct.server_lookahead = this_lookahead; - } + handler runner{std::make_shared(std::move(self), std::move(creds), std::move(f))}; - // Ensure that unused minor lookahead is far enough - const std::uint32_t last_minor = std::max(lookahead.minor, std::uint32_t(1)) - 1; - for (std::size_t i = self.primary.subaccounts.size(); i < all.size(); ++i) + bool needed = 1 < required_subs.size(); + for (std::size_t i = 0; i < required_subs.size(); ++i) { - if (!min_met) - break; - - const auto& range = all.nth(i)->head; - min_met &= last_minor <= std::get<1>(range); - min_met &= std::get<0>(range) == 0; + const std::uint32_t last = required_subs[i].last; + runner.frame_->request.subaddrs_.emplace(std::uint32_t(i), rpc::subaddrs{last}); + needed |= bool(last); } - if (!min_met) - self.lookahead_error = {error::subaddr_ahead}; - return min_met; + if (!needed) + return runner.frame_->f(std::error_code{}); + runner(); } - void fill_upsert(const wallet& self, boost::container::flat_set>& out) + //! Bind N arguments into a callback that takes zero arguments to run + template + struct binder { - static constexpr const std::uint32_t max_index = - std::numeric_limits::max(); - - // remember upsert_subaddrs is inclusive in the minor ranges - - const auto& accts = self.primary.subaccounts; - const std::size_t end = accts.size() + self.primary.lookahead.major; - out.reserve(end); + std::shared_ptr self; + F f; + std::tuple args; - for (std::size_t i = 0; i < accts.size() && i <= max_index; ++i) - out.insert(out.end(), rpc::subaddrs{std::uint32_t(i)})->head = {0, std::max(std::uint32_t(1), add_uint32_clamp(accts[i].last, self.primary.lookahead.minor)) - 1}; + template + void run(std::index_sequence) + { f(std::move(std::get(args)...)); } - /* `0 < lookahead.major & lookahead.minor == 0` is a strange edge case. - Assume `lookahead.minor == 1` in that scenario. */ - - const std::uint32_t minor = std::max(std::uint32_t(1), self.primary.lookahead.minor) - 1; - for (std::size_t i = accts.size(); i < end && i <= max_index; ++i) - out.insert(out.end(), rpc::subaddrs{std::uint32_t(i)})->head = {0, minor}; - } + void operator()() + { run(std::make_index_sequence{}); } + }; - std::error_code handle_lookahead_error(std::error_code error) noexcept + //! Forward N arguments in a callback to be invoked on `wallet::strand + template + struct callback_on_strand { - if (error == rpc_max_subaddresses) - error = {error::subaddr_ahead}; - else if (error == rpc_not_implemented) - error = {error::subaddr_disabled}; - else if (error == wire::error::schema::array_max_element) - error = {error::subaddr_local}; - - return error; - } + std::shared_ptr self; + F f; - void prep_primary_account(sub_account& self) + template + void operator()(T... args) + { + // Use `post` instead of `dispatch` due to locking in handlers + LWSF_VERIFY(self); + boost::asio::post( + self->strand, + binder{self, std::move(f), std::tuple(std::move(args)...)} + ); + } + }; + + /*! The ASIO handlers in this file all use `boost::asio::coroutine` which + is a light-weight async routine that looks traditional stack based code. + Unfortunately, a lock must be held for many of the functions, because the + API (basically) demands it. These handlers must release their lock before + being called again or deadlock occurs. To get around this, we wrap all + code needed to acquire `backend::wallet::sync` in a handler that posts to + the strand, so the handler never calls itself directly. + + As an example, if `rpc::invoke_async` calls the handler immediately, this + would cause deadlock, except the outter handler simply posts the + operation to be deferred later, the lock is released, and then asio runs + the handler in the next loop iteration. A bit gross, but this makes + `backend::wallet::get_decoys` and `backend::wallet::send_tx` super fast + and never blocking on anything (except briefly when queueing http stuff). */ + template + callback_on_strand post_on_strand(std::shared_ptr self, F f) { - // Enforce special account {0,0} exists and is labeled - self.detail.try_emplace(0).first->second.label = std::string{config::default_primary_name}; - self.last = 0; - self.server_lookahead = 0; + return {std::move(self), std::move(f)}; } } // anonymous @@ -746,8 +772,75 @@ namespace lwsf { namespace internal { namespace backend void write_bytes(wire::writer& dest, const account& source) { map_account(dest, source); } + std::uint64_t account::needed_subaddresses(boost::container::flat_map subaddrs) const + { + if (this->lookahead.major == 0 || this->lookahead.minor == 0) + return 0; + + std::uint64_t count = 0; + const auto add_subaddresses = [&count] (const std::uint64_t next) + { + if (next <= std::numeric_limits::max() - count) + count += next; + else + count = std::numeric_limits::max(); + }; + + const auto count_subaddresses = [this, &add_subaddresses] (const boost::container::flat_set, std::less<>>& minors) + { + auto last = minors.begin(); + while (last != minors.end() && std::get<1>(*last) < this->lookahead.minor) + ++last; + + // tally minium first, as determined by minor lookahead + { + std::uint64_t next = this->lookahead.minor; + if (last != minors.end()) + next = std::max(next, std::uint64_t(std::get<1>(*last)) + 1); + + add_subaddresses(next); + } + + // last was just included in total + if (last != minors.end()) + ++last; + + // tally any upserted out of lookahead range + for ( ; last != minors.end(); ++last) + add_subaddresses(std::uint64_t(std::get<1>(*last)) - std::uint64_t(std::get<0>(*last)) + 1); + }; + + if (this->subaccounts.empty()) + throw std::runtime_error{"Subaccounts has invalid state"}; + + // all registered subaddresses + for (std::size_t i = 0; i < this->subaccounts.size(); ++i) + { + if (std::numeric_limits::max() < i) + throw std::runtime_error{"Invalid subaddress major index"}; + + auto major = subaddrs.emplace(std::uint32_t(i), rpc::subaddrs{}).first; + major->second.merge(add_uint32_clamp(this->subaccounts[i].last, this->lookahead.minor - 1)); + count_subaddresses(major->second.value); + } + + const std::uint32_t last = + add_uint32_clamp(this->subaccounts.size() - 1, this->lookahead.major); + for (std::size_t i = this->subaccounts.size(); i < last; ++i) + add_subaddresses(this->lookahead.minor); + + // go through subaddresses after main lookahead + for (auto elem = subaddrs.lower_bound(this->lookahead.major); elem != subaddrs.end(); ++elem) + { + for (const auto& minor : elem->second.value) + add_subaddresses(std::uint64_t(std::get<1>(minor)) - std::uint64_t(std::get<0>(minor)) + 1); + } + + return count; + } + sub_account::sub_account() - : detail(), last(0), server_lookahead(0) + : detail(), last(0) {} std::string_view sub_account::sub_label(const std::uint32_t minor) const noexcept @@ -818,9 +911,10 @@ namespace lwsf { namespace internal { namespace backend return true; } - wallet::wallet() + wallet::wallet(boost::asio::io_context& io) : listener(nullptr), - client(), + strand(io), + client{}, primary{}, per_byte_fee(), refresh_error(), @@ -829,7 +923,7 @@ namespace lwsf { namespace internal { namespace backend last_sync(), blockchain_height(0), fee_mask(0), - server_lookahead(0), + server_lookahead{}, sync(), sync_listener(), sync_refresh(), @@ -880,6 +974,13 @@ namespace lwsf { namespace internal { namespace backend ); } + bool wallet::lookahead_good() const noexcept + { + return + primary.lookahead.major <= server_lookahead.major && + primary.lookahead.minor <= server_lookahead.minor; + } + expect wallet::to_bytes() const { epee::byte_stream dest{}; @@ -908,471 +1009,674 @@ namespace lwsf { namespace internal { namespace backend return status; } - expect wallet::login_is_new() + void wallet::login_is_new(std::shared_ptr self, std::function)> f) { - // Remember that this function provides the strong exception guarantee. - bool new_account = false; - boost::unique_lock lock{sync}; + struct frame { - passed_login = false; - per_byte_fee.clear(); - fee_mask = 0; - server_lookahead = 0; - import_error = {}; - lookahead_error = error::subaddr_disabled; - const rpc::login_request login{ - primary.address, primary.view.sec, true, primary.generated_locally - }; + const std::shared_ptr self; + const std::function)> f; + rpc::login_request login; + rpc::login_response response; + unsigned i; + + explicit frame(std::shared_ptr in, std::function)> f) + : self(std::move(in)), + f(std::move(f)), + login{}, + response{}, + i(0) + {} + }; - lock.unlock(); - const auto response = rpc::invoke(client, client_prefix, login); - if (!response) - { - if (response == rpc_unapproved) - return {error::approval}; - else if (response == rpc_internal_error) - return {error::network_type}; // almost always this - else if (response == rpc_not_implemented) - return {error::create}; - return response.error(); - } - lock.lock(); + struct handler : boost::asio::coroutine + { + std::shared_ptr frame_; - passed_login = true; - if (response->start_height) - primary.restore_height = *response->start_height; + explicit handler(std::shared_ptr frame) + : boost::asio::coroutine(), frame_(std::move(frame)) + {} - if (no_subaddresses(epee::to_span(primary.subaccounts), primary.lookahead)) + void operator()(std::error_code error = {}) { - lookahead_error = {}; - return response->new_address; - } - new_account = response->new_address; - } + LWSF_VERIFY(frame_ && frame_->self); - // Converting `rpc::invoke` into exceptions is easier here - try - { - rpc::login login{primary.address, primary.view.sec}; - { - lock.unlock(); - const auto response = rpc::invoke(client, client_prefix, login).value(); - lock.lock(); - if (update_lookaheads(*this, response.all_subaddrs)) - return new_account; - } + // Remember that this function provides the strong exception guarantee. + wallet& self = *frame_->self; + assert(self.strand.running_in_this_thread()); + const boost::lock_guard lock{self.sync}; + BOOST_ASIO_CORO_REENTER(*this) + { + if (self.passed_login) + return frame_->f(false); // not a new account + + self.passed_login = false; + self.probed_lookahead = false; + self.per_byte_fee.clear(); + self.fee_mask = 0; + self.server_lookahead = {}; + self.refresh_error = {}; + self.subaddress_error = {}; + self.import_error = {}; + self.lookahead_error = self.lookahead_good() ? std::error_code{} : error::subaddr_disabled; + + frame_->login = rpc::login_request{ + self.primary.address, self.primary.view.sec, self.primary.lookahead, true, self.primary.generated_locally + }; - // lookaheads not far enough - rpc::upsert_subaddrs_request request{std::move(login), {}, true /* get_all */}; - fill_upsert(*this, request.subaddrs_); + for ( ; frame_->i < 2; ++frame_->i) + { + /* Do not release `lock` during login calls, want to temporarily block + everything until complete or timeout. */ - lock.unlock(); - const auto response = rpc::invoke(client, client_prefix, request).value(); - lock.lock(); - update_lookaheads(*this, response.all_subaddrs); - } - catch (const std::system_error& e) - { - if (e.code() == rpc_unapproved) // happens on new account creation - return {error::approval}; - else - lookahead_error = handle_lookahead_error(e.code()); - } + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, frame_->login, std::addressof(frame_->response), post_on_strand(frame_->self, std::move(*this)) + ); - return new_account; - } + if (error) + { + if (error == rpc_unapproved) + return frame_->f({error::approval}); + else if (error == rpc_internal_error) + return frame_->f({error::network_type}); // almost always this + else if (error == rpc_not_implemented) + return frame_->f({error::create}); + else if (0 < frame_->i) + return frame_->f(error); + frame_->login.lookahead = {}; + } + else // response + { + self.passed_login = true; - std::error_code wallet::refresh(const bool mandatory) - { - // Remember that this function provides the strong exception guarantee. + if (frame_->response.start_height) + self.primary.restore_height = *frame_->response.start_height; - boost::unique_lock lock_refresh{sync_refresh}; - const auto now = std::chrono::steady_clock::now(); + if (frame_->response.lookahead) + { + self.server_lookahead = {frame_->response.lookahead->maj_i, frame_->response.lookahead->min_i}; + self.lookahead_error = {}; + } - if (!mandatory) - { - if (now - last_sync < config::refresh_interval_min) - return refresh_error; - } - last_sync = now; + /* Make sure all registered subs are known to this backend. This can + differ from lookahead because API allows arbitrary major,minor + requests to be performed. */ + BOOST_ASIO_CORO_YIELD prep_subs( + frame_->self, {frame_->login.address, frame_->login.view_key}, post_on_strand(frame_->self, std::move(*this)) + ); + if (error && !self.subaddress_error) + self.subaddress_error = handle_subaddress_error(error); - struct call_refreshed - { - void operator()(wallet* ptr) const - { - if (!ptr) return; - const boost::lock_guard lock{ptr->sync_listener}; - if (!ptr->listener) return; - ptr->listener->refreshed(); + break; // retry loop + } + } + + if (!self.lookahead_good() && !self.lookahead_error) + self.lookahead_error = {error::subaddr_ahead}; + frame_->f(frame_->response.new_address); + } } }; - std::unique_ptr refresh_on_exit{this}; + // Post in case of nested callback + LWSF_VERIFY(self); + boost::asio::post(self->strand, handler{std::make_shared(self, std::move(f))}); + } - boost::unique_lock lock{sync}; - const bool try_login = !passed_login; - const rpc::login login{primary.address, primary.view.sec}; - - lock.unlock(); - if (try_login) + void wallet::refresh(std::shared_ptr self, const bool mandatory, std::function f) + { + // everything used across async calls + struct frame { - const std::error_code failed_login = this->login(); - if (failed_login) + std::shared_ptr self; + const std::function f; + rpc::login login; + rpc::get_address_txs txs_response; + rpc::get_unspent_outs_response outs_response; + rpc::get_version info; + rpc::get_subaddrs subaddrs; + merge_results merged; + std::uint64_t orig_scan_height; + std::uint64_t restore_height; + boost::unique_lock lock_refresh; + const bool mandatory; + + explicit frame(std::shared_ptr in, std::function&& f, const bool mandatory) + : self(std::move(in)), + f(std::move(f)), + login{}, + txs_response{}, + outs_response{}, + info{}, + subaddrs{}, + merged{}, + orig_scan_height(0), + restore_height(0), + lock_refresh(self->sync_refresh), + mandatory(mandatory) + {} + + ~frame() { - lock.lock(); - return refresh_error = failed_login; + if (self) + { + const boost::lock_guard lock{self->sync_listener}; + if (self->listener) + self->listener->refreshed(); + } } - } - - expect outs_response{common_error::kInvalidArgument}; - const auto txs_response = rpc::invoke(client, client_prefix, login); - if (txs_response) + }; + + struct handler : boost::asio::coroutine { - const rpc::get_unspent_outs_request request{login, rpc::uint64_string(0), 0, true}; - outs_response = rpc::invoke(client, client_prefix, request); - } - lock.lock(); + std::shared_ptr frame_; - passed_login = bool(txs_response) && bool(outs_response); // reset state + explicit handler(std::shared_ptr in) + : boost::asio::coroutine(), frame_(std::move(in)) + {} - if (!txs_response) - { - if (txs_response == rpc_unapproved) - return refresh_error = {error::approval}; - return refresh_error = txs_response.error(); - } - if (!outs_response) - return refresh_error = outs_response.error(); + void operator()(const std::error_code error = {}) + { + LWSF_VERIFY(frame_ && frame_->self); - const std::uint64_t orig_scan_height = primary.scan_height; - auto merged = merge_response(*this, *txs_response, *outs_response); + wallet& self = *frame_->self; + assert(self.strand.running_in_this_thread()); + boost::unique_lock lock{self.sync}; + BOOST_ASIO_CORO_REENTER(*this) + { + { + const auto now = std::chrono::steady_clock::now(); + if (!frame_->mandatory) + { + if (now - self.last_sync < config::refresh_interval_min) + return frame_->f(self.refresh_error); + } + self.last_sync = now; + } - if (import_error || primary.requested_start < primary.restore_height) - { - const std::uint64_t from_height = primary.requested_start; - lock.unlock(); - restore_height(from_height); - lock.lock(); - } + if (!self.passed_login) + { + BOOST_ASIO_CORO_YIELD login(frame_->self, post_on_strand(frame_->self, std::move(*this))); + if (error) + return frame_->f(self.refresh_error = error); + } - if (lookahead_error) - { - rpc::upsert_subaddrs_request request{login, {}, true /* get_all */}; - fill_upsert(*this, request.subaddrs_); - - lock.unlock(); - const auto response = rpc::invoke(client, client_prefix, request); - lock.lock(); - if (response && update_lookaheads(*this, response->all_subaddrs)) - lookahead_error = {}; - } + frame_->orig_scan_height = self.primary.scan_height; + frame_->login = rpc::login{self.primary.address, self.primary.view.sec}; + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, frame_->login, std::addressof(frame_->txs_response), post_on_strand(frame_->self, std::move(*this)) + ); - if (!merged.new_subaddrs.empty() && !lookahead_error) - { - server_lookahead = std::max(server_lookahead, merged.new_subaddrs.rbegin()->key); + if (error) + { + self.passed_login = false; + if (error == rpc_unapproved) + return frame_->f(self.refresh_error = error::approval); + return frame_->f(self.refresh_error = error); + } - // ensure lookahead is from zero, or our logic is busted - for (auto& sub : merged.new_subaddrs) - { - std::get<0>(sub.head) = 0; - std::get<1>(sub.head) = add_uint32_clamp(std::get<1>(sub.head), primary.lookahead.minor); - } + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, + rpc::get_unspent_outs_request{frame_->login, rpc::uint64_string(0), 0, true}, + std::addressof(frame_->outs_response), + post_on_strand(frame_->self, std::move(*this)) + ); - const rpc::upsert_subaddrs_request upsert_request{ - login, std::move(merged.new_subaddrs), false /* get_all */ - }; + if (error) + { + self.passed_login = false; + return frame_->f(self.refresh_error = error); + } - lock.unlock(); - const auto upsert_response = rpc::invoke(client, client_prefix, upsert_request); - lock.lock(); + // Remember that this function provides the strong exception guarantee. + frame_->merged = merge_response(self, frame_->txs_response, frame_->outs_response); - if (upsert_response) - { - for (const auto& sub : upsert_request.subaddrs_) - { - auto& acct = primary.subaccounts.at(sub.key); - acct.server_lookahead = std::max(acct.server_lookahead, std::get<1>(sub.head)); - } + if (frame_->merged.lookahead_fail || !self.lookahead_good()) + { + if (!self.lookahead_error) + self.lookahead_error = {error::subaddr_ahead}; + if (!self.probed_lookahead) + { + self.probed_lookahead = true; // check just once for re-scan + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, rpc::empty{}, std::addressof(frame_->info), post_on_strand(frame_->self, std::move(*this)) + ); - if (primary.lookahead.major) - { - const std::uint32_t last_used = upsert_request.subaddrs_.rbegin()->key; - const std::uint32_t maj_i = add_uint32_clamp(unsigned(1), last_used); - const std::uint32_t maj_count = primary.lookahead.major; - const std::uint32_t min_count = std::max(std::uint32_t(1), primary.lookahead.minor); - const rpc::provision_subaddrs_request provision_request{ - login, maj_i, 0, maj_count, min_count, false /* get_all */ - }; + if (!error) + { + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, frame_->login, std::addressof(frame_->subaddrs), post_on_strand(frame_->self, std::move(*this)) + ); + + if (!error && should_attempt_rescan(self.primary, std::move(frame_->subaddrs.all_subaddrs), frame_->info.max_subaddresses)) + { + frame_->restore_height = + frame_->merged.lookahead_fail.value_or(self.primary.restore_height); + BOOST_ASIO_CORO_YIELD + restore_height(frame_->self, frame_->restore_height, post_on_strand(frame_->self, std::move(*this))); + } + } + else + self.lookahead_error = error; + } + } + else if (self.lookahead_good()) + { + self.lookahead_error = {}; + self.probed_lookahead = false; + if (self.primary.restore_height <= self.primary.requested_start) + self.import_error = {}; + } + if (!self.import_error && self.primary.requested_start < self.primary.restore_height) + { + BOOST_ASIO_CORO_YIELD restore_height( + frame_->self, self.primary.requested_start, post_on_strand(frame_->self, std::move(*this)) + ); + } + + // return error if subaddresses enabled, and recovered wallet + const std::shared_ptr strong_count = frame_->self; + frame_->self.reset(); // release before acquiring `sync_listener`. + const std::error_code rc = self.refresh_error = + self.import_error ? + self.import_error : self.subaddress_error ? + self.subaddress_error : self.lookahead_error; + const boost::lock_guard lock_listener{self.sync_listener}; + if (!self.listener) + return frame_->f(rc); + + // Call listener functions without holding `sync`, in case a call is made + // back into the library. + const std::uint64_t new_scan_height = + std::max(frame_->orig_scan_height, self.primary.scan_height); lock.unlock(); - const auto provision_response = rpc::invoke(client, client_prefix, provision_request); - lock.lock(); + frame_->lock_refresh.unlock(); + + self.listener->refreshed(); + const auto& merged = frame_->merged; + if (!merged.new_transactions.empty() || new_scan_height - frame_->orig_scan_height) + self.listener->updated(); + + for (std::uint64_t i = frame_->orig_scan_height; i < new_scan_height; ++i) + self.listener->newBlock(i); + + for (const auto& tx : merged.new_transactions) + { + const auto txid = epee::string_tools::pod_to_hex(tx->id); + if (tx->direction == Monero::TransactionInfo::Direction_In) + { + if (tx->height) + self.listener->moneyReceived(txid, tx->amount); + else + self.listener->unconfirmedMoneyReceived(txid, tx->amount); + } + else + self.listener->moneySpent(txid, tx->amount); + } - if (provision_response) - server_lookahead = std::max(server_lookahead, add_uint32_clamp(last_used, maj_count)); - else // if !provision_response - lookahead_error = handle_lookahead_error(provision_response.error()); + frame_->f(rc); } } - else // if !upsert_response - lookahead_error = handle_lookahead_error(upsert_response.error()); - } // if new subaddress(es) - - // return error if subaddresses enabled, and recovered wallet - refresh_on_exit.release(); // release before acquiring `sync_listener`. - const std::error_code rc = refresh_error = - import_error ? import_error : lookahead_error; - const boost::lock_guard lock_listener{sync_listener}; - if (!listener) - return rc; - - // Call listener functions without holding `sync`, in case a call is made - // back into the library. - const std::uint64_t new_scan_height = - std::max(orig_scan_height, primary.scan_height); - lock.unlock(); - lock_refresh.unlock(); - - listener->refreshed(); - if (!merged.new_transactions.empty() || new_scan_height - orig_scan_height) - listener->updated(); - - for (std::uint64_t i = orig_scan_height; i < new_scan_height; ++i) - listener->newBlock(i); - - for (const auto& tx : merged.new_transactions) + }; + + LWSF_VERIFY(self); + boost::asio::post(self->strand, handler{std::make_shared(self, std::move(f), mandatory)}); + } + + void wallet::register_subaccount(std::shared_ptr self, const std::uint32_t maj_i, std::function f) + { + struct frame { - const auto txid = epee::string_tools::pod_to_hex(tx->id); - if (tx->direction == Monero::TransactionInfo::Direction_In) + const std::shared_ptr self; + const std::function f; + rpc::provision_subaddrs_response response; + const std::uint32_t maj_i; + + explicit frame(std::shared_ptr self, std::function&& f, const std::uint32_t maj_i) + : self(std::move(self)), f(std::move(f)), response{}, maj_i(maj_i) + {} + }; + + struct handler : boost::asio::coroutine + { + std::shared_ptr frame_; + + explicit handler(std::shared_ptr in) noexcept + : boost::asio::coroutine(), frame_(std::move(in)) + {} + + static rpc::provision_subaddrs_request get_request(wallet& self, const std::uint32_t maj_i) { - if (tx->height) - listener->moneyReceived(txid, tx->amount); - else - listener->unconfirmedMoneyReceived(txid, tx->amount); + const std::uint32_t minor_count = std::max(std::uint32_t(1), self.primary.lookahead.minor); + return { + rpc::login{self.primary.address, self.primary.view.sec}, + maj_i, 0, 1, minor_count, false, /* get_all */ + }; } - else - listener->moneySpent(txid, tx->amount); - } - return rc; + void operator()(const std::error_code error = {}) + { + LWSF_VERIFY(frame_ && frame_->self && frame_->f); + + wallet& self = *frame_->self; + assert(self.strand.running_in_this_thread()); + const boost::lock_guard lock{self.sync}; + BOOST_ASIO_CORO_REENTER(*this) + { + LWSF_VERIFY(frame_->maj_i < self.primary.subaccounts.size()); + + if (!self.passed_login) + { + BOOST_ASIO_CORO_YIELD login(frame_->self, post_on_strand(frame_->self, std::move(*this))); + if (error) + return frame_->f(error); + } + + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, + get_request(self, frame_->maj_i), + std::addressof(frame_->response), + post_on_strand(frame_->self, std::move(*this)) + ); + + if (error && !self.subaddress_error) + self.subaddress_error = handle_subaddress_error(error); + frame_->f(self.subaddress_error); + } + } + }; + + LWSF_VERIFY(self); + boost::asio::post(self->strand, handler{std::make_shared(self, std::move(f), maj_i)}); } - std::error_code wallet::register_subaccount(const std::uint32_t maj_i) + void wallet::register_subaddress(std::shared_ptr self, const std::uint32_t maj_i, const std::uint32_t min_i, std::function f) { - boost::unique_lock lock{sync}; - if (primary.subaccounts.size() <= maj_i) - throw std::runtime_error{"wallet::register_subaccount exceeded subaddress indexes"}; - - if (!passed_login) + struct frame { - lock.unlock(); - const std::error_code error = login(); - if (error) - return error; - lock.lock(); - } + const std::shared_ptr self; + const std::function f; + rpc::provision_subaddrs_response response; + const std::uint32_t maj_i; + const std::uint32_t min_i; - const std::uint32_t needed_maj_i = add_uint32_clamp(maj_i, primary.lookahead.major); - if (!lookahead_error && needed_maj_i <= server_lookahead) - return {}; + frame(std::shared_ptr self, std::uint32_t maj_i, std::uint32_t min_i, std::function&& f) + : self(std::move(self)), f(std::move(f)), response{}, maj_i(maj_i), min_i(min_i) + {} - const std::uint32_t major_count = needed_maj_i - server_lookahead; - const std::uint32_t minor_count = std::max(std::uint32_t(1), primary.lookahead.minor); - const rpc::provision_subaddrs_request request{ - rpc::login{primary.address, primary.view.sec}, - server_lookahead + 1, 0, major_count, minor_count, false /* get_all */ + rpc::provision_subaddrs_request get_request(wallet& self) + { + const std::uint32_t needed_min_i = add_uint32_clamp(min_i, self.primary.lookahead.minor); + const std::uint32_t needed_count = add_uint32_clamp(unsigned(1), needed_min_i); + return { + rpc::login{self.primary.address, self.primary.view.sec}, + maj_i, 0, 1, needed_count, false /* get_all */ + }; + } }; - lock.unlock(); - const auto response = rpc::invoke(client, client_prefix, request); - lock.lock(); - if (response) - server_lookahead = std::max(server_lookahead, needed_maj_i); - else if (!lookahead_error) - lookahead_error = handle_lookahead_error(response.error()); + struct handler : boost::asio::coroutine + { + std::shared_ptr frame_; + + explicit handler(std::shared_ptr in) + : boost::asio::coroutine(), frame_(std::move(in)) + {} + + void operator()(const std::error_code error = {}) + { + LWSF_VERIFY(frame_ && frame_->self); + wallet& self = *frame_->self; + assert(self.strand.running_in_this_thread()); + const boost::lock_guard lock{self.sync}; + BOOST_ASIO_CORO_REENTER(*this) + { + if (!self.passed_login) + { + BOOST_ASIO_CORO_YIELD login(frame_->self, post_on_strand(frame_->self, std::move(*this))); + if (error) + return frame_->f(error); + } - return lookahead_error; + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, frame_->get_request(self), std::addressof(frame_->response), post_on_strand(frame_->self, std::move(*this)) + ); + + if (error && !self.subaddress_error) + self.subaddress_error = handle_subaddress_error(error); + frame_->f(self.subaddress_error); + } + } + }; + + LWSF_VERIFY(self); + const boost::lock_guard lock{self->sync}; + LWSF_VERIFY(maj_i < self->primary.subaccounts.size()); + LWSF_VERIFY(min_i < self->primary.subaccounts[maj_i].last); + boost::asio::post(self->strand, handler{std::make_shared(self, maj_i, min_i, std::move(f))}); } - std::error_code wallet::register_subaddress(const std::uint32_t maj_i, const std::uint32_t min_i) + void wallet::set_lookahead(std::shared_ptr self, std::uint32_t major, std::uint32_t minor, std::function f) { - boost::unique_lock lock{sync}; - if (primary.subaccounts.size() <= maj_i) - throw std::invalid_argument{"wallet::register_subaddress exceeded major index"}; + LWSF_VERIFY(self); + const boost::lock_guard lock{self->sync}; + self->primary.lookahead.major = major; + self->primary.lookahead.minor = minor; - auto maj = std::addressof(primary.subaccounts.at(maj_i)); - if (maj->last < min_i) - throw std::invalid_argument{"wallet::register_subaddress exceeded minor index"}; + const std::uint64_t from_height = self->primary.scan_height; + restore_height(std::move(self), from_height, std::move(f)); + } - if (!passed_login) + void wallet::restore_height(std::shared_ptr self, const std::uint64_t height, std::function f) + { + struct frame { - lock.unlock(); - const std::error_code error = login(); - if (error) - return error; - lock.lock(); - } + const std::shared_ptr self; + const std::function f; + rpc::import_response response; + const std::uint64_t height; - const std::uint32_t needed_min_i = add_uint32_clamp(min_i, primary.lookahead.minor); - if (!lookahead_error && needed_min_i <= maj->server_lookahead) - return {}; + frame(std::shared_ptr self, const std::uint64_t height, std::function&& f) + : self(std::move(self)), f(std::move(f)), response{}, height(height) + {} - const std::uint32_t needed_count = add_uint32_clamp(unsigned(1), needed_min_i); - const rpc::provision_subaddrs_request request{ - rpc::login{primary.address, primary.view.sec}, - maj_i, 0, 1, needed_count, false /* get_all */ + void done(const expect result) + { + LWSF_VERIFY(self && f); + if (result) + { + self->import_error = {}; + if (result->lookahead) + { + self->server_lookahead = {result->lookahead->maj_i, result->lookahead->min_i}; + self->lookahead_error = {}; + } + else + self->lookahead_error = {error::subaddr_disabled}; + } + else + { + if (result == rpc_max_subaddresses) + self->import_error = {error::subaddr_ahead}; + else + self->import_error = result.error(); + } + f(result.error()); + } }; - lock.unlock(); - const auto response = rpc::invoke(client, client_prefix, request); - lock.lock(); - - if (response) + struct handler : boost::asio::coroutine { - // address could've changed during unlock - maj = std::addressof(primary.subaccounts.at(maj_i)); - maj->server_lookahead = std::max(maj->server_lookahead, needed_min_i); - } - else if (!lookahead_error) - lookahead_error = handle_lookahead_error(response.error()); - return lookahead_error; - } + std::shared_ptr frame_; - std::error_code wallet::set_lookahead(std::uint32_t major, std::uint32_t minor) - { - boost::unique_lock lock{sync}; - const bool extending = - primary.lookahead.major < major || - primary.lookahead.minor < minor; + explicit handler(std::shared_ptr in) + : boost::asio::coroutine(), frame_(std::move(in)) + {} - primary.lookahead.major = major; - primary.lookahead.minor = minor; + void operator()(const std::error_code error = {}) + { + LWSF_VERIFY(frame_ && frame_->self); + wallet& self = *frame_->self; + assert(self.strand.running_in_this_thread()); + const boost::lock_guard lock{self.sync}; + BOOST_ASIO_CORO_REENTER(*this) + { + if (!self.passed_login) + { + BOOST_ASIO_CORO_YIELD login(frame_->self, post_on_strand(frame_->self, std::move(*this))); + if (error) + frame_->done(error); + } - if (!passed_login) - { - lock.unlock(); - const std::error_code error = login(); - if (error) - return error; - lock.lock(); - } + if (self.primary.restore_height <= frame_->height && self.lookahead_good() && !self.lookahead_error) + return frame_->done(rpc::import_response{.lookahead = rpc::address_meta{self.server_lookahead}}); + + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, + rpc::import_request{{self.primary.address, self.primary.view.sec}, frame_->height, self.primary.lookahead}, + std::addressof(frame_->response), + post_on_strand(frame_->self, std::move(*this)) + ); + + if (error || frame_->response.request_fulfilled) + return frame_->done(error); + + const unsigned total = + unsigned(bool(frame_->response.import_fee)) + bool(frame_->response.payment_address); + switch (total) + { + default: + case 0: + return frame_->done({error::import_pending}); + case 1: + if (frame_->response.import_fee.value_or(rpc::uint64_string(0)) == rpc::uint64_string(0)) + return frame_->done({error::import_pending}); + return frame_->done({error::import_invalid}); + case 2: + break; + } - if (!extending && !lookahead_error) - return {}; + cryptonote::address_parse_info info{}; + if (!cryptonote::get_account_address_from_str(info, convert_net_type(self.primary.type), *frame_->response.payment_address)) + return frame_->done({error::import_invalid}); + if (info.has_payment_id && frame_->response.payment_id) + return frame_->done({error::import_invalid}); + if (frame_->response.payment_id && (frame_->response.payment_id->empty() || (frame_->response.payment_id->size() != sizeof(crypto::hash8) && frame_->response.payment_id->size() != sizeof(crypto::hash)))) + return frame_->done({error::import_invalid}); + + #ifdef LWSF_MASTER_ENABLE + std::string payment_id; + if (frame_->response.payment_id) + payment_id = epee::to_hex::string(epee::to_span(*frame_->response.payment_id)); + + std::size_t i = 0; + for (; i < self.primary.addressbook.size(); ++i) + { + const bool existing = + self.primary.addressbook[i].address == *frame_->response.payment_address && + self.primary.addressbook[i].payment_id == payment_id; + if (existing) + break; + } - rpc::upsert_subaddrs_request request{ - rpc::login{primary.address, primary.view.sec}, {}, true /* get_all */ + std::string description = "Payment of " + cryptonote::print_money(*frame_->response.import_fee) + " XMR is needed to import/restore height"; + if (i == self.primary.addressbook.size()) + self.primary.addressbook.push_back(address_book_entry{std::move(*frame_->response.payment_address), std::move(payment_id), std::move(description)}); + else + self.primary.addressbook[i] = address_book_entry{std::move(*frame_->response.payment_address), std::move(payment_id), std::move(description)}; + #endif + frame_->done({error::import_pending}); + } + } }; - fill_upsert(*this, request.subaddrs_); - lock.unlock(); - const auto response = rpc::invoke(client, client_prefix, request); - lock.lock(); - if (!response) - return (lookahead_error = handle_lookahead_error(response.error())); - - update_lookaheads(*this, response->all_subaddrs); - return {}; + LWSF_VERIFY(self); + boost::asio::post(self->strand, handler{std::make_shared(self, height, std::move(f))}); } - expect wallet::restore_height(const std::uint64_t height) + void wallet::get_decoys(std::shared_ptr self, rpc::get_random_outs_request&& req, std::function f) { - const auto update_import = [this](expect value) + struct frame { - if (value) - this->import_error = {}; - else - this->import_error = value.error(); - return value; + const std::shared_ptr self; + const std::function f; + rpc::get_random_outs_request request; + rpc::get_random_outs_response response; + + explicit frame(std::shared_ptr&& self, rpc::get_random_outs_request&& req, std::function&& f) + : self(std::move(self)), f(std::move(f)), request(std::move(req)), response{} + {} }; - boost::unique_lock lock{sync}; - - if (primary.restore_height <= height) - return update_import(rpc::import_response{}); - if (import_error && import_error == error::import_pending) - return import_error; - - if (!passed_login) + struct handler : boost::asio::coroutine { - lock.unlock(); - const std::error_code error = login(); - if (error) - return error; - lock.lock(); - } + std::shared_ptr frame_; - // current api does not allow height selection, defaults to 0 - rpc::import_request login{{primary.address, primary.view.sec}, height}; + explicit handler(std::shared_ptr in) + : boost::asio::coroutine(), frame_(std::move(in)) + {} + + void operator()(const std::error_code error = {}) + { + LWSF_VERIFY(frame_ && frame_->self); - lock.unlock(); - auto import = rpc::invoke(client, client_prefix, login); - lock.lock(); + wallet& self = *frame_->self; + BOOST_ASIO_CORO_REENTER(*this) + { + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, frame_->request, std::addressof(frame_->response), std::move(*this) + ); - if (!import) - return update_import(import.error()); + if (error) + frame_->f(error); + else + frame_->f(std::move(frame_->response.amount_outs)); + }; + } + }; - if (import->request_fulfilled) - return update_import(std::move(import)); + handler{std::make_shared(std::move(self), std::move(req), std::move(f))}(); + } - const unsigned total = - unsigned(bool(import->import_fee)) + bool(import->payment_address); - switch (total) + void wallet::send_tx(std::shared_ptr self, epee::byte_slice tx_bytes, std::function f) + { + struct frame { - default: - case 0: - return update_import({error::import_pending}); - case 1: - if (import->import_fee.value_or(rpc::uint64_string(0)) == rpc::uint64_string(0)) - return update_import({error::import_pending}); - return update_import({error::import_invalid}); - case 2: - break; - } + const std::shared_ptr self; + const std::function f; + epee::byte_slice tx_bytes; + rpc::submit_raw_tx_response response; + + explicit frame(std::shared_ptr&& self, epee::byte_slice&& tx_bytes, std::function&& f) + : self(std::move(self)), f(std::move(f)), tx_bytes(std::move(tx_bytes)), response{} + {} + }; - cryptonote::address_parse_info info{}; - if (!cryptonote::get_account_address_from_str(info, convert_net_type(primary.type), *import->payment_address)) - return update_import({error::import_invalid}); - if (info.has_payment_id && import->payment_id) - return update_import({error::import_invalid}); - if (import->payment_id && (import->payment_id->empty() || (import->payment_id->size() != sizeof(crypto::hash8) && import->payment_id->size() != sizeof(crypto::hash)))) - return update_import({error::import_invalid}); - -#ifdef LWSF_MASTER_ENABLE - std::string payment_id; - if (import->payment_id) - payment_id = epee::to_hex::string(epee::to_span(*import->payment_id)); - - std::size_t i = 0; - for (; i < primary.addressbook.size(); ++i) + struct handler : boost::asio::coroutine { - const bool existing = - primary.addressbook[i].address == *import->payment_address && - primary.addressbook[i].payment_id == payment_id; - if (existing) - break; - } + std::shared_ptr frame_; - std::string description = "Payment of " + cryptonote::print_money(*import->import_fee) + " XMR is needed to import/restore height"; - if (i == primary.addressbook.size()) - primary.addressbook.push_back(address_book_entry{std::move(*import->payment_address), std::move(payment_id), std::move(description)}); - else - primary.addressbook[i] = address_book_entry{std::move(*import->payment_address), std::move(payment_id), std::move(description)}; -#endif - return update_import(std::move(import)); - } + explicit handler(std::shared_ptr&& in) + : frame_(std::move(in)) + {} - expect> wallet::get_decoys(const rpc::get_random_outs_request& req) - { - auto resp = rpc::invoke(client, client_prefix, req); - if (!resp) - return resp.error(); - return {std::move(resp->amount_outs)}; - } + void operator()(const std::error_code error = {}) + { + LWSF_VERIFY(frame_ && frame_->self); + wallet& self = *frame_->self; + BOOST_ASIO_CORO_REENTER(*this) + { + BOOST_ASIO_CORO_YIELD rpc::invoke_async( + self.client, + rpc::submit_raw_tx_request{std::move(frame_->tx_bytes)}, + std::addressof(frame_->response), + std::move(*this) + ); + frame_->f(error); + } + } + }; - std::error_code wallet::send_tx(epee::byte_slice tx_bytes) - { - const rpc::submit_raw_tx_request request{std::move(tx_bytes)}; - return rpc::invoke(client, client_prefix, request).error(); + handler{std::make_shared(std::move(self), std::move(tx_bytes), std::move(f))}(); } }}} // lwsf // internal // backend diff --git a/src/backend.h b/src/backend.h index 8d94c3c..fc24368 100644 --- a/src/backend.h +++ b/src/backend.h @@ -29,6 +29,8 @@ #pragma once #include +#include +#include #include #include #include @@ -36,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -98,7 +101,6 @@ namespace lwsf { namespace internal { namespace backend { boost::container::flat_map detail; //!< Minor address info std::uint32_t last; //!< Last minor index in use (inclusive) - std::uint32_t server_lookahead; //!< Status of server (minor) lookahead. Inclusive //! Creates 1 subaddress entry (key == `0`) with default label sub_account(); @@ -204,6 +206,9 @@ namespace lwsf { namespace internal { namespace backend { account() = delete; + //! \return # subaddresses needed for `lookahead, `txes`, and `subaddrs`. + std::uint64_t needed_subaddresses(boost::container::flat_map subaddrs) const; + struct polyseed { epee::byte_slice seed; @@ -233,23 +238,26 @@ namespace lwsf { namespace internal { namespace backend struct wallet { Monero::WalletListener* listener; - rpc::http_client client; + boost::asio::io_context::strand strand; // per_byte_fee; //!< by priority level std::error_code refresh_error; //!< Cached because `refresh(...)` is rate limited + std::error_code subaddress_error; //!< Errors with subaddresses (not via lookahead) std::error_code lookahead_error; //!< warnings/errors of `server_lookahead` value std::error_code import_error; //!< Error from `import_wallet_request` std::chrono::steady_clock::time_point last_sync; std::uint64_t blockchain_height; std::uint64_t fee_mask; - std::uint32_t server_lookahead; //!< Status of major lookahead server-side. Inclusive + config::lookahead server_lookahead; //!< Status of lookahead server-side mutable boost::mutex sync; boost::mutex sync_listener; boost::mutex sync_refresh; bool passed_login; + bool probed_lookahead; //!< True iff queries were done to determine lookahead re-sync - wallet(); + wallet(boost::asio::io_context& io); // `sync` mutex is NOT acquired for this group @@ -258,6 +266,7 @@ namespace lwsf { namespace internal { namespace backend cryptonote::account_keys get_primary_keys() const; cryptonote::account_public_address get_spend_account(const rpc::address_meta& index) const; std::string get_spend_address(const rpc::address_meta& index) const; + bool lookahead_good() const noexcept; // End GROUP @@ -269,32 +278,35 @@ namespace lwsf { namespace internal { namespace backend //! De-serialize `this` from msgpack. Locks+replaces contents. std::error_code from_bytes(epee::byte_slice source); - /*! Attempt login and sync subaddresses. The result of subaddress syncing, - including errors, is stored in `server_lookahead`. - \return No errors if login succeeded, and true if new account */ - expect login_is_new(); - - /*! Attempt login and sync subaddresses. The result of subaddress syncing, - including errors, is stored in `server_lookahead`. - \return No errors if login succeeded. */ - std::error_code login() { return login_is_new().error(); } + /*! Attempt login and check lookahead status. Updates `lookahead_error`. + \return No errors if login succeeded, and true if new account */ + static void login_is_new(std::shared_ptr self, std::function)>); + /*! Attempt login and check lookahead status. Updates `lookahead_error`. + \return No errors if login succeeded. */ + template + static void login(std::shared_ptr self, F f) + { + login_is_new(std::move(self), [f = std::move(f)] (expect r) mutable { f(r.error()); }); + } //! Refreshes txes information. Strong exception guarantee. - std::error_code refresh(bool mandatory = false); + static void refresh(std::shared_ptr self, bool mandatory, std::function); //! Notify server that new major accounts need to be watched. - std::error_code register_subaccount(std::uint32_t maj_i); + static void register_subaccount(std::shared_ptr self, std::uint32_t maj_i, std::function f); + + //! Notify server that new minor accounts need to be watched. Do not hold `self->sync`. + static void register_subaddress(std::shared_ptr self, std::uint32_t maj_i, std::uint32_t min_i, std::function); - //! Notify server that new minor accounts need to be watched. - std::error_code register_subaddress(std::uint32_t maj_i, std::uint32_t min_i); + //! Modify local and possibly server lookahead. Do not hold `self->sync`. + static void set_lookahead(std::shared_ptr self, std::uint32_t major, std::uint32_t minor, std::function); - //! Modify local and possibly server lookahead - std::error_code set_lookahead(std::uint32_t major, std::uint32_t minor); + static void restore_height(std::shared_ptr self, const std::uint64_t height, std::function); - expect restore_height(const std::uint64_t height); + using decoys_callable = void(expect>); + static void get_decoys(std::shared_ptr self, rpc::get_random_outs_request&& req, std::function f); - expect> get_decoys(const rpc::get_random_outs_request& req); - std::error_code send_tx(epee::byte_slice tx_bytes); + static void send_tx(std::shared_ptr slef, epee::byte_slice tx_bytes, std::function f); }; }}} // lwsf // internal // backend diff --git a/src/error.cpp b/src/error.cpp index 97d9cee..d642955 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -26,6 +26,7 @@ // THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include "error.h" +#include "common/error.h" // monero/src namespace lwsf { @@ -61,6 +62,8 @@ namespace lwsf return "Unexpected user+pass field in URL"; case error::unexpected_nullptr: return "Unexpected nullptr"; + case error::unknown_exception: + return "Unknown exception"; default: break; @@ -85,4 +88,14 @@ namespace lwsf static const category instance{}; return instance; } + + void throw_invalid_argument(const int line, const char* file) + { + if (!file) + file = ""; + char const* const end = std::strrchr(file, '/'); + if (end) + file = end + 1; + throw std::invalid_argument{"Failed pre-condition at " + std::string{file} + ":" + std::to_string(line)}; + } } // lwsf diff --git a/src/error.h b/src/error.h index 7e513d4..6e15579 100644 --- a/src/error.h +++ b/src/error.h @@ -28,6 +28,14 @@ #pragma once #include +#include + +#define LWSF_VERIFY(x) \ + do \ + { \ + if (!(x)) \ + ::lwsf::throw_invalid_argument(__LINE__, __FILE__); \ + } while (0) namespace lwsf { @@ -48,6 +56,7 @@ namespace lwsf subaddr_local, //!< Local limits on subaddresses too small unexpected_userinfo,//!< Unexpected user+pass provided unexpected_nullptr, //!< Expected non-nullptr + unknown_exception //!< Unknown exception occured }; //! \return Error message string. @@ -62,6 +71,7 @@ namespace lwsf return std::error_code{int(value), error_category()}; } + [[noreturn]] void throw_invalid_argument(int line, const char* file); } // lwsf namespace std diff --git a/src/lwsf_config.h b/src/lwsf_config.h index 7687eb3..0d5d02d 100644 --- a/src/lwsf_config.h +++ b/src/lwsf_config.h @@ -47,6 +47,9 @@ namespace lwsf { namespace config constexpr const lookahead default_lookahead{50, 200}; constexpr const lookahead default_minimal_lookahead{5, 15}; constexpr const std::string_view default_primary_name{"Primary account"}; + constexpr const std::size_t http_body_limit = 25 * 1024 * 1024; // 25 MiB + constexpr const std::size_t http_parser_buffer_size = 16 * 1024; // 16 KiB + constexpr const unsigned http_version = 11; constexpr const std::size_t initial_buffer_size = 1024 * 64; // 64 KiB constexpr const std::size_t max_file_read_size = 50 * 1024 * 1024; // 50 MiB constexpr const std::size_t max_inputs_in_rpc = 512; diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt new file mode 100644 index 0000000..ad1daec --- /dev/null +++ b/src/net/CMakeLists.txt @@ -0,0 +1,34 @@ +# Copyright (c) 2025, The Monero Project +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, are +# permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of +# conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, this list +# of conditions and the following disclaimer in the documentation and/or other +# materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors may be +# used to endorse or promote products derived from this software without specific +# prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +# THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +# THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +set(lwsf-net_sources context.cpp http.cpp) +set(lwsf-net_headers context.h http.h) + +add_library(lwsf-net ${lwsf-net_sources} ${lwsf-net_headers}) +target_link_libraries(lwsf-net PRIVATE monero::libraries) + diff --git a/src/net/context.cpp b/src/net/context.cpp new file mode 100644 index 0000000..1fb8c66 --- /dev/null +++ b/src/net/context.cpp @@ -0,0 +1,55 @@ +// Copyright (c) 2025, The Monero Project +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "context.h" +#include +#include + +namespace lwsf { namespace internal { namespace net +{ + context::context() + : restart_(), io_(), sync_() + {} + + std::shared_ptr context::restart_asio() + { + std::shared_ptr out; + const boost::lock_guard lock{sync_}; + out = restart_.lock(); + if (!out || io_.stopped()) + { + out.reset(); // need to restart // + while (restart_.lock()) + boost::this_thread::sleep_for(boost::chrono::milliseconds{50}); + io_.restart(); + out = std::make_shared(); + restart_ = out; + } + return out; + } +}}} // lwsf // internal // net diff --git a/src/net/context.h b/src/net/context.h new file mode 100644 index 0000000..eb3f9c1 --- /dev/null +++ b/src/net/context.h @@ -0,0 +1,101 @@ +// Copyright (c) 2025, The Monero Project +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace lwsf { namespace internal { namespace net +{ + template + struct to_future + { + struct inner + { + std::promise promise; + inner() + : promise() + {} + }; + + std::shared_ptr inner_; + + to_future() + : inner_(std::make_shared()) + {} + + void operator()(T value) + { + LWSF_VERIFY(inner_); + inner_->promise.set_value(std::move(value)); + } + }; + + struct context + { + std::weak_ptr restart_; + boost::asio::io_context io_; + boost::mutex sync_; + + explicit context(); + + //! Restart `io_` if needed, and get an object that prevents future restarts. + std::shared_ptr restart_asio(); + + template + T wait_for(F f) + { + to_future callable{}; + auto value = callable.inner_->promise.get_future(); + f(std::move(callable)); + for (;;) + { + switch (value.wait_for(std::chrono::seconds{0})) + { + case std::future_status::deferred: + case std::future_status::ready: + return value.get(); + + case std::future_status::timeout: + { + const auto restart_lock = restart_asio(); + io_.run_one_for(std::chrono::milliseconds{100}); + } + break; + }; + } + throw std::logic_error{"unreachable"}; + } + }; + +}}} // lwsf // internal // net diff --git a/src/net/http.cpp b/src/net/http.cpp new file mode 100644 index 0000000..751ec5f --- /dev/null +++ b/src/net/http.cpp @@ -0,0 +1,686 @@ +// Copyright (c) 2024, The Monero Project +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "http.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "byte_stream.h" // monero/controib/epee/include +#include "common/error.h" // monero/src +#include "error.h" +#include "lwsf_config.h" +#include "misc_log_ex.h" // monero/contrib/epee/include +#include "net/net_utils_base.h" +#include "net/socks.h" // monero/src +#include "string_tools.h" // monero/contrib/epee/include + +namespace lwsf { namespace internal { namespace http +{ + namespace + { + const char* get_string(error value) noexcept + { + switch (value) + { + default: + break; + + case error::none: + return "No rpc errors"; + case error::timeout: + return "No response from HTTP server"; + case error::invalid_code: + return "Invalid status code from HTTP server"; + } + return nullptr; + } + + struct message + { + message(epee::byte_slice body, std::string target, std::function&& notifier, boost::beast::http::verb verb) + : body(std::move(body)), + notifier(std::move(notifier)), + target(std::move(target)), + verb(verb) + {} + + message(message&&) = default; + message(const message& rhs) + : body(rhs.body.clone()), + notifier(rhs.notifier), + target(rhs.target), + verb(rhs.verb) + {} + + bool is_get() const noexcept { return verb == boost::beast::http::verb::get; } + + epee::byte_slice body; + std::function notifier; + std::string target; + boost::beast::http::verb verb; + }; + + std::ostream& operator<<(std::ostream& out, const message& src) + { + out << src.target; + return out; + } + + struct stream_body + { + using value_type = epee::byte_stream; + + struct reader + { + epee::byte_stream& body_; + + template + reader(boost::beast::http::header&, value_type& body) + : body_(body) + {} + + void init(boost::optional const& content_length, boost::system::error_code& ec) + { + static_assert( + std::numeric_limits::max() <= std::numeric_limits::max() + ); + if (content_length) + body_.reserve(*content_length); + ec = {}; + } + + template + std::size_t put(ConstBufferSequence const& buffers, boost::system::error_code& ec) + { + const std::size_t size = boost::asio::buffer_size(buffers); + body_.write({reinterpret_cast(buffers.data()), size}); + ec = {}; + return size; + } + + void finish(boost::system::error_code& ec) + { + ec = {}; + } + }; + }; + + struct slice_body + { + using value_type = epee::byte_slice; + + static std::size_t size(const value_type& source) noexcept + { + return source.size(); + }; + + struct writer + { + epee::byte_slice body_; + + using const_buffers_type = boost::asio::const_buffer; + + template + explicit writer(boost::beast::http::header const&, value_type const& body) + : body_(body.clone()) + {} + + void init(boost::beast::error_code& ec) + { + ec = {}; + } + + boost::optional> get(boost::beast::error_code& ec) + { + ec = {}; + return {{const_buffers_type{body_.data(), body_.size()}, false}}; + } + }; + }; + } // anonymous + + //! \return Category for `error`. + const std::error_category& error_category() noexcept + { + struct category final : std::error_category + { + virtual const char* name() const noexcept override final + { + return "lwsf::internal::rpc::error_category()"; + } + + virtual std::string message(int value) const override final + { + char const * const msg = get_string(error(value)); + if (msg) + return msg; + return "HTTP error code " + std::to_string(value); + } + }; + static const category instance{}; + return instance; + } + + struct client_state + { + using connect_func = + void(std::shared_ptr, std::function); + + boost::beast::flat_static_buffer buffer; + std::function connect; + std::deque outgoing; + const std::string host; + const std::string prefix; + boost::asio::steady_timer timer; + boost::asio::io_context::strand strand; + boost::asio::ip::tcp::endpoint endpoint; + boost::beast::http::request request; + const epee::net_utils::ssl_options_t ssl; + boost::asio::ssl::context ssl_context; + boost::asio::ssl::stream sock; + boost::optional> parser; + std::size_t iteration; + const std::uint16_t port; + std::atomic is_connected; + epee::net_utils::ssl_support_t ssl_status; + + client_state(boost::asio::io_context& io, std::string host, std::string prefix, const std::uint16_t port, epee::net_utils::ssl_options_t in, std::function connect) + : buffer{}, + connect(std::move(connect)), + outgoing(), + host(std::move(host)), + prefix(std::move(prefix)), + timer(io), + strand(io), + endpoint(), + request{}, + ssl(std::move(in)), + ssl_context(ssl.create_context()), + sock(io, ssl_context), + parser(), + iteration(0), + port(port), + is_connected(false), + ssl_status(ssl.support) + {} + + template + void async_write(F&& callback) + { + assert(!outgoing.empty()); + const bool no_body = outgoing.front().body.empty(); + if (!prefix.empty()) + outgoing.front().target.insert(0, prefix.data(), prefix.size()); + + request = { + outgoing.front().verb, + outgoing.front().target, + config::http_version, + std::move(outgoing.front().body) + }; + request.set(boost::beast::http::field::user_agent, BOOST_BEAST_VERSION_STRING); + if (!no_body) + request.set(boost::beast::http::field::content_type, "application/json"); + + // Setting Host is tricky. Check for v6 and non-standard ports + boost::system::error_code error{}; + boost::asio::ip::make_address_v6(host, error); + const bool https = ssl_status == epee::net_utils::ssl_support_t::e_ssl_support_enabled; + if (!error) + request.set(boost::beast::http::field::host, "[" + host + "]:" + std::to_string(port)); + else if ((https && port == 443) || (!https && port == 80)) + request.set(boost::beast::http::field::host, host); + else + request.set(boost::beast::http::field::host, host + ":" + std::to_string(port)); + + request.prepare_payload(); + if (https) + boost::beast::http::async_write(sock, request, boost::asio::bind_executor(strand, std::forward(callback))); + else + boost::beast::http::async_write(sock.next_layer(), request, boost::asio::bind_executor(strand, std::forward(callback))); + } + + template + void async_read(F&& callback) + { + assert(sock); + assert(!outgoing.empty()); + parser.emplace(); + parser->body_limit(config::http_body_limit); + if (ssl_status == epee::net_utils::ssl_support_t::e_ssl_support_enabled) + boost::beast::http::async_read(sock, buffer, *parser, boost::asio::bind_executor(strand, std::forward(callback))); + else + boost::beast::http::async_read(sock.next_layer(), buffer, *parser, boost::asio::bind_executor(strand, std::forward(callback))); + } + + void close() + { + MWARNING("Closing socket to " << host); + + ++iteration; + outgoing.clear(); + is_connected = false; + + boost::system::error_code ignore{}; + timer.cancel(); + sock.next_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ignore); + sock.next_layer().close(ignore); + } + + void notify_error(const std::error_code& error) + { + assert(!outgoing.empty()); + auto& f = outgoing.front().notifier; + if (f) + f(error, {}); + } + + void notify_connection_error() + { + for (auto& elem : outgoing) + { + if (elem.notifier) + elem.notifier(error::timeout, {}); + } + close(); + } + }; + + namespace + { + template + void set_timeout(std::shared_ptr self, const std::chrono::steady_clock::duration timeout, F f) + { + LWSF_VERIFY(self); + + struct on_timeout + { + on_timeout() = delete; + std::shared_ptr self_; + F f_; + const std::size_t iteration; + + void operator()(const boost::system::error_code error) + { + LWSF_VERIFY(self_); + if (!self_ || error == boost::asio::error::operation_aborted) + return; + if (iteration < self_->iteration) + return; + + MWARNING("Timeout in HTTP attempt to " << self_->host); + f_(error); + } + }; + + client_state& me = *self; + me.timer.expires_after(timeout); + me.timer.async_wait(boost::asio::bind_executor(me.strand, on_timeout{std::move(self), std::move(f), me.iteration})); + } + + void set_timeout(std::shared_ptr self, const std::chrono::steady_clock::duration timeout) + { + LWSF_VERIFY(self); + + struct on_timeout + { + on_timeout() = delete; + std::shared_ptr self_; + + void operator()(boost::system::error_code error = {}) const + { + LWSF_VERIFY(self_); + assert(self_->strand.running_in_this_thread()); + self_->close(); + } + }; + + set_timeout(self, timeout, on_timeout{self}); + } + + class client_loop : public boost::asio::coroutine + { + std::shared_ptr self_; + + public: + explicit client_loop(std::shared_ptr self) noexcept + : boost::asio::coroutine(), self_(std::move(self)) + {} + + void operator()(boost::system::error_code error = {}, std::size_t = 0) + { + LWSF_VERIFY(self_); + + client_state& self = *self_; + assert(self.strand.running_in_this_thread()); + BOOST_ASIO_CORO_REENTER(*this) + { + if (!self.is_connected) + { + do_connect: + BOOST_ASIO_CORO_YIELD self.connect(self_, std::move(*this)); + + if (!error && self.ssl_status != epee::net_utils::ssl_support_t::e_ssl_support_disabled) + { + ++self.iteration; + set_timeout(self_, config::connect_timeout); // socks needs a reset + self.ssl.configure(self.sock, boost::asio::ssl::stream_base::client, self.host); + + MDEBUG("Starting SSL handshake to " << self.host << " for HTTP"); + BOOST_ASIO_CORO_YIELD self.sock.async_handshake( + boost::asio::ssl::stream::client, + boost::asio::bind_executor(self.strand, std::move(*this)) + ); + + if (error) + { + MERROR("SSL handshake to " << self.host << " failed: " << error.message()); + if (self.ssl_status == epee::net_utils::ssl_support_t::e_ssl_support_autodetect) + { + MINFO("Retrying to << " << self.host << " without ssl (autodetect mode)"); + self.ssl_status = epee::net_utils::ssl_support_t::e_ssl_support_disabled; + error = {}; + ++self.iteration; + goto do_connect; + } + } + else + self.ssl_status = epee::net_utils::ssl_support_t::e_ssl_support_enabled; + } + } + + if (error) + return self.notify_connection_error(); + + ++self.iteration; + self.is_connected = true; + while (!self.outgoing.empty()) + { + set_timeout(self_, config::rpc_timeout); + + MDEBUG("Sending " << self.outgoing.front().body.size() << " bytes in HTTP " << (self.outgoing.front().is_get() ? "GET" : "POST") << " to " << self.outgoing.front()); + BOOST_ASIO_CORO_YIELD self.async_write(std::move(*this)); + + if (!error) + { + MDEBUG("Starting read from " << self.outgoing.front() << " to previous HTTP message"); + BOOST_ASIO_CORO_YIELD self.async_read(std::move(*this)); + + if (error) + MERROR("Failed to parse HTTP response from " << self.outgoing.front() << ": " << error.message()); + else if (self.parser->get().result_int() != 200 && self.parser->get().result_int() != 201) + { + const auto result = self.parser->get().result_int(); + MERROR(self.outgoing.front() << " returned " << result << " status code"); + if (0 < result && result <= std::numeric_limits::max()) + self.notify_error(http::error(result)); + else + self.notify_error(http::error::invalid_code); + } + else + { + MDEBUG(self.outgoing.front() << " successful"); + if (self.outgoing.front().notifier) + self.outgoing.front().notifier({}, epee::byte_slice{std::move(self.parser->get()).body()}); + } + } + else + MERROR("Failed HTTP " << (self.outgoing.front().is_get() ? "GET" : "POST") << " to " << self.outgoing.front() << ": " << error.message()); + + if (error) + return self.notify_connection_error(); + self.outgoing.pop_front(); + ++self.iteration; + } + } + } + }; + } // anonymous + + void client::queue_async(std::string&& target, epee::byte_slice&& body, std::function&& notifier, boost::beast::http::verb verb) const + { + message msg{std::move(body), std::move(target), std::move(notifier), verb}; + std::shared_ptr state; + { + const boost::lock_guard lock{sync_}; + state = state_; + } + + if (!state) + return notifier(common_error::kInvalidArgument, epee::byte_slice{}); + + MDEBUG("Queueing HTTP " << (msg.is_get() ? "GET" : "POST") << " to " << msg << " using " << this); + boost::asio::dispatch( + state->strand, + [state, msg = std::move(msg)] () mutable + { + const bool empty = state->outgoing.empty(); + state->outgoing.push_back(std::move(msg)); + if (empty) + boost::asio::post(state->strand, client_loop{state}); + } + ); + } + + void client::direct::operator()(std::shared_ptr self, std::function f) const + { + struct frame + { + std::function f; + boost::asio::ip::tcp::resolver resolver; + const std::size_t iteration; + + frame(client_state& self, std::function&& f) + : f(std::move(f)), resolver(self.strand.context()), iteration(self.iteration) + {} + }; + + struct handler : boost::asio::coroutine + { + std::shared_ptr self_; + std::shared_ptr frame_; + + explicit handler(std::shared_ptr self, std::shared_ptr&& in) + : boost::asio::coroutine(), self_(std::move(self)), frame_(std::move(in)) + {} + + void operator()(boost::system::error_code error = {}) + { + LWSF_VERIFY(self_ && frame_); + + client_state& self = *self_; + assert(self.strand.running_in_this_thread()); + BOOST_ASIO_CORO_REENTER(*this) + { + struct resolve + { + handler continue_; + + void operator()(const boost::system::error_code error, const boost::asio::ip::tcp::resolver::results_type& ips) + { + if (error) + std::move(continue_)(error); + else if (ips.empty()) + std::move(continue_)(boost::asio::error::host_not_found); + else if (continue_.self_) + { + continue_.self_->endpoint = *ips.begin(); + std::move(continue_)(error); + } + } + }; + + struct on_timeout + { + std::shared_ptr self_; + std::shared_ptr frame_; + void operator()(boost::system::error_code error) const + { + LWSF_VERIFY(frame_ && self_); + assert(self_->strand.running_in_this_thread()); + + frame_->resolver.cancel(); + self_->close(); + frame_->f(boost::asio::error::operation_aborted); + } + }; + + set_timeout(self_, config::connect_timeout, on_timeout{self_, frame_}); + + MDEBUG("Resolving " << self.host << " for HTTP"); + BOOST_ASIO_CORO_YIELD frame_->resolver.async_resolve( + self.host, std::to_string(self.port), boost::asio::bind_executor(self.strand, resolve{*this}) + ); + + if (!error) + { + MDEBUG("Connecting to " << self.endpoint << " / " << self.host << " for HTTP"); + BOOST_ASIO_CORO_YIELD self.sock.next_layer().async_connect( + self.endpoint, boost::asio::bind_executor(self.strand, std::move(*this)) + ); + + if (error) + MERROR("Failed to connect to " << self.host << ": " << error.message()); + } + else + MERROR("Failed to resolve TCP/IP address for " << self.host << ": " << error.message()); + + if (self_->iteration <= frame_->iteration) + frame_->f(error); + } + } + }; + + LWSF_VERIFY(self); + handler{self, std::make_shared(*self, std::move(f))}(); + } + + void client::socks::operator()(std::shared_ptr self, std::function f) const + { + struct handler + { + std::shared_ptr self_; + std::function f_; + + void operator()(boost::system::error_code error, boost::asio::ip::tcp::socket&& sock) + { + LWSF_VERIFY(self_ && f_); + + if (error) + MERROR("Failed socks connection: " << error.message()); + self_->sock.next_layer() = std::move(sock); + + std::function f{std::move(f_)}; + boost::asio::dispatch(self_->strand, [f = std::move(f), error] { f(error); }); + } + }; + + LWSF_VERIFY(self && f); + + std::shared_ptr<::net::socks::client> proxy = ::net::socks::make_connect_client( + std::move(self->sock.next_layer()), ::net::socks::version::v4, handler{self, f} + ); + + bool is_set = false; + std::uint32_t ip_address = 0; + if (epee::string_tools::get_ip_int32_from_string(ip_address, self->host)) + is_set = proxy->set_connect_command(epee::net_utils::ipv4_network_address{ip_address, self->port}); + else + is_set = proxy->set_connect_command(self->host, self->port); + + if (is_set) + { + set_timeout(self, config::connect_timeout, ::net::socks::client::async_close{proxy}); + is_set = ::net::socks::client::connect_and_send(std::move(proxy), proxy_address); + } + + if (!is_set) + { + MERROR("Failed to initiate socks proxy"); + f(boost::asio::error::fault); + } + } + + void client::init(boost::asio::io_context& io, std::string host, std::string prefix, std::uint16_t port, epee::net_utils::ssl_options_t ssl) + { + const boost::lock_guard lock{sync_}; + state_ = std::make_shared(io, std::move(host), std::move(prefix), port, std::move(ssl), proxy_); + } + + client::~client() + {} + + bool client::is_connected() const noexcept + { + const boost::lock_guard lock{sync_}; + return state_ && state_->is_connected; + } + + void client::set_proxy(std::function connector) + { + LWSF_VERIFY(connector); + const boost::lock_guard lock{sync_}; + proxy_ = std::move(connector); + if (state_) + state_ = std::make_shared(state_->strand.context(), state_->host, state_->prefix, state_->port, state_->ssl, proxy_); + } + + void client::post_async(std::string url, epee::byte_slice json_body, std::function notifier) const + { + return queue_async(std::move(url), std::move(json_body), std::move(notifier), boost::beast::http::verb::post); + } + + void client::get_async(std::string url, std::function notifier) const + { + if (!notifier) + throw std::logic_error{"net::http::client::get_async requires callback"}; + return queue_async(std::move(url), epee::byte_slice{}, std::move(notifier), boost::beast::http::verb::get); + } +}}} // lwsf // internal // http + diff --git a/src/net/http.h b/src/net/http.h new file mode 100644 index 0000000..6e24797 --- /dev/null +++ b/src/net/http.h @@ -0,0 +1,117 @@ +// Copyright (c) 2024, The Monero Project +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "byte_slice.h" // monero/contrib/epee/include +#include "net/net_ssl.h" // monero/contrib/epee/include + +namespace lwsf { namespace internal { namespace http +{ + enum class error : int + { + none = 0, timeout = -1, invalid_code = -2 /* Otherwise HTTP error code */ + }; + + const std::error_category& error_category() noexcept; + inline std::error_code make_error_code(const error value) noexcept + { + return std::error_code{int(value), error_category()}; + } + + struct client_state; + using server_response_func = void(std::error_code, epee::byte_slice); + using callback_func = void(boost::system::error_code); + using connect_func = void(std::shared_ptr, std::function); + + //! Primarily for webhooks, where the response is (basically) ignored. + class client + { + + std::shared_ptr state_; + std::function proxy_; + mutable boost::mutex sync_; + + void queue_async(std::string&& target, epee::byte_slice&& body, std::function&& notifier, boost::beast::http::verb verb) const; + + public: + struct direct + { + void operator()(std::shared_ptr, std::function) const; + }; + + struct socks + { + boost::asio::ip::tcp::endpoint proxy_address; + void operator()(std::shared_ptr, std::function) const; + }; + + client() + : state_(nullptr), proxy_(direct{}), sync_() + {} + + void init(boost::asio::io_context& io, std::string host, std::string prefix, std::uint16_t port, epee::net_utils::ssl_options_t ssl); + + client(client&&) = delete; + client(const client&) = delete; + ~client(); + client& operator=(client&&) = delete; + client& operator=(const client&) = delete; + + //! thread-safe + bool is_connected() const noexcept; + + //! thread-safe + void set_proxy(std::function connector); + + //! Never blocks. Thread safe. \return `success()` if `url` is valid. + void post_async(std::string target, epee::byte_slice body, std::function notifier) const; + + /*! Never blocks. Thread safe. Calls `notifier` with server response iff + `success()` is returned. + \return `success()` if `url` is valid. */ + void get_async(std::string target, std::function notifier) const; + }; +}}} // lwsf // internal // http + +namespace std +{ + template<> + struct is_error_code_enum + : true_type + {}; +} diff --git a/src/pending_transaction.cpp b/src/pending_transaction.cpp index 43a5d62..7641813 100644 --- a/src/pending_transaction.cpp +++ b/src/pending_transaction.cpp @@ -35,6 +35,7 @@ #include "byte_slice.h" // monero/contrib/epee/include #include "cryptonote_basic/cryptonote_format_utils.h" // monero/src #include "error.h" +#include "net/context.h" #include "numeric.h" #include "string_tools.h" // monero/contrib/epee/include #include "utils/encrypted_file.h" @@ -99,27 +100,24 @@ namespace lwsf { namespace internal } - pending_transaction::pending_transaction(std::shared_ptr wallet, std::string error, std::vector> local) - : wallet_(std::move(wallet)), local_(std::move(local)), error_(std::move(error)) + pending_transaction::pending_transaction(std::shared_ptr wallet, std::shared_ptr context, std::string error, std::vector> local) + : context_(std::move(context)), wallet_(std::move(wallet)), local_(std::move(local)), error_(std::move(error)) { - if (!wallet_) - throw std::invalid_argument{"lwsf::internal::pending_transaction cannot be given nullptr backend"}; + LWSF_VERIFY(wallet_); for (const auto& e : local_) { - if (!e) - throw std::invalid_argument{"lwsf::internal::pending_transaction cannot be given nullptr backend::transaction"}; + LWSF_VERIFY(bool(e)); } } - std::unique_ptr pending_transaction::load_from_file(std::shared_ptr wallet, const std::string& filename) + std::unique_ptr pending_transaction::load_from_file(std::shared_ptr wallet, std::shared_ptr context, const std::string& filename) { - if (!wallet) - throw std::invalid_argument{"lwsf::internal::pending_transaction::load_from_file cannot be given nullptr backend"}; + LWSF_VERIFY(wallet && context); epee::byte_slice file = try_load(filename, tx_file_magic); if (file.empty()) - return std::make_unique(wallet, "Invalid path/file for tx"); + return std::make_unique(wallet, context, "Invalid path/file for tx"); expect payload = epee::byte_slice{}; { @@ -127,12 +125,12 @@ namespace lwsf { namespace internal payload = decrypt(std::move(file), epee::as_byte_span(unwrap(unwrap(wallet->primary.view.sec)))); } if (!payload) - return std::make_unique(wallet, payload.error().message()); + return std::make_unique(wallet, context, payload.error().message()); txes_file dest{}; if (std::error_code error = wire::msgpack::from_bytes(std::move(*payload), dest)) - return std::make_unique(wallet, error.message()); - return std::make_unique(wallet, "", std::move(dest.txes)); + return std::make_unique(wallet, context, error.message()); + return std::make_unique(wallet, context, "", std::move(dest.txes)); } pending_transaction::~pending_transaction() @@ -143,18 +141,29 @@ namespace lwsf { namespace internal if (!error_.empty()) return false; - for (auto e : local_) + try { - const std::error_code error = wallet_->send_tx(e->raw_bytes.clone()); - if (error) + for (auto e : local_) { - error_ = error.message(); - return false; + const auto error = context_->wait_for( + [this, &e] (auto&& f) + { backend::wallet::send_tx(this->wallet_, e->raw_bytes.clone(), std::move(f)); } + ); + if (error) + { + error_ = error.message(); + return false; + } + const boost::lock_guard lock{wallet_->sync}; + auto entry = wallet_->primary.txes.try_emplace(e->id).first; + if (!entry->second) + entry->second = std::move(e); } - const boost::lock_guard lock{wallet_->sync}; - auto entry = wallet_->primary.txes.try_emplace(e->id).first; - if (!entry->second) - entry->second = std::move(e); + } + catch(const std::exception& e) + { + error_ = e.what(); + return false; } return true; } diff --git a/src/pending_transaction.h b/src/pending_transaction.h index 0e0499c..1e9a686 100644 --- a/src/pending_transaction.h +++ b/src/pending_transaction.h @@ -47,15 +47,18 @@ namespace lwsf { namespace internal struct wallet; } + namespace net { struct context; } + class pending_transaction final : public Monero::PendingTransaction { + const std::shared_ptr context_; // `wallet_` holds `io_context` references const std::shared_ptr wallet_; const std::vector> local_; std::string error_; public: - pending_transaction(std::shared_ptr wallet, std::string error, std::vector> local = {}); - static std::unique_ptr load_from_file(std::shared_ptr wallet, const std::string& filename); + pending_transaction(std::shared_ptr wallet, std::shared_ptr context, std::string error, std::vector> local = {}); + static std::unique_ptr load_from_file(std::shared_ptr wallet, std::shared_ptr context, const std::string& filename); pending_transaction(pending_transaction&&) = delete; pending_transaction(const pending_transaction&) = delete; diff --git a/src/rpc.cpp b/src/rpc.cpp index efd7b83..28b279f 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -44,86 +44,12 @@ #include "wire/json.h" #include "wire/traits.h" #include "wire/wrapper/array.h" +#include "wire/wrapper/defaulted.h" #include "wire/wrapper/trusted_array.h" #include "wire/wrappers_impl.h" namespace lwsf { namespace internal { namespace rpc { - namespace - { - //! \return Error message string. - const char* get_string(error value) noexcept - { - switch (value) - { - default: - break; - - case error::none: - return "No rpc errors"; - case error::no_response: - return "No response from HTTP server"; - case error::invalid_code: - return "Invalid status code from HTTP server"; - } - return nullptr; - } - } - - //! \return Category for `error`. - const std::error_category& error_category() noexcept - { - struct category final : std::error_category - { - virtual const char* name() const noexcept override final - { - return "lwsf::internal::rpc::error_category()"; - } - - virtual std::string message(int value) const override final - { - char const * const msg = get_string(error(value)); - if (msg) - return msg; - return "HTTP error code " + std::to_string(value); - } - }; - static const category instance{}; - return instance; - } - - expect invoke_payload(http_client& client, const boost::string_ref prefix, boost::string_ref endpoint, const epee::byte_slice payload) - { - static const epee::net_utils::http::fields_list headers{ - {"Content-Type", "application/json; charset=utf-8"} - }; - - if (!client.is_connected()) - { - if (!client.connect(config::connect_timeout)) - return {error::no_response}; - } - - std::string real; - if (!prefix.empty()) - { - real = std::string{prefix} + std::string{endpoint}; - endpoint = real; - } - - const epee::net_utils::http::http_response_info* response = nullptr; - if (!client.invoke(endpoint, "POST", {reinterpret_cast(payload.data()), payload.size()}, config::rpc_timeout, std::addressof(response), headers)) - return {error::no_response}; - if (!response) - return {error::no_response}; - if(response->m_response_code == 200 || response->m_response_code == 201) - return response->m_body; - - if (response->m_response_code <= 0 || std::numeric_limits::max() < response->m_response_code) - return {error::invalid_code}; - return {error(int(response->m_response_code))}; - } - void write_bytes(wire::writer& dest, const empty&) { wire::object(dest); } void read_bytes(wire::reader& source, empty&) @@ -139,6 +65,7 @@ namespace lwsf { namespace internal { namespace rpc wire::object(dest, WIRE_FIELD(address), WIRE_FIELD(view_key), + WIRE_FIELD_DEFAULTED(lookahead, address_meta{}), WIRE_FIELD(create_account), WIRE_FIELD(generated_locally) ); @@ -146,7 +73,11 @@ namespace lwsf { namespace internal { namespace rpc void read_bytes(wire::json_reader& source, login_response& self) { - wire::object(source, WIRE_OPTIONAL_FIELD(start_height), WIRE_FIELD(new_address)); + wire::object(source, + WIRE_OPTIONAL_FIELD(start_height), + WIRE_OPTIONAL_FIELD(lookahead), + WIRE_FIELD(new_address) + ); } void read_bytes(wire::json_reader& source, daemon_status& self) @@ -247,14 +178,21 @@ namespace lwsf { namespace internal { namespace rpc } } + void read_bytes(wire::json_reader& source, get_version& self) + { + wire::object(source, WIRE_FIELD_DEFAULTED(max_subaddresses, unsigned(0))); + } + void read_bytes(wire::json_reader& source, get_address_txs& self) { using min_tx_size = wire::min_element_size; wire::object(source, WIRE_FIELD_ARRAY(transactions, min_tx_size), + WIRE_OPTIONAL_FIELD(lookahead_fail), WIRE_FIELD(scanned_block_height), WIRE_FIELD(start_height), - WIRE_FIELD(blockchain_height) + WIRE_FIELD(blockchain_height), + WIRE_FIELD_DEFAULTED(lookahead, address_meta{}) ); } @@ -336,19 +274,72 @@ namespace lwsf { namespace internal { namespace rpc { return {std::move(val)}; } template - void map_subaddrs(F& format, T& self) + void map_subaddr(F& format, T& self) { - auto head = array_head(std::ref(self.head)); wire::object(format, - WIRE_FIELD(key), - wire::field("value", wire::trusted_array(std::ref(head))) + wire::field("key", std::ref(self.first)), + wire::field("value", std::ref(self.second)) ); - if (head.empty()) - WIRE_DLOG_THROW(wire::error::schema::array, "unexpected empty array"); } } - - WIRE_JSON_DEFINE_OBJECT(subaddrs, map_subaddrs); + + bool subaddrs::is_valid() const noexcept + { + std::int64_t last = -1; + for (const auto& elem : value) + { + if (std::get<1>(elem) < std::get<0>(elem)) + return false; + if (std::int64_t(std::get<0>(elem)) <= last) + return false; + if (std::int64_t(std::get<1>(elem)) <= last) + return false; + last = std::get<1>(elem); + } + return true; + } + + void subaddrs::merge(const std::uint32_t index) + { + auto start = value.lower_bound({index, 0}); + if (start != value.end()) + { // merge lower ranges + if (index < std::get<0>(*start)) + { + if (start == value.begin()) + { + value.insert({0, index}); + return; + } + --start; + } + + const auto highest = std::max(index, std::get<1>(*start)); + value.erase(value.begin(), start + 1); + value.insert({0, highest}); + } + else if (start == value.end()) + { + auto highest = index; + if (!value.empty()) + highest = std::max(highest, std::get<1>(*value.rbegin())); + value.clear(); + value.insert({0, highest}); + } + } + + void read_bytes(wire::json_reader& source, subaddrs& self) + { + wire_read::array(source, self.value, wire::min_element_size<2>{}); + if (!self.is_valid()) + WIRE_DLOG_THROW(wire::error::schema::array, "invalid array mapping"); + } + void write_bytes(wire::json_writer& dest, const subaddrs& self) + { + wire_write::array(dest, self.value); + } + + WIRE_JSON_DEFINE_OBJECT(get_subaddrs::mapped_type, map_subaddr); void read_bytes(wire::json_reader& source, get_subaddrs& self) { wire::object(source, WIRE_FIELD_ARRAY(all_subaddrs, max_subaddrs)); @@ -451,7 +442,8 @@ namespace lwsf { namespace internal { namespace rpc wire::object(source, wire::field("address", std::cref(self.creds.address)), wire::field("view_key", std::cref(self.creds.view_key)), - WIRE_FIELD(from_height) + WIRE_FIELD_DEFAULTED(from_height, unsigned(0)), + WIRE_FIELD_DEFAULTED(lookahead, address_meta{}) ); } @@ -461,6 +453,7 @@ namespace lwsf { namespace internal { namespace rpc WIRE_OPTIONAL_FIELD(payment_address), WIRE_OPTIONAL_FIELD(payment_id), WIRE_OPTIONAL_FIELD(import_fee), + WIRE_OPTIONAL_FIELD(lookahead), WIRE_FIELD(status), WIRE_FIELD(new_request), WIRE_FIELD(request_fulfilled) @@ -513,10 +506,7 @@ namespace lwsf { namespace internal { namespace rpc void read_bytes(wire::json_reader& source, upsert_subaddrs_response& self) { - wire::object(source, - WIRE_FIELD_ARRAY(new_subaddrs, max_subaddrs), - WIRE_FIELD_ARRAY(all_subaddrs, max_subaddrs) - ); + wire::object(source); } }}} // lwsf // internal // rpc diff --git a/src/rpc.h b/src/rpc.h index d7ad750..91db559 100644 --- a/src/rpc.h +++ b/src/rpc.h @@ -28,8 +28,11 @@ #pragma once +#include #include +#include #include +#include #include #include #include @@ -39,60 +42,94 @@ #include "byte_stream.h" // moneor/contrib/epee/include #include "common/expect.h" // monero/src #include "crypto/crypto.h" // monero/src +#include "error.h" +#include "lwsf_config.h" +#include "net/http.h" #include "ringct/rctTypes.h" // monero/src #include "wire/basic_value.h" #include "wire/fwd.h" #include "wire/json.h" #include "wire/traits.h" -namespace epee { namespace net_utils -{ - class blocked_mode_client; - namespace http { template class http_simple_client_template; } -}} - namespace lwsf { namespace internal { namespace rpc { - using max_subaddrs = wire::max_element_count<43690>; - using http_client = epee::net_utils::http::http_simple_client_template< - epee::net_utils::blocked_mode_client - >; + using max_subaddrs = wire::max_element_count<16384>; + struct daemon_status; - enum class error : int + template + struct unpacker { - none = 0, no_response = -1, invalid_code = -2 /* Otherwise HTTP error code */ + T* out; + F f; + + void operator()(std::error_code error, epee::byte_slice response) + { + LWSF_VERIFY(out); + if (!error) + { + error = wire::json::from_bytes({reinterpret_cast(response.data()), response.size()}, *out); + if (error) + MERROR("Failed to unpack " << boost::core::demangle(typeid(T).name()) << ": " << error.message()); + } + f(error); + } }; + - const std::error_category& error_category() noexcept; - inline std::error_code make_error_code(const error value) noexcept + template + void invoke_async(const http::client& client, const T& in, U* out, F f) { - return std::error_code{int(value), error_category()}; - } - - //! Send `payload` to `client` at uri `endpoint`, and \return payload response - expect invoke_payload(http_client& client, boost::string_ref prefix, boost::string_ref endpoint, epee::byte_slice payload); - - template - expect invoke(http_client& client, boost::string_ref prefix, const G& in) - { - epee::byte_stream sink{}; - std::error_code error = wire::json::to_bytes(sink, in); - if (error) - return error; - - expect result = invoke_payload(client, prefix, F::endpoint(), epee::byte_slice{std::move(sink)}); - if (!result) - return result.error(); - - F out{}; - error = wire::json::from_bytes(epee::to_span(*result), out); - if (error) - return error; - return out; + // /daemon_status historically needed to be a post + if constexpr (!std::is_empty{} || std::is_same{}) + { + epee::byte_stream sink{}; + std::error_code error = wire::json::to_bytes(sink, in); + if (error) + return f(error); + client.post_async(U::endpoint(), epee::byte_slice{std::move(sink)}, unpacker{out, std::move(f)}); + } + else + client.get_async(U::endpoint(), unpacker{out, std::move(f)}); } struct empty {}; WIRE_DECLARE_OBJECT(empty); + + struct address_meta + { + std::uint32_t maj_i; + std::uint32_t min_i; + + constexpr address_meta() noexcept + : maj_i(0), min_i(0) + {} + + constexpr address_meta(const std::uint32_t maj, std::uint32_t min) noexcept + : maj_i(maj), min_i(min) + {} + + constexpr address_meta(config::lookahead src) noexcept + : maj_i(src.major), min_i(src.minor) + {} + + constexpr bool is_default() const noexcept { return !maj_i && !min_i; } + }; + WIRE_DECLARE_OBJECT(address_meta); + + inline constexpr bool operator<(const address_meta& lhs, const address_meta& rhs) noexcept + { + return lhs.maj_i == rhs.maj_i ? + lhs.min_i < rhs.min_i : lhs.maj_i < rhs.maj_i; + } + inline constexpr bool operator==(const address_meta& lhs, const address_meta& rhs) noexcept + { + return lhs.maj_i == rhs.maj_i && lhs.min_i == rhs.min_i; + } + inline constexpr bool operator!=(const address_meta& lhs, const address_meta& rhs) noexcept + { + return lhs.maj_i != rhs.maj_i || lhs.min_i != rhs.min_i; + } + struct login { @@ -110,6 +147,7 @@ namespace lwsf { namespace internal { namespace rpc std::string address; crypto::secret_key view_key; + address_meta lookahead; bool create_account; bool generated_locally; }; @@ -121,6 +159,7 @@ namespace lwsf { namespace internal { namespace rpc static constexpr const char* endpoint() noexcept { return "/login"; } boost::optional start_height; + boost::optional lookahead; bool new_address; }; void read_bytes(wire::json_reader&, login_response&); @@ -141,34 +180,7 @@ namespace lwsf { namespace internal { namespace rpc enum class uint64_string : std::uint64_t {}; void write_bytes(wire::json_writer&, uint64_string); void read_bytes(wire::json_reader&, uint64_string&); - - struct address_meta - { - std::uint32_t maj_i; - std::uint32_t min_i; - - constexpr address_meta() noexcept - : maj_i(0), min_i(0) - {} - - constexpr address_meta(const std::uint32_t maj, std::uint32_t min) noexcept - : maj_i(maj), min_i(min) - {} - - constexpr bool is_default() const noexcept { return !maj_i && !min_i; } - }; - WIRE_DECLARE_OBJECT(address_meta); - - inline constexpr bool operator<(const address_meta& lhs, const address_meta& rhs) noexcept - { - return lhs.maj_i == rhs.maj_i ? - lhs.min_i < rhs.min_i : lhs.maj_i < rhs.maj_i; - } - inline constexpr bool operator==(const address_meta& lhs, const address_meta& rhs) noexcept - { - return lhs.maj_i == rhs.maj_i && lhs.min_i == rhs.min_i; - } - + struct transaction_spend { uint64_string amount; @@ -221,13 +233,25 @@ namespace lwsf { namespace internal { namespace rpc static constexpr const char* endpoint() noexcept { return "/get_address_txs"; } std::vector transactions; + boost::optional lookahead_fail; std::uint64_t scanned_block_height; std::uint64_t start_height; std::uint64_t blockchain_height; + address_meta lookahead; }; void read_bytes(wire::json_reader&, get_address_txs&); + struct get_version + { + get_version() = delete; + static constexpr const char* endpoint() noexcept { return "/get_version"; } + + std::uint64_t max_subaddresses; + }; + void read_bytes(wire::json_reader&, get_version&); + + struct random_output { random_output() @@ -272,44 +296,36 @@ namespace lwsf { namespace internal { namespace rpc struct subaddrs { - constexpr subaddrs() noexcept - : head({0, 0}), key(0) + subaddrs() noexcept + : value() {} - constexpr explicit subaddrs(const std::uint32_t key) noexcept - : head({0, 0}), key(key) - {} + explicit subaddrs(const std::uint32_t last) noexcept + : value() + { + value.insert({0, last}); + } + + //! \return true if `this` represents a canonical range of values. + bool is_valid() const noexcept; + + // Merge `{0, index}` to the set of subaddresses. + void merge(std::uint32_t index); - std::array head; //!< Only the first element of array - std::uint32_t key; + boost::container::flat_set, std::less<>> value; }; WIRE_JSON_DECLARE_OBJECT(subaddrs); void read_bytes(wire::json_reader&, subaddrs&); - constexpr inline bool operator<(const subaddrs& lhs, const subaddrs& rhs) noexcept - { return lhs.key < rhs.key; } - - template - constexpr inline bool operator<(const subaddrs& lhs, const T rhs) noexcept - { - static_assert(std::is_unsigned()); - return lhs.key < rhs; - } - - template - inline bool operator<(const T lhs, const subaddrs& rhs) noexcept - { - static_assert(std::is_unsigned()); - return lhs < rhs.key; - } - struct get_subaddrs { + using mapped_type = std::pair; get_subaddrs() = delete; static constexpr const char* endpoint() noexcept { return "/get_subaddrs"; } - boost::container::flat_set> all_subaddrs; + boost::container::flat_map all_subaddrs; }; + WIRE_JSON_DECLARE_OBJECT(get_subaddrs::mapped_type); void read_bytes(wire::json_reader&, get_subaddrs&); @@ -383,6 +399,7 @@ namespace lwsf { namespace internal { namespace rpc import_request() = delete; login creds; std::uint64_t from_height; + address_meta lookahead; }; void write_bytes(wire::json_writer&, const import_request&); @@ -391,11 +408,14 @@ namespace lwsf { namespace internal { namespace rpc import_response() = delete; import_response(import_response&&) = default; import_response(const import_response&) = delete; + import_response& operator=(import_response&&) = default; + import_response& operator=(const import_response&) = delete; static constexpr const char* endpoint() noexcept { return "/import_wallet_request"; } boost::optional payment_address; boost::optional payment_id; boost::optional import_fee; + boost::optional lookahead; std::string status; bool new_request; bool request_fulfilled; @@ -420,8 +440,8 @@ namespace lwsf { namespace internal { namespace rpc provision_subaddrs_response() = delete; static constexpr const char* endpoint() noexcept { return "/provision_subaddrs"; } - boost::container::flat_set new_subaddrs; - boost::container::flat_set all_subaddrs; + boost::container::flat_map new_subaddrs; + boost::container::flat_map all_subaddrs; }; void read_bytes(wire::json_reader&, provision_subaddrs_response&); @@ -447,7 +467,7 @@ namespace lwsf { namespace internal { namespace rpc { upsert_subaddrs_request() = delete; login creds; - boost::container::flat_set> subaddrs_; + boost::container::flat_map subaddrs_; bool get_all; }; void write_bytes(wire::json_writer&, const upsert_subaddrs_request&); @@ -456,9 +476,6 @@ namespace lwsf { namespace internal { namespace rpc { upsert_subaddrs_response() = delete; static constexpr const char* endpoint() noexcept { return "/upsert_subaddrs"; } - - boost::container::flat_set new_subaddrs; - boost::container::flat_set> all_subaddrs; }; void read_bytes(wire::json_reader&, upsert_subaddrs_response&); @@ -466,10 +483,3 @@ namespace lwsf { namespace internal { namespace rpc WIRE_DECLARE_BLOB(lwsf::internal::rpc::ringct); -namespace std -{ - template<> - struct is_error_code_enum - : true_type - {}; -} diff --git a/src/wallet.cpp b/src/wallet.cpp index e6b0da7..2d540f4 100644 --- a/src/wallet.cpp +++ b/src/wallet.cpp @@ -34,6 +34,7 @@ #include #include +#include #include #include #include @@ -51,10 +52,10 @@ #include "hex.h" // monero/contrib/epee/include #include "lwsf_config.h" #include "mnemonics/electrum-words.h" // monero/src +#include "net/context.h" +#include "net/http.h" #include "net/net_parse_helpers.h" // monero/contrib/epee/include #include "net/parse.h" // monero/src -#include "net/socks.h" // monero/src -#include "net/socks_connect.h" // monero/src #include "numeric.h" #include "pending_transaction.h" #include "subaddress_account.h" @@ -81,12 +82,9 @@ namespace lwsf { namespace internal struct null_connector { - boost::unique_future - operator()(const std::string&, const std::string&, boost::asio::steady_timer&) const + void operator()(std::shared_ptr, std::function f) const { - return boost::make_exceptional_future( - std::runtime_error{"invalid proxy value"} - ); + f(http::error::timeout); } }; @@ -265,40 +263,82 @@ namespace lwsf { namespace internal } } // anonymous - bool wallet::set_error(const std::error_code error, const bool clear) const + wallet::frame::frame() + : exception_error_(), + error_(), + refresh_interval_(config::refresh_interval), + sync_(), + thread_state_(state::stop) + {} + + bool wallet::frame::set_error(const std::error_code error, const bool clear) { if (clear || error) { - const boost::lock_guard lock{error_sync_}; + const boost::lock_guard lock{sync_}; error_ = error; } return !error; } + void wallet::frame::async_error::operator()(const std::error_code error) const + { + LWSF_VERIFY(self); + self->set_error(error, false); + } + + bool wallet::set_error(const std::error_code error, const bool clear) const + { + return status_->set_error(error, clear); + } + void wallet::set_critical(const std::exception& e) const { - const boost::lock_guard lock{error_sync_}; - exception_error_ = e.what(); + + const boost::lock_guard lock{status_->sync_}; + status_->exception_error_ = e.what(); + } + + template + T wallet::wait_for(F&& f) + { + try + { + return context_->wait_for(std::forward(f)); + } + catch (const std::exception& e) + { set_critical(e); throw; } + catch (...) + { set_critical(unknown_exception{}); throw; } + throw std::runtime_error{"unreachable"}; } - template - void wallet::queue_work(F&& f) + void wallet::check_worker_thread() { + const boost::lock_guard lock{thread_sync_}; + bool run = false; { - const boost::lock_guard lock{refresh_sync_}; - work_queue_.push_back(std::forward(f)); + const boost::lock_guard lock2{status_->sync_}; + run = (status_->thread_state_ == state::stop); + } + + if (run) + { + context_->io_.stop(); + if (thread_.joinable()) + thread_.join(); + thread_ = boost::thread{[this] () { this->refresh_loop(); }}; } - startRefresh(); } void wallet::stop_refresh_loop() { const boost::lock_guard lock{thread_sync_}; { - const boost::lock_guard lock2{refresh_sync_}; - thread_state_ = state::stop; + const boost::lock_guard lock2{status_->sync_}; + status_->thread_state_ = state::stop; } - refresh_notify_.notify_all(); + context_->io_.stop(); if (thread_.joinable()) thread_.join(); } @@ -307,63 +347,107 @@ namespace lwsf { namespace internal { struct set_stop_ { - void operator()(state* val) const noexcept + void operator()(wallet* val) const { - if (val) - *val = state::stop; + if (!val) + return; + const boost::lock_guard lock{val->status_->sync_}; + val->status_->thread_state_ = state::stop; } }; - try + + struct on_refresh { - std::chrono::steady_clock::time_point last_refresh; - boost::unique_lock lock{refresh_sync_}; - const std::unique_ptr set_stop{std::addressof(thread_state_)}; - while (mandatory_refresh_ || thread_state_ != state::stop || !work_queue_.empty()) - { - while (!work_queue_.empty()) - { - const std::function work{std::move(work_queue_.front())}; - work_queue_.pop_front(); - lock.unlock(); - if (work) - set_error(work()); - lock.lock(); - } + std::weak_ptr timer_; + std::shared_ptr status_; + std::shared_ptr wallet_; - const bool mandatory_refresh = mandatory_refresh_; - mandatory_refresh_ = false; + explicit on_refresh(std::weak_ptr timer, std::shared_ptr in, std::shared_ptr in2) + : timer_(std::move(timer)), status_(std::move(in)), wallet_(std::move(in2)) + {} + + on_refresh(on_refresh&&) = default; + on_refresh(const on_refresh&) = default; + + void operator()(const std::error_code error) const + { + LWSF_VERIFY(status_); + status_->set_error(error, true /* clear */); + const auto timer = timer_.lock(); + if (!timer) + return; // exiting refresh loop - const auto now = std::chrono::steady_clock::now(); - if (mandatory_refresh_ || (refresh_interval_ <= now - last_refresh && thread_state_ == state::run)) + std::chrono::milliseconds interval; { - // refresh has strong exception guarantee - never in partial state. - lock.unlock(); - last_refresh = now; - set_error(data_->refresh(mandatory_refresh), true /*clear*/); - lock.lock(); + const boost::lock_guard lock{status_->sync_}; + interval = status_->refresh_interval_; } - else if (thread_state_ == state::skip_once) - thread_state_ = state::run; - - // check while holding lock and before a wait call - if (thread_state_ == state::stop) + if (interval <= std::chrono::milliseconds{0}) return; - const auto last_state = thread_state_; - refresh_notify_.wait_for( - lock, to_boost(refresh_interval_), [this, last_state] () { - return mandatory_refresh_ || thread_state_ != last_state || !work_queue_.empty(); - }); + timer->expires_after(interval); + on_refresh copy{*this}; + timer->async_wait( + [copy = std::move(copy)] (const boost::system::error_code error) + { + if (error == boost::asio::error::operation_aborted) + return; + + LWSF_VERIFY(copy.status_); + boost::unique_lock lock{copy.status_->sync_}; + switch (copy.status_->thread_state_) + { + case state::stop: + break; + case state::skip_once: + copy.status_->thread_state_ = state::run; + /* fallthrough */ + default: + case state::paused: + { + std::error_code dummy; + { + const boost::lock_guard lock2{copy.wallet_->sync}; + dummy = copy.wallet_->refresh_error; + } + lock.unlock(); + copy(dummy); + } + break; + case state::run: + lock.unlock(); + backend::wallet::refresh(copy.wallet_, false /* mandatory */, copy); + break; + } + } + ); } - } - catch (const std::exception& e) + }; + + const std::unique_ptr set_stop{this}; + try { - set_critical(e); + std::shared_ptr timer; + boost::unique_lock lock{status_->sync_}; + if (status_->thread_state_ != state::stop) + { + timer = std::make_shared(context_->io_); + backend::wallet::refresh(data_, false /* mandatory */, on_refresh{timer, status_, data_}); + } + do + { + lock.unlock(); + { + const std::shared_ptr restart_lock = context_->restart_asio(); + context_->io_.run(); + } + lock.lock(); + } while (status_->thread_state_ != state::stop); } + catch (const std::exception& e) + { set_critical(e); } catch (...) - { - set_critical(unknown_exception{}); - } + { set_critical(unknown_exception{}); } } bool wallet::is_wallet_file(const std::string& path) @@ -405,48 +489,41 @@ namespace lwsf { namespace internal } wallet::wallet(error, std::string msg) - : data_(std::make_shared()), + : context_(std::make_shared()), + data_(std::make_shared(context_->io_)), + status_(std::make_shared()), addressbook_(), history_(), subaddresses_(), subaddress_minor_(), filename_(), password_(), - work_queue_(), - exception_error_(std::move(msg)), - error_(), thread_(), iterations_(1), mixin_(config::mixin_default), - refresh_interval_(config::refresh_interval), refresh_notify_(), - error_sync_(), - refresh_sync_(), thread_sync_(), - thread_state_(state::stop), mandatory_refresh_(false) - {} + { + status_->exception_error_ = std::move(msg); + } wallet::wallet(create, Monero::NetworkType nettype, std::string filename, std::string password, const std::uint64_t kdf_rounds) - : data_(std::make_shared()), + : + context_(std::make_shared()), + data_(std::make_shared(context_->io_)), + status_(std::make_shared()), addressbook_(), history_(), subaddresses_(), subaddress_minor_(), filename_(std::move(filename)), password_(std::move(password)), - work_queue_(), - exception_error_(), - error_(), thread_(), iterations_(kdf_rounds), mixin_(config::mixin_default), - refresh_interval_(config::refresh_interval), refresh_notify_(), - error_sync_(), - refresh_sync_(), thread_sync_(), - thread_state_(state::stop), mandatory_refresh_(false) { if (sodium_init() < 0) @@ -460,25 +537,20 @@ namespace lwsf { namespace internal } wallet::wallet(open, Monero::NetworkType nettype, std::string filename, std::string password, const std::uint64_t kdf_rounds) - : data_(std::make_shared()), + : context_(std::make_shared()), + data_(std::make_shared(context_->io_)), + status_(std::make_shared()), addressbook_(), history_(), subaddresses_(), subaddress_minor_(), filename_(std::move(filename)), password_(std::move(password)), - work_queue_(), - exception_error_(), - error_(), thread_(), iterations_(kdf_rounds), mixin_(config::mixin_default), - refresh_interval_(config::refresh_interval), refresh_notify_(), - error_sync_(), - refresh_sync_(), thread_sync_(), - thread_state_(state::stop), mandatory_refresh_(false) { try @@ -499,37 +571,33 @@ namespace lwsf { namespace internal } catch (const std::exception& e) { - exception_error_ = e.what(); + status_->exception_error_ = e.what(); } } wallet::wallet(from_mnemonic, const Monero::NetworkType nettype, std::string filename, std::string password, const std::uint64_t kdf_rounds, const std::string& mnemonic, const std::string& seed_offset) - : data_(std::make_shared()), + : + context_(std::make_shared()), + data_(std::make_shared(context_->io_)), + status_(std::make_shared()), addressbook_(), history_(), subaddresses_(), subaddress_minor_(), filename_(std::move(filename)), password_(std::move(password)), - work_queue_(), - exception_error_(), - error_(), thread_(), iterations_(kdf_rounds), mixin_(config::mixin_default), - refresh_interval_(config::refresh_interval), refresh_notify_(), - error_sync_(), - refresh_sync_(), thread_sync_(), - thread_state_(state::stop), mandatory_refresh_(false) { crypto::secret_key recovery; std::string language; if (!crypto::ElectrumWords::words_to_bytes(mnemonic, recovery, language)) { - exception_error_ = "Electrum-style word list failed verification"; + status_->exception_error_ = "Electrum-style word list failed verification"; return; } @@ -540,25 +608,20 @@ namespace lwsf { namespace internal } wallet::wallet(from_keys, const Monero::NetworkType nettype, std::string filename, std::string password, const std::uint64_t kdf_rounds, const boost::optional& view_key, const boost::optional& spend_key) - : data_(std::make_shared()), + : context_(std::make_shared()), + data_(std::make_shared(context_->io_)), + status_(std::make_shared()), addressbook_(), history_(), subaddresses_(), subaddress_minor_(), filename_(std::move(filename)), password_(std::move(password)), - work_queue_(), - exception_error_(), - error_(), thread_(), iterations_(kdf_rounds), mixin_(config::mixin_default), - refresh_interval_(config::refresh_interval), refresh_notify_(), - error_sync_(), - refresh_sync_(), thread_sync_(), - thread_state_(state::stop), mandatory_refresh_(false) { if (sodium_init() < 0) @@ -566,13 +629,13 @@ namespace lwsf { namespace internal if (!view_key) { - exception_error_ = "view_key is invalid"; + status_->exception_error_ = "view_key is invalid"; return; } if (!spend_key) { - exception_error_ = "spend_key is invalid"; + status_->exception_error_ = "spend_key is invalid"; return; } @@ -583,13 +646,13 @@ namespace lwsf { namespace internal if (!crypto::secret_key_to_public_key(data_->primary.view.sec, data_->primary.view.pub)) { - exception_error_ = "view_pub could not be computed"; + status_->exception_error_ = "view_pub could not be computed"; return; } if (!crypto::secret_key_to_public_key(data_->primary.spend.sec, data_->primary.spend.pub)) { - exception_error_ = "spend_pub could not be computed"; + status_->exception_error_ = "spend_pub could not be computed"; return; } @@ -611,28 +674,31 @@ namespace lwsf { namespace internal : wallet(from_keys{}, nettype, std::move(filename), std::move(password), kdf_rounds, to_secret_key(view_key), to_secret_key(spend_key)) { if (data_->get_spend_address({0, 0}) != address_string) - exception_error_ = "view_key, spend_key, and address_string do not match"; + status_->exception_error_ = "view_key, spend_key, and address_string do not match"; } wallet::~wallet() { stop_refresh_loop(); } void wallet::add_subaddress(const std::uint32_t accountIndex, std::string label) { - const boost::lock_guard lock{data_->sync}; - auto& accts = data_->primary.subaccounts; - - if (accts.size() <= accountIndex) - throw std::runtime_error{"add_subaddress: account does not exist"}; + std::uint32_t min_i = 0; + { + const boost::lock_guard lock{data_->sync}; + auto& accts = data_->primary.subaccounts; - auto& acct = accts.at(accountIndex); - if (std::numeric_limits::max() <= acct.last) - throw std::runtime_error{"add_subaddress: exceeded minor indexes"}; + if (accts.size() <= accountIndex) + throw std::runtime_error{"add_subaddress: account does not exist"}; - const std::uint32_t min_i = ++acct.last; - if (!label.empty()) - acct.detail.try_emplace(min_i).first->second.label = std::move(label); + auto& acct = accts.at(accountIndex); + if (std::numeric_limits::max() <= acct.last) + throw std::runtime_error{"add_subaddress: exceeded minor indexes"}; - queue_work([=] () { return data_->register_subaddress(accountIndex, min_i); }); + min_i = ++acct.last; + if (!label.empty()) + acct.detail.try_emplace(min_i).first->second.label = std::move(label); + } + backend::wallet::register_subaddress(data_, accountIndex, min_i, frame::async_error{status_}); + check_worker_thread(); } std::string wallet::seed(const std::string& seed_offset) const @@ -690,16 +756,16 @@ namespace lwsf { namespace internal void wallet::statusWithErrorString(int& status, std::string& errorString) const { - const boost::lock_guard lock{error_sync_}; - if (!exception_error_.empty()) + const boost::lock_guard lock{status_->sync_}; + if (!status_->exception_error_.empty()) { status = Status_Critical; - errorString = exception_error_; + errorString = status_->exception_error_; } - else if (error_) + else if (status_->error_) { status = Status_Error; - errorString = error_.message(); + errorString = status_->error_.message(); } else { @@ -743,8 +809,8 @@ namespace lwsf { namespace internal void wallet::stop() { - const boost::lock_guard lock{refresh_sync_}; - thread_state_ = state::skip_once; + const boost::lock_guard lock{status_->sync_}; + status_->thread_state_ = state::skip_once; } bool wallet::store(const std::string& path) @@ -789,12 +855,11 @@ namespace lwsf { namespace internal if (!epee::net_utils::parse_url(daemon_address, url)) throw std::runtime_error{"Invalid LWS URL: " + daemon_address}; - if (!proxy_address.empty() && !setProxy(proxy_address)) + if (!setProxy(proxy_address)) return false; - boost::optional login; if (!daemon_username.empty() || !daemon_password.empty()) - login.emplace(daemon_username, daemon_password); + throw std::runtime_error{"HTTP loging not supported"}; // verify cert if `use_ssl == true`, otherwise autodetect if `https` // specified. @@ -834,9 +899,8 @@ namespace lwsf { namespace internal } const boost::unique_lock lock{data_->sync}; - data_->client.set_server(std::move(url.host), std::to_string(url.port), std::move(login), std::move(options)); data_->passed_login = false; - data_->client_prefix = std::move(url.uri); + data_->client.init(context_->io_, std::move(url.host), std::move(url.uri), boost::numeric_cast(url.port), std::move(options)); } catch (const std::exception& e) { @@ -849,9 +913,12 @@ namespace lwsf { namespace internal void wallet::setRefreshFromBlockHeight(const std::uint64_t height) { - const boost::lock_guard lock{data_->sync}; - data_->primary.requested_start = std::min(data_->primary.requested_start, height); - queue_work([this, height] () { return data_->restore_height(height).error(); }); + { + const boost::lock_guard lock{data_->sync}; + data_->primary.requested_start = std::min(data_->primary.requested_start, height); + } + backend::wallet::restore_height(this->data_, height, frame::async_error{status_}); + check_worker_thread(); } uint64_t wallet::getRefreshFromBlockHeight() const @@ -862,49 +929,55 @@ namespace lwsf { namespace internal void wallet::setSubaddressLookahead(uint32_t major, uint32_t minor) { - queue_work([this, major, minor] () { return data_->set_lookahead(major, minor); }); + backend::wallet::set_lookahead(this->data_, major, minor, frame::async_error{status_}); + check_worker_thread(); } bool wallet::connectToDaemon() { - const bool connected = data_->client.is_connected(); boost::unique_lock lock{data_->sync}; - if (connected && data_->passed_login) + if (data_->passed_login && data_->client.is_connected()) return true; - if (connected || data_->client.connect(config::connect_timeout)) + lock.unlock(); + try { - lock.unlock(); - return set_error(data_->login()); + auto data = data_; + const std::error_code error = wait_for( + [data = std::move(data)] (auto&& f) { backend::wallet::login(data, std::move(f)); } + ); + if (error) + return false; } - return set_error(rpc::error::no_response); + catch (...) + { return false; } + + lock.lock(); + return data_->passed_login && data_->client.is_connected(); } Monero::Wallet::ConnectionStatus wallet::connected() const { - if (!data_->client.is_connected()) - return ConnectionStatus_Disconnected; - const boost::lock_guard lock{data_->sync}; - return data_->passed_login ? + return data_->passed_login && data_->client.is_connected() ? ConnectionStatus_Connected : ConnectionStatus_Disconnected; } bool wallet::setProxy(const std::string &address) { - data_->client.disconnect(); if (address.empty()) - data_->client.set_connector(epee::net_utils::direct_connect{}); - else { - auto endpoint = net::get_tcp_endpoint(address); - if (!endpoint) - { - data_->client.set_connector(null_connector{}); - return set_error(endpoint.error()); - } - data_->client.set_connector(net::socks::connector{std::move(*endpoint)}); + data_->client.set_proxy(http::client::direct{}); + return true; + } + + auto endpoint = ::net::get_tcp_endpoint(address); + if (!endpoint) + { + data_->client.set_proxy(null_connector{}); + return set_error(endpoint.error()); } + data_->client.set_proxy(http::client::socks{*endpoint}); return true; } @@ -1044,25 +1117,19 @@ namespace lwsf { namespace internal void wallet::startRefresh() { const boost::lock_guard lock{thread_sync_}; - state old_state = state::stop; + bool run = false; { - const boost::lock_guard lock2{refresh_sync_}; - old_state = thread_state_; - const bool no_refresh = - refresh_interval_ <= std::chrono::milliseconds{0}; - if (no_refresh) + const boost::lock_guard lock2{status_->sync_}; + if (status_->thread_state_ == state::stop) { - thread_state_ = state::stop; - if (!mandatory_refresh_ && work_queue_.empty()) - return; + status_->thread_state_ = state::run; + run = true; } - else - thread_state_ = state::run; } - refresh_notify_.notify_all(); - if (old_state == state::stop) + if (run) { + context_->io_.stop(); if (thread_.joinable()) thread_.join(); thread_ = boost::thread{[this] () { this->refresh_loop(); }}; @@ -1071,25 +1138,28 @@ namespace lwsf { namespace internal void wallet::pauseRefresh() { - const boost::lock_guard lock{refresh_sync_}; - if (thread_state_ != state::stop) - thread_state_ = state::paused; + const boost::lock_guard lock{status_->sync_}; + if (status_->thread_state_ != state::stop) + status_->thread_state_ = state::paused; } bool wallet::refresh() { - try { return set_error(data_->refresh(true), true /* clear */); } - catch (const std::exception& e) { set_critical(e); } + try + { + const auto refresh = [this] (auto&& f) + { backend::wallet::refresh(this->data_, true, std::move(f)); }; + + return set_error(wait_for(refresh), true /* clear */); + } + catch (...) {} return false; } void wallet::refreshAsync() { - { - const boost::lock_guard lock{refresh_sync_}; - mandatory_refresh_ = true; - } - startRefresh(); + backend::wallet::refresh(data_, true /* mandatory */, frame::async_error{status_}); + check_worker_thread(); } void wallet::setAutoRefreshInterval(int millis) @@ -1097,11 +1167,11 @@ namespace lwsf { namespace internal using rep_type = std::chrono::milliseconds::rep; static_assert(std::numeric_limits::max() <= std::numeric_limits::max()); - boost::unique_lock lock{refresh_sync_}; - refresh_interval_ = std::chrono::milliseconds{millis}; + boost::unique_lock lock{status_->sync_}; + status_->refresh_interval_ = std::chrono::milliseconds{millis}; if (millis <= 0) { - thread_state_ = state::stop; + status_->thread_state_ = state::stop; lock.unlock(); stop_refresh_loop(); } @@ -1109,20 +1179,24 @@ namespace lwsf { namespace internal int wallet::autoRefreshInterval() const { - return refresh_interval_.count(); + return status_->refresh_interval_.count(); } void wallet::addSubaddressAccount(const std::string& label) { - const boost::lock_guard lock{data_->sync}; - auto& accts = data_->primary.subaccounts; + std::size_t index = 0; + { + const boost::lock_guard lock{data_->sync}; + auto& accts = data_->primary.subaccounts; - const std::size_t index = accts.size(); - if (std::numeric_limits::max() < index) - throw std::runtime_error{"addSubddressAccount exceeded subaddress indexes"}; + index = accts.size(); + if (std::numeric_limits::max() < index) + throw std::runtime_error{"addSubddressAccount exceeded subaddress indexes"}; - accts.emplace_back().detail.try_emplace(0).first->second.label = label; - queue_work([this, index] () { return data_->register_subaccount(index); }); + accts.emplace_back().detail.try_emplace(0).first->second.label = label; + } + backend::wallet::register_subaccount(this->data_, index, frame::async_error{status_}); + check_worker_thread(); } std::size_t wallet::numSubaddressAccounts() const @@ -1223,8 +1297,10 @@ namespace lwsf { namespace internal cryptonote::account_public_address change_account{}; unspent_map unspent{}; { - data_->refresh(); // get latest outputs, block height, and fee info - + wait_for( + [this] (auto&& callable) { backend::wallet::refresh(this->data_, false, std::move(callable)); } + ); + const boost::lock_guard lock{data_->sync}; fee_mask = data_->fee_mask; @@ -1420,7 +1496,10 @@ namespace lwsf { namespace internal for (const auto& spend : spending) req.amounts.push_back(rpc::uint64_string(rct_amount(spend.second->receives.at(spend.first)))); - auto resp = data_->get_decoys(req); + const auto resp = wait_for>( + [this, &req] (auto&& callable) + { backend::wallet::get_decoys(this->data_, std::move(req), std::move(callable)); } + ); if (!resp) throw std::system_error{resp.error()}; decoys = std::move(*resp); @@ -1842,14 +1921,14 @@ namespace lwsf { namespace internal if (!dests_flat.empty()) throw_low_funds(); - return std::make_unique(data_, std::string{}, std::move(pending)); + return std::make_unique(data_, context_, std::string{}, std::move(pending)); }(); // end unnamed lambda encapsulating all tx construction LWSF_TX_VERIFY(out != nullptr); } catch (const std::exception& e) { - out = std::make_unique(data_, e.what()); + out = std::make_unique(data_, context_, e.what()); } return out.release(); @@ -1866,7 +1945,7 @@ namespace lwsf { namespace internal bool wallet::submitTransaction(const std::string &fileName) { - const auto tx = pending_transaction::load_from_file(data_, fileName); + const auto tx = pending_transaction::load_from_file(data_, context_, fileName); if (!tx) throw std::runtime_error{"Expected non-null from load_from_file"}; return tx->status() == Monero::PendingTransaction::Status_Ok && tx->send(); diff --git a/src/wallet.h b/src/wallet.h index 0d61846..14645a6 100644 --- a/src/wallet.h +++ b/src/wallet.h @@ -41,7 +41,7 @@ #include #include #include "crypto/crypto.h" // monero/src -#include "net/http_client.h" // monero/contrib/epee/include +#include "net/http.h" #include "wallet/api/wallet2_api.h" // monero/src namespace lwsf @@ -49,12 +49,35 @@ namespace lwsf namespace internal { namespace backend { struct wallet; } + namespace net { struct context; } class wallet final : public Monero::Wallet { enum class state : std::uint8_t { stop = 0, paused, skip_once, run }; + struct frame + { + std::string exception_error_; + std::error_code error_; + std::chrono::milliseconds refresh_interval_; + boost::mutex sync_; + state thread_state_; + + frame(); + + bool set_error(std::error_code error, bool clear); + struct async_error + { + std::shared_ptr self; + void operator()(std::error_code error) const; + }; + + bool thead_start(); + }; + + const std::shared_ptr context_; // `data_` holds `io_service` references const std::shared_ptr data_; + const std::shared_ptr status_; std::unique_ptr addressbook_; std::unique_ptr history_; std::unique_ptr subaddresses_; @@ -63,26 +86,21 @@ namespace internal std::string password_; std::string language_; std::string ca_file_path_; - std::deque> work_queue_; - mutable std::string exception_error_; - mutable std::error_code error_; boost::thread thread_; const std::uint64_t iterations_; - std::chrono::milliseconds refresh_interval_; std::uint32_t mixin_; boost::condition_variable refresh_notify_; - mutable boost::mutex error_sync_; boost::mutex refresh_sync_; //!< Synchronizes with requests from other threads boost::mutex thread_sync_; //!< Syncrhonizes thread destruction+creation - state thread_state_; bool mandatory_refresh_; bool set_error(std::error_code status, bool clear = false) const; void set_critical(const std::exception& e) const; - template - void queue_work(F&& f); + template + T wait_for(F&& f); + void check_worker_thread(); void stop_refresh_loop(); void refresh_loop(); diff --git a/src/wallet_manager.cpp b/src/wallet_manager.cpp index 624e15e..85a73d1 100644 --- a/src/wallet_manager.cpp +++ b/src/wallet_manager.cpp @@ -42,6 +42,7 @@ #ifdef LWSF_POLYSEED_ENABLE #include "polyseed.h" #endif +#include "net/context.h" #include "net/http_client.h" // monero/contrib/epee/include #include "net/parse.h" // monero/src #include "net/socks_connect.h" // monero/src @@ -57,27 +58,51 @@ namespace lwsf //! \TODO Mark final when completely implemented class wallet_manager : public Monero::WalletManager { - rpc::http_client client_; - std::string client_prefix_; + boost::asio::io_context io_; + http::client client_; std::string error_; rpc::daemon_status cached_; std::chrono::steady_clock::time_point cached_last_; + boost::mutex sync_; + + template + T wait_for(F f) + { + const boost::lock_guard lock{sync_}; + + net::to_future callable{}; + auto value = callable.inner_->promise.get_future(); + f(std::move(callable)); + for (;;) + { + switch (value.wait_for(std::chrono::seconds{0})) + { + case std::future_status::deferred: + case std::future_status::ready: + return value.get(); + + case std::future_status::timeout: + io_.restart(); + io_.run_one(); + break; + }; + } + throw std::logic_error{"unreachable"}; + } rpc::daemon_status get_daemon_status() { if (std::chrono::steady_clock::now() - cached_last_ > config::daemon_status_cache) { - const auto resp = rpc::invoke(client_, client_prefix_, rpc::empty{}); - if (!resp) - { - error_ = resp.error().message(); - cached_ = rpc::daemon_status{}; - } + rpc::daemon_status out{}; + const auto error = wait_for( + [this, &out] (auto&& f) { rpc::invoke_async(client_, rpc::empty{}, std::addressof(out), std::move(f)); } + ); + if (error) + error_ = error.message(); else - { error_.clear(); - cached_ = std::move(*resp); - } + cached_ = std::move(out); } return cached_; } @@ -85,7 +110,7 @@ namespace lwsf public: wallet_manager() - : client_(), error_(), cached_{}, cached_last_(std::chrono::seconds{0}) + : io_(), client_(), error_(), cached_{}, cached_last_(std::chrono::seconds{0}), sync_() {} virtual ~wallet_manager() /* override */ @@ -410,8 +435,7 @@ namespace lwsf url.port = 80; } - client_prefix_ = std::move(url.uri); - client_.set_server(std::move(url.host), std::to_string(url.port), boost::none, std::move(options)); + client_.init(io_,std::move(url.host), std::move(url.uri), boost::numeric_cast(url.port), std::move(options)); } //! returns whether the daemon can be reached, and its version number @@ -465,13 +489,13 @@ namespace lwsf bool setProxy(const std::string &address) override { - auto endpoint = net::get_tcp_endpoint(address); + auto endpoint = ::net::get_tcp_endpoint(address); if (!endpoint) { error_ = endpoint.error().message(); return false; } - client_.set_connector(net::socks::connector{std::move(*endpoint)}); + client_.set_proxy(http::client::socks{std::move(*endpoint)}); return true; } }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..e69c338 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,29 @@ +# Copyright (c) 2025, The Monero Project +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, are +# permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of +# conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, this list +# of conditions and the following disclaimer in the documentation and/or other +# materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors may be +# used to endorse or promote products derived from this software without specific +# prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +# THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +# THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +add_subdirectory(unit) diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt new file mode 100644 index 0000000..8db6f58 --- /dev/null +++ b/tests/unit/CMakeLists.txt @@ -0,0 +1,37 @@ +# Copyright (c) 2025, The Monero Project +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, are +# permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of +# conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, this list +# of conditions and the following disclaimer in the documentation and/or other +# materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors may be +# used to endorse or promote products derived from this software without specific +# prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +# THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +# THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +add_library(lwsf-unit-framework framework.test.cpp) +target_include_directories(lwsf-unit-framework PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} "${CMAKE_SOURCE_DIR}/src") +target_link_libraries(lwsf-unit-framework) + +add_executable(lwsf-unit main.cpp backend.test.cpp rpc.test.cpp) + +target_include_directories(lwsf-unit PRIVATE "${lwsf_SOURCE_DIR}/src") +target_link_libraries(lwsf-unit PRIVATE lwsf-api lwsf-unit-framework monero::libraries) +add_test(NAME lwsf-unit COMMAND lwsf-unit -v) diff --git a/tests/unit/backend.test.cpp b/tests/unit/backend.test.cpp new file mode 100644 index 0000000..ef0698d --- /dev/null +++ b/tests/unit/backend.test.cpp @@ -0,0 +1,73 @@ +// Copyright (c) 2025, The Monero Project +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "framework.test.h" +#include "backend.h" + +LWS_CASE("backend::acount") +{ + using subaddrs = lwsf::internal::rpc::subaddrs; + + SETUP("empty with zero lookahead") + { + lwsf::internal::backend::account account{.lookahead = {}}; + account.subaccounts.emplace_back().detail.try_emplace(0); + + boost::container::flat_map majors{}; + EXPECT(account.needed_subaddresses(majors) == 0); + + SECTION("standard lookahead") + { + account.lookahead = {50, 200}; + EXPECT(account.needed_subaddresses(majors) == 10000); + + account.subaccounts.back().last = 10; + EXPECT(account.needed_subaddresses(majors) == 10010); + + account.subaccounts.emplace_back().last = 10; + EXPECT(account.needed_subaddresses(majors) == 10220); + } + + SECTION("standard lookahead with overlapping custom lookaheads") + { + account.lookahead = {50, 200}; + majors.try_emplace(0, subaddrs{}).first->second.value = {{10, 20}, {50, 155}}; + + EXPECT(account.needed_subaddresses(majors) == 10000); + } + + SECTION("standard lookahead with non-overlapping custom lookaheads") + { + account.lookahead = {50, 200}; + majors.try_emplace(0, subaddrs{}).first->second.value = {{199, 201}}; + majors.try_emplace(50, subaddrs{}).first->second.value = {{199, 201}}; + + EXPECT(account.needed_subaddresses(majors) == 10005); + } + } +} + diff --git a/tests/unit/framework.test.cpp b/tests/unit/framework.test.cpp new file mode 100644 index 0000000..9bcf8f6 --- /dev/null +++ b/tests/unit/framework.test.cpp @@ -0,0 +1,37 @@ +// Copyright (c) 2025, The Monero Project +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "framework.test.h" + +namespace lwsf_test +{ + lest::tests& get_tests() + { + static lest::tests instance; + return instance; + } +} diff --git a/tests/unit/framework.test.h b/tests/unit/framework.test.h new file mode 100644 index 0000000..2d6f215 --- /dev/null +++ b/tests/unit/framework.test.h @@ -0,0 +1,39 @@ +// Copyright (c) 2025, The Monero Project +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#pragma once + +#define lest_FEATURE_AUTO_REGISTER 1 +#include "lest.hpp" + +#define LWS_CASE(name) \ + lest_CASE(lwsf_test::get_tests(), name) + +namespace lwsf_test +{ + lest::tests& get_tests(); +} diff --git a/tests/unit/lest.hpp b/tests/unit/lest.hpp new file mode 100644 index 0000000..ad09806 --- /dev/null +++ b/tests/unit/lest.hpp @@ -0,0 +1,1487 @@ +// Copyright 2013-2018 by Martin Moene +// +// lest is based on ideas by Kevlin Henney, see video at +// http://skillsmatter.com/podcast/agile-testing/kevlin-henney-rethinking-unit-testing-in-c-plus-plus +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + +#ifndef LEST_LEST_HPP_INCLUDED +#define LEST_LEST_HPP_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#define lest_MAJOR 1 +#define lest_MINOR 35 +#define lest_PATCH 1 + +#define lest_VERSION lest_STRINGIFY(lest_MAJOR) "." lest_STRINGIFY(lest_MINOR) "." lest_STRINGIFY(lest_PATCH) + +#ifndef lest_FEATURE_AUTO_REGISTER +# define lest_FEATURE_AUTO_REGISTER 0 +#endif + +#ifndef lest_FEATURE_COLOURISE +# define lest_FEATURE_COLOURISE 0 +#endif + +#ifndef lest_FEATURE_LITERAL_SUFFIX +# define lest_FEATURE_LITERAL_SUFFIX 0 +#endif + +#ifndef lest_FEATURE_REGEX_SEARCH +# define lest_FEATURE_REGEX_SEARCH 0 +#endif + +#ifndef lest_FEATURE_TIME_PRECISION +# define lest_FEATURE_TIME_PRECISION 0 +#endif + +#ifndef lest_FEATURE_WSTRING +# define lest_FEATURE_WSTRING 1 +#endif + +#ifdef lest_FEATURE_RTTI +# define lest__cpp_rtti lest_FEATURE_RTTI +#elif defined(__cpp_rtti) +# define lest__cpp_rtti __cpp_rtti +#elif defined(__GXX_RTTI) || defined (_CPPRTTI) +# define lest__cpp_rtti 1 +#else +# define lest__cpp_rtti 0 +#endif + +#if lest_FEATURE_REGEX_SEARCH +# include +#endif + +// Stringify: + +#define lest_STRINGIFY( x ) lest_STRINGIFY_( x ) +#define lest_STRINGIFY_( x ) #x + +// Compiler warning suppression: + +#if defined (__clang__) +# pragma clang diagnostic ignored "-Waggregate-return" +# pragma clang diagnostic ignored "-Woverloaded-shift-op-parentheses" +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wunused-comparison" +#elif defined (__GNUC__) +# pragma GCC diagnostic ignored "-Waggregate-return" +# pragma GCC diagnostic push +#endif + +// Suppress shadow and unused-value warning for sections: + +#if defined (__clang__) +# define lest_SUPPRESS_WSHADOW _Pragma( "clang diagnostic push" ) \ + _Pragma( "clang diagnostic ignored \"-Wshadow\"" ) +# define lest_SUPPRESS_WUNUSED _Pragma( "clang diagnostic push" ) \ + _Pragma( "clang diagnostic ignored \"-Wunused-value\"" ) +# define lest_RESTORE_WARNINGS _Pragma( "clang diagnostic pop" ) + +#elif defined (__GNUC__) +# define lest_SUPPRESS_WSHADOW _Pragma( "GCC diagnostic push" ) \ + _Pragma( "GCC diagnostic ignored \"-Wshadow\"" ) +# define lest_SUPPRESS_WUNUSED _Pragma( "GCC diagnostic push" ) \ + _Pragma( "GCC diagnostic ignored \"-Wunused-value\"" ) +# define lest_RESTORE_WARNINGS _Pragma( "GCC diagnostic pop" ) +#else +# define lest_SUPPRESS_WSHADOW /*empty*/ +# define lest_SUPPRESS_WUNUSED /*empty*/ +# define lest_RESTORE_WARNINGS /*empty*/ +#endif + +// C++ language version detection (C++23 is speculative): +// Note: VC14.0/1900 (VS2015) lacks too much from C++14. + +#ifndef lest_CPLUSPLUS +# if defined(_MSVC_LANG ) && !defined(__clang__) +# define lest_CPLUSPLUS (_MSC_VER == 1900 ? 201103L : _MSVC_LANG ) +# else +# define lest_CPLUSPLUS __cplusplus +# endif +#endif + +#define lest_CPP98_OR_GREATER ( lest_CPLUSPLUS >= 199711L ) +#define lest_CPP11_OR_GREATER ( lest_CPLUSPLUS >= 201103L ) +#define lest_CPP14_OR_GREATER ( lest_CPLUSPLUS >= 201402L ) +#define lest_CPP17_OR_GREATER ( lest_CPLUSPLUS >= 201703L ) +#define lest_CPP20_OR_GREATER ( lest_CPLUSPLUS >= 202002L ) +#define lest_CPP23_OR_GREATER ( lest_CPLUSPLUS >= 202300L ) + +#if ! defined( lest_NO_SHORT_MACRO_NAMES ) && ! defined( lest_NO_SHORT_ASSERTION_NAMES ) +# define MODULE lest_MODULE + +# if ! lest_FEATURE_AUTO_REGISTER +# define CASE lest_CASE +# define CASE_ON lest_CASE_ON +# define SCENARIO lest_SCENARIO +# endif + +# define SETUP lest_SETUP +# define SECTION lest_SECTION + +# define EXPECT lest_EXPECT +# define EXPECT_NOT lest_EXPECT_NOT +# define EXPECT_NO_THROW lest_EXPECT_NO_THROW +# define EXPECT_THROWS lest_EXPECT_THROWS +# define EXPECT_THROWS_AS lest_EXPECT_THROWS_AS + +# define GIVEN lest_GIVEN +# define WHEN lest_WHEN +# define THEN lest_THEN +# define AND_WHEN lest_AND_WHEN +# define AND_THEN lest_AND_THEN +#endif + +#if lest_FEATURE_AUTO_REGISTER +#define lest_SCENARIO( specification, sketch ) lest_CASE( specification, lest::text("Scenario: ") + sketch ) +#else +#define lest_SCENARIO( sketch ) lest_CASE( lest::text("Scenario: ") + sketch ) +#endif +#define lest_GIVEN( context ) lest_SETUP( lest::text(" Given: ") + context ) +#define lest_WHEN( story ) lest_SECTION( lest::text(" When: ") + story ) +#define lest_THEN( story ) lest_SECTION( lest::text(" Then: ") + story ) +#define lest_AND_WHEN( story ) lest_SECTION( lest::text("And then: ") + story ) +#define lest_AND_THEN( story ) lest_SECTION( lest::text("And then: ") + story ) + +#if lest_FEATURE_AUTO_REGISTER + +# define lest_CASE( specification, proposition ) \ + static void lest_FUNCTION( lest::env & ); \ + namespace { lest::add_test lest_REGISTRAR( specification, lest::test( proposition, lest_FUNCTION ) ); } \ + static void lest_FUNCTION( lest::env & lest_env ) + +#else // lest_FEATURE_AUTO_REGISTER + +# define lest_CASE( proposition ) \ + proposition, []( lest::env & lest_env ) + +# define lest_CASE_ON( proposition, ... ) \ + proposition, [__VA_ARGS__]( lest::env & lest_env ) + +# define lest_MODULE( specification, module ) \ + namespace { lest::add_module _( specification, module ); } + +#endif //lest_FEATURE_AUTO_REGISTER + +#define lest_SETUP( context ) \ + for ( int lest__section = 0, lest__count = 1; lest__section < lest__count; lest__count -= 0==lest__section++ ) \ + for ( lest::ctx lest__ctx_setup( lest_env, context ); lest__ctx_setup; ) + +#define lest_SECTION( proposition ) \ + lest_SUPPRESS_WSHADOW \ + static int lest_UNIQUE( id ) = 0; \ + if ( lest::guard( lest_UNIQUE( id ), lest__section, lest__count ) ) \ + for ( int lest__section = 0, lest__count = 1; lest__section < lest__count; lest__count -= 0==lest__section++ ) \ + for ( lest::ctx lest__ctx_section( lest_env, proposition ); lest__ctx_section; ) \ + lest_RESTORE_WARNINGS + +#define lest_EXPECT( expr ) \ + do { \ + try \ + { \ + if ( lest::result score = lest_DECOMPOSE( expr ) ) \ + throw lest::failure{ lest_LOCATION, #expr, score.decomposition }; \ + else if ( lest_env.pass() ) \ + lest::report( lest_env.os, lest::passing{ lest_LOCATION, #expr, score.decomposition, lest_env.zen() }, lest_env.context() ); \ + } \ + catch(...) \ + { \ + lest::inform( lest_LOCATION, #expr ); \ + } \ + } while ( lest::is_false() ) + +#define lest_EXPECT_NOT( expr ) \ + do { \ + try \ + { \ + if ( lest::result score = lest_DECOMPOSE( expr ) ) \ + { \ + if ( lest_env.pass() ) \ + lest::report( lest_env.os, lest::passing{ lest_LOCATION, lest::not_expr( #expr ), lest::not_expr( score.decomposition ), lest_env.zen() }, lest_env.context() ); \ + } \ + else \ + throw lest::failure{ lest_LOCATION, lest::not_expr( #expr ), lest::not_expr( score.decomposition ) }; \ + } \ + catch(...) \ + { \ + lest::inform( lest_LOCATION, lest::not_expr( #expr ) ); \ + } \ + } while ( lest::is_false() ) + +#define lest_EXPECT_NO_THROW( expr ) \ + do \ + { \ + try \ + { \ + lest_SUPPRESS_WUNUSED \ + expr; \ + lest_RESTORE_WARNINGS \ + } \ + catch (...) \ + { \ + lest::inform( lest_LOCATION, #expr ); \ + } \ + if ( lest_env.pass() ) \ + lest::report( lest_env.os, lest::got_none( lest_LOCATION, #expr ), lest_env.context() ); \ + } while ( lest::is_false() ) + +#define lest_EXPECT_THROWS( expr ) \ + do \ + { \ + try \ + { \ + lest_SUPPRESS_WUNUSED \ + expr; \ + lest_RESTORE_WARNINGS \ + } \ + catch (...) \ + { \ + if ( lest_env.pass() ) \ + lest::report( lest_env.os, lest::got{ lest_LOCATION, #expr }, lest_env.context() ); \ + break; \ + } \ + throw lest::expected{ lest_LOCATION, #expr }; \ + } \ + while ( lest::is_false() ) + +#define lest_EXPECT_THROWS_AS( expr, excpt ) \ + do \ + { \ + try \ + { \ + lest_SUPPRESS_WUNUSED \ + expr; \ + lest_RESTORE_WARNINGS \ + } \ + catch ( excpt & ) \ + { \ + if ( lest_env.pass() ) \ + lest::report( lest_env.os, lest::got{ lest_LOCATION, #expr, lest::of_type( #excpt ) }, lest_env.context() ); \ + break; \ + } \ + catch (...) {} \ + throw lest::expected{ lest_LOCATION, #expr, lest::of_type( #excpt ) }; \ + } \ + while ( lest::is_false() ) + +#define lest_UNIQUE( name ) lest_UNIQUE2( name, __LINE__ ) +#define lest_UNIQUE2( name, line ) lest_UNIQUE3( name, line ) +#define lest_UNIQUE3( name, line ) name ## line + +#define lest_DECOMPOSE( expr ) ( lest::expression_decomposer() << expr ) + +#define lest_FUNCTION lest_UNIQUE(__lest_function__ ) +#define lest_REGISTRAR lest_UNIQUE(__lest_registrar__ ) + +#define lest_LOCATION lest::location{__FILE__, __LINE__} + +namespace lest { + +const int exit_max_value = 255; + +using text = std::string; +using texts = std::vector; + +struct env; + +struct test +{ + text name; + std::function behaviour; + +#if lest_FEATURE_AUTO_REGISTER + test( text name_, std::function behaviour_ ) + : name( name_), behaviour( behaviour_) {} +#endif +}; + +using tests = std::vector; + +#if lest_FEATURE_AUTO_REGISTER + +struct add_test +{ + add_test( tests & specification, test const & test_case ) + { + specification.push_back( test_case ); + } +}; + +#else + +struct add_module +{ + template< std::size_t N > + add_module( tests & specification, test const (&module)[N] ) + { + specification.insert( specification.end(), std::begin( module ), std::end( module ) ); + } +}; + +#endif + +struct result +{ + const bool passed; + const text decomposition; + + template< typename T > + result( T const & passed_, text decomposition_) + : passed( !!passed_), decomposition( decomposition_) {} + + explicit operator bool() { return ! passed; } +}; + +struct location +{ + const text file; + const int line; + + location( text file_, int line_) + : file( file_), line( line_) {} +}; + +struct comment +{ + const text info; + + comment( text info_) : info( info_) {} + explicit operator bool() { return ! info.empty(); } +}; + +struct message : std::runtime_error +{ + const text kind; + const location where; + const comment note; + + ~message() throw() {} // GCC 4.6 + + message( text kind_, location where_, text expr_, text note_ = "" ) + : std::runtime_error( expr_), kind( kind_), where( where_), note( note_) {} +}; + +struct failure : message +{ + failure( location where_, text expr_, text decomposition_) + : message{ "failed", where_, expr_ + " for " + decomposition_ } {} +}; + +struct success : message +{ +// using message::message; // VC is lagging here + + success( text kind_, location where_, text expr_, text note_ = "" ) + : message( kind_, where_, expr_, note_ ) {} +}; + +struct passing : success +{ + passing( location where_, text expr_, text decomposition_, bool zen ) + : success( "passed", where_, expr_ + (zen ? "":" for " + decomposition_) ) {} +}; + +struct got_none : success +{ + got_none( location where_, text expr_ ) + : success( "passed: got no exception", where_, expr_ ) {} +}; + +struct got : success +{ + got( location where_, text expr_) + : success( "passed: got exception", where_, expr_) {} + + got( location where_, text expr_, text excpt_) + : success( "passed: got exception " + excpt_, where_, expr_) {} +}; + +struct expected : message +{ + expected( location where_, text expr_, text excpt_ = "" ) + : message{ "failed: didn't get exception", where_, expr_, excpt_ } {} +}; + +struct unexpected : message +{ + unexpected( location where_, text expr_, text note_ = "" ) + : message{ "failed: got unexpected exception", where_, expr_, note_ } {} +}; + +struct guard +{ + int & id; + int const & section; + + guard( int & id_, int const & section_, int & count ) + : id( id_), section( section_) + { + if ( section == 0 ) + id = count++ - 1; + } + operator bool() { return id == section; } +}; + +class approx +{ +public: + explicit approx ( double magnitude ) + : epsilon_ { std::numeric_limits::epsilon() * 100 } + , scale_ { 1.0 } + , magnitude_{ magnitude } {} + + approx( approx const & other ) = default; + + static approx custom() { return approx( 0 ); } + + approx operator()( double new_magnitude ) + { + approx appr( new_magnitude ); + appr.epsilon( epsilon_ ); + appr.scale ( scale_ ); + return appr; + } + + double magnitude() const { return magnitude_; } + + approx & epsilon( double epsilon ) { epsilon_ = epsilon; return *this; } + approx & scale ( double scale ) { scale_ = scale; return *this; } + + friend bool operator == ( double lhs, approx const & rhs ) + { + // Thanks to Richard Harris for his help refining this formula. + return std::abs( lhs - rhs.magnitude_ ) < rhs.epsilon_ * ( rhs.scale_ + (std::min)( std::abs( lhs ), std::abs( rhs.magnitude_ ) ) ); + } + + friend bool operator == ( approx const & lhs, double rhs ) { return operator==( rhs, lhs ); } + friend bool operator != ( double lhs, approx const & rhs ) { return !operator==( lhs, rhs ); } + friend bool operator != ( approx const & lhs, double rhs ) { return !operator==( rhs, lhs ); } + + friend bool operator <= ( double lhs, approx const & rhs ) { return lhs < rhs.magnitude_ || lhs == rhs; } + friend bool operator <= ( approx const & lhs, double rhs ) { return lhs.magnitude_ < rhs || lhs == rhs; } + friend bool operator >= ( double lhs, approx const & rhs ) { return lhs > rhs.magnitude_ || lhs == rhs; } + friend bool operator >= ( approx const & lhs, double rhs ) { return lhs.magnitude_ > rhs || lhs == rhs; } + +private: + double epsilon_; + double scale_; + double magnitude_; +}; + +inline bool is_false( ) { return false; } +inline bool is_true ( bool flag ) { return flag; } + +inline text not_expr( text message ) +{ + return "! ( " + message + " )"; +} + +inline text with_message( text message ) +{ + return "with message \"" + message + "\""; +} + +inline text of_type( text type ) +{ + return "of type " + type; +} + +inline void inform( location where, text expr ) +{ + try + { + throw; + } + catch( message const & ) + { + throw; + } + catch( std::exception const & e ) + { + throw unexpected{ where, expr, with_message( e.what() ) }; \ + } + catch(...) + { + throw unexpected{ where, expr, "of unknown type" }; \ + } +} + +// Expression decomposition: + +template< typename T > +auto make_value_string( T const & value ) -> std::string; + +template< typename T > +auto make_memory_string( T const & item ) -> std::string; + +#if lest_FEATURE_LITERAL_SUFFIX +inline char const * sfx( char const * txt ) { return txt; } +#else +inline char const * sfx( char const * ) { return ""; } +#endif + +inline std::string transformed( char chr ) +{ + struct Tr { char chr; char const * str; } table[] = + { + {'\\', "\\\\" }, + {'\r', "\\r" }, {'\f', "\\f" }, + {'\n', "\\n" }, {'\t', "\\t" }, + }; + + for ( auto tr : table ) + { + if ( chr == tr.chr ) + return tr.str; + } + + auto unprintable = [](char c){ return 0 <= c && c < ' '; }; + + auto to_hex_string = [](char c) + { + std::ostringstream os; + os << "\\x" << std::hex << std::setw(2) << std::setfill('0') << static_cast( static_cast(c) ); + return os.str(); + }; + + return unprintable( chr ) ? to_hex_string( chr ) : std::string( 1, chr ); +} + +inline std::string make_tran_string( std::string const & txt ) { std::ostringstream os; for(auto c:txt) os << transformed(c); return os.str(); } +inline std::string make_strg_string( std::string const & txt ) { return "\"" + make_tran_string( txt ) + "\"" ; } +inline std::string make_char_string( char chr ) { return "\'" + make_tran_string( std::string( 1, chr ) ) + "\'" ; } + +inline std::string to_string( std::nullptr_t ) { return "nullptr"; } +inline std::string to_string( std::string const & txt ) { return make_strg_string( txt ); } +#if lest_FEATURE_WSTRING +inline std::string to_string( std::wstring const & txt ) ; +#endif + +inline std::string to_string( char const * const txt ) { return txt ? make_strg_string( txt ) : "{null string}"; } +inline std::string to_string( char * const txt ) { return txt ? make_strg_string( txt ) : "{null string}"; } +#if lest_FEATURE_WSTRING +inline std::string to_string( wchar_t const * const txt ) { return txt ? to_string( std::wstring( txt ) ) : "{null string}"; } +inline std::string to_string( wchar_t * const txt ) { return txt ? to_string( std::wstring( txt ) ) : "{null string}"; } +#endif + +inline std::string to_string( bool flag ) { return flag ? "true" : "false"; } + +inline std::string to_string( signed short value ) { return make_value_string( value ) ; } +inline std::string to_string( unsigned short value ) { return make_value_string( value ) + sfx("u" ); } +inline std::string to_string( signed int value ) { return make_value_string( value ) ; } +inline std::string to_string( unsigned int value ) { return make_value_string( value ) + sfx("u" ); } +inline std::string to_string( signed long value ) { return make_value_string( value ) + sfx("l" ); } +inline std::string to_string( unsigned long value ) { return make_value_string( value ) + sfx("ul" ); } +inline std::string to_string( signed long long value ) { return make_value_string( value ) + sfx("ll" ); } +inline std::string to_string( unsigned long long value ) { return make_value_string( value ) + sfx("ull"); } +inline std::string to_string( double value ) { return make_value_string( value ) ; } +inline std::string to_string( float value ) { return make_value_string( value ) + sfx("f" ); } + +inline std::string to_string( signed char chr ) { return make_char_string( static_cast( chr ) ); } +inline std::string to_string( unsigned char chr ) { return make_char_string( static_cast( chr ) ); } +inline std::string to_string( char chr ) { return make_char_string( chr ); } + +template< typename T > +struct is_streamable +{ + template< typename U > + static auto test( int ) -> decltype( std::declval() << std::declval(), std::true_type() ); + + template< typename > + static auto test( ... ) -> std::false_type; + +#ifdef _MSC_VER + enum { value = std::is_same< decltype( test(0) ), std::true_type >::value }; +#else + static constexpr bool value = std::is_same< decltype( test(0) ), std::true_type >::value; +#endif +}; + +template< typename T > +struct is_container +{ + template< typename U > + static auto test( int ) -> decltype( std::declval().begin() == std::declval().end(), std::true_type() ); + + template< typename > + static auto test( ... ) -> std::false_type; + +#ifdef _MSC_VER + enum { value = std::is_same< decltype( test(0) ), std::true_type >::value }; +#else + static constexpr bool value = std::is_same< decltype( test(0) ), std::true_type >::value; +#endif +}; + +template< typename T, typename R > +using ForEnum = typename std::enable_if< std::is_enum::value, R>::type; + +template< typename T, typename R > +using ForNonEnum = typename std::enable_if< ! std::is_enum::value, R>::type; + +template< typename T, typename R > +using ForStreamable = typename std::enable_if< is_streamable::value, R>::type; + +template< typename T, typename R > +using ForNonStreamable = typename std::enable_if< ! is_streamable::value, R>::type; + +template< typename T, typename R > +using ForContainer = typename std::enable_if< is_container::value, R>::type; + +template< typename T, typename R > +using ForNonContainerNonPointer = typename std::enable_if< ! (is_container::value || std::is_pointer::value), R>::type; + +template< typename T > +auto to_string( T const & item ) -> ForNonContainerNonPointer; + +template< typename T > +auto make_enum_string( T const & item ) -> ForNonEnum +{ +#if lest__cpp_rtti + return text("[type: ") + typeid(T).name() + "]: " + make_memory_string( item ); +#else + return text("[type: (no RTTI)]: ") + make_memory_string( item ); +#endif +} + +template< typename T > +auto make_enum_string( T const & item ) -> ForEnum +{ + return to_string( static_cast::type>( item ) ); +} + +template< typename T > +auto make_string( T const & item ) -> ForNonStreamable +{ + return make_enum_string( item ); +} + +template< typename T > +auto make_string( T const & item ) -> ForStreamable +{ + std::ostringstream os; os << item; return os.str(); +} + +template +auto make_string( std::pair const & pair ) -> std::string +{ + std::ostringstream oss; + oss << "{ " << to_string( pair.first ) << ", " << to_string( pair.second ) << " }"; + return oss.str(); +} + +template< typename TU, std::size_t N > +struct make_tuple_string +{ + static std::string make( TU const & tuple ) + { + std::ostringstream os; + os << to_string( std::get( tuple ) ) << ( N < std::tuple_size::value ? ", ": " "); + return make_tuple_string::make( tuple ) + os.str(); + } +}; + +template< typename TU > +struct make_tuple_string +{ + static std::string make( TU const & ) { return ""; } +}; + +template< typename ...TS > +auto make_string( std::tuple const & tuple ) -> std::string +{ + return "{ " + make_tuple_string, sizeof...(TS)>::make( tuple ) + "}"; +} + +template< typename T > +inline std::string make_string( T const * ptr ) +{ + // Note showbase affects the behavior of /integer/ output; + std::ostringstream os; + os << std::internal << std::hex << std::showbase << std::setw( 2 + 2 * sizeof(T*) ) << std::setfill('0') << reinterpret_cast( ptr ); + return os.str(); +} + +template< typename C, typename R > +inline std::string make_string( R C::* ptr ) +{ + std::ostringstream os; + os << std::internal << std::hex << std::showbase << std::setw( 2 + 2 * sizeof(R C::* ) ) << std::setfill('0') << ptr; + return os.str(); +} + +template< typename T > +auto to_string( T const * ptr ) -> std::string +{ + return ! ptr ? "nullptr" : make_string( ptr ); +} + +template +auto to_string( R C::* ptr ) -> std::string +{ + return ! ptr ? "nullptr" : make_string( ptr ); +} + +template< typename T > +auto to_string( T const & item ) -> ForNonContainerNonPointer +{ + return make_string( item ); +} + +template< typename C > +auto to_string( C const & cont ) -> ForContainer +{ + std::ostringstream os; + os << "{ "; + for ( auto & x : cont ) + { + os << to_string( x ) << ", "; + } + os << "}"; + return os.str(); +} + +#if lest_FEATURE_WSTRING +inline +auto to_string( std::wstring const & txt ) -> std::string +{ + std::string result; result.reserve( txt.size() ); + + for( auto & chr : txt ) + { + result += chr <= 0xff ? static_cast( chr ) : '?'; + } + return to_string( result ); +} +#endif + +template< typename T > +auto make_value_string( T const & value ) -> std::string +{ + std::ostringstream os; os << value; return os.str(); +} + +inline +auto make_memory_string( void const * item, std::size_t size ) -> std::string +{ + // reverse order for little endian architectures: + + auto is_little_endian = [] + { + union U { int i = 1; char c[ sizeof(int) ]; }; + + return 1 != U{}.c[ sizeof(int) - 1 ]; + }; + + int i = 0, end = static_cast( size ), inc = 1; + + if ( is_little_endian() ) { i = end - 1; end = inc = -1; } + + unsigned char const * bytes = static_cast( item ); + + std::ostringstream os; + os << "0x" << std::setfill( '0' ) << std::hex; + for ( ; i != end; i += inc ) + { + os << std::setw(2) << static_cast( bytes[i] ) << " "; + } + return os.str(); +} + +template< typename T > +auto make_memory_string( T const & item ) -> std::string +{ + return make_memory_string( &item, sizeof item ); +} + +inline +auto to_string( approx const & appr ) -> std::string +{ + return to_string( appr.magnitude() ); +} + +template< typename L, typename R > +auto to_string( L const & lhs, std::string op, R const & rhs ) -> std::string +{ + std::ostringstream os; os << to_string( lhs ) << " " << op << " " << to_string( rhs ); return os.str(); +} + +template< typename L > +struct expression_lhs +{ + const L lhs; + + expression_lhs( L lhs_) : lhs( lhs_) {} + + operator result() { return result{ !!lhs, to_string( lhs ) }; } + + template< typename R > result operator==( R const & rhs ) { return result{ lhs == rhs, to_string( lhs, "==", rhs ) }; } + template< typename R > result operator!=( R const & rhs ) { return result{ lhs != rhs, to_string( lhs, "!=", rhs ) }; } + template< typename R > result operator< ( R const & rhs ) { return result{ lhs < rhs, to_string( lhs, "<" , rhs ) }; } + template< typename R > result operator<=( R const & rhs ) { return result{ lhs <= rhs, to_string( lhs, "<=", rhs ) }; } + template< typename R > result operator> ( R const & rhs ) { return result{ lhs > rhs, to_string( lhs, ">" , rhs ) }; } + template< typename R > result operator>=( R const & rhs ) { return result{ lhs >= rhs, to_string( lhs, ">=", rhs ) }; } +}; + +struct expression_decomposer +{ + template + expression_lhs operator<< ( L const & operand ) + { + return expression_lhs( operand ); + } +}; + +// Reporter: + +#if lest_FEATURE_COLOURISE + +inline text red ( text words ) { return "\033[1;31m" + words + "\033[0m"; } +inline text green( text words ) { return "\033[1;32m" + words + "\033[0m"; } +inline text gray ( text words ) { return "\033[1;30m" + words + "\033[0m"; } + +inline bool starts_with( text words, text with ) +{ + return 0 == words.find( with ); +} + +inline text replace( text words, text from, text to ) +{ + size_t pos = words.find( from ); + return pos == std::string::npos ? words : words.replace( pos, from.length(), to ); +} + +inline text colour( text words ) +{ + if ( starts_with( words, "failed" ) ) return replace( words, "failed", red ( "failed" ) ); + else if ( starts_with( words, "passed" ) ) return replace( words, "passed", green( "passed" ) ); + + return replace( words, "for", gray( "for" ) ); +} + +inline bool is_cout( std::ostream & os ) { return &os == &std::cout; } + +struct colourise +{ + const text words; + + colourise( text words ) + : words( words ) {} + + // only colourise for std::cout, not for a stringstream as used in tests: + + std::ostream & operator()( std::ostream & os ) const + { + return is_cout( os ) ? os << colour( words ) : os << words; + } +}; + +inline std::ostream & operator<<( std::ostream & os, colourise words ) { return words( os ); } +#else +inline text colourise( text words ) { return words; } +#endif + +inline text pluralise( text word, int n ) +{ + return n == 1 ? word : word + "s"; +} + +inline std::ostream & operator<<( std::ostream & os, comment note ) +{ + return os << (note ? " " + note.info : "" ); +} + +inline std::ostream & operator<<( std::ostream & os, location where ) +{ +#ifdef __GNUG__ + return os << where.file << ":" << where.line; +#else + return os << where.file << "(" << where.line << ")"; +#endif +} + +inline void report( std::ostream & os, message const & e, text test ) +{ + os << e.where << ": " << colourise( e.kind ) << e.note << ": " << test << ": " << colourise( e.what() ) << std::endl; +} + +// Test runner: + +#if lest_FEATURE_REGEX_SEARCH + inline bool search( text re, text line ) + { + return std::regex_search( line, std::regex( re ) ); + } +#else + inline bool search( text part, text line ) + { + auto case_insensitive_equal = []( char a, char b ) + { + return tolower( a ) == tolower( b ); + }; + + return std::search( + line.begin(), line.end(), + part.begin(), part.end(), case_insensitive_equal ) != line.end(); + } +#endif + +inline bool match( texts whats, text line ) +{ + for ( auto & what : whats ) + { + if ( search( what, line ) ) + return true; + } + return false; +} + +inline bool select( text name, texts include ) +{ + auto none = []( texts args ) { return args.size() == 0; }; + +#if lest_FEATURE_REGEX_SEARCH + auto hidden = []( text arg ){ return match( { "\\[\\..*", "\\[hide\\]" }, arg ); }; +#else + auto hidden = []( text arg ){ return match( { "[.", "[hide]" }, arg ); }; +#endif + + if ( none( include ) ) + { + return ! hidden( name ); + } + + bool any = false; + for ( auto pos = include.rbegin(); pos != include.rend(); ++pos ) + { + auto & part = *pos; + + if ( part == "@" || part == "*" ) + return true; + + if ( search( part, name ) ) + return true; + + if ( '!' == part[0] ) + { + any = true; + if ( search( part.substr(1), name ) ) + return false; + } + else + { + any = false; + } + } + return any && ! hidden( name ); +} + +inline int indefinite( int repeat ) { return repeat == -1; } + +using seed_t = std::mt19937::result_type; + +struct options +{ + bool help = false; + bool abort = false; + bool count = false; + bool list = false; + bool tags = false; + bool time = false; + bool pass = false; + bool zen = false; + bool lexical = false; + bool random = false; + bool verbose = false; + bool version = false; + int repeat = 1; + seed_t seed = 0; +}; + +struct env +{ + std::ostream & os; + options opt; + text testing; + std::vector< text > ctx; + + env( std::ostream & out, options option ) + : os( out ), opt( option ), testing(), ctx() {} + + env & operator()( text test ) + { + clear(); testing = test; return *this; + } + + bool abort() { return opt.abort; } + bool pass() { return opt.pass; } + bool zen() { return opt.zen; } + + void clear() { ctx.clear(); } + void pop() { ctx.pop_back(); } + void push( text proposition ) { ctx.emplace_back( proposition ); } + + text context() { return testing + sections(); } + + text sections() + { + if ( ! opt.verbose ) + return ""; + + text msg; + for( auto section : ctx ) + { + msg += "\n " + section; + } + return msg; + } +}; + +struct ctx +{ + env & environment; + bool once; + + ctx( env & environment_, text proposition_ ) + : environment( environment_), once( true ) + { + environment.push( proposition_); + } + + ~ctx() + { +#if lest_CPP17_OR_GREATER + if ( std::uncaught_exceptions() == 0 ) +#else + if ( ! std::uncaught_exception() ) +#endif + { + environment.pop(); + } + } + + explicit operator bool() { bool result = once; once = false; return result; } +}; + +struct action +{ + std::ostream & os; + + action( std::ostream & out ) : os( out ) {} + + action( action const & ) = delete; + void operator=( action const & ) = delete; + + operator int() { return 0; } + bool abort() { return false; } + action & operator()( test ) { return *this; } +}; + +struct print : action +{ + print( std::ostream & out ) : action( out ) {} + + print & operator()( test testing ) + { + os << testing.name << "\n"; return *this; + } +}; + +inline texts tags( text name, texts result = {} ) +{ + auto none = std::string::npos; + auto lb = name.find_first_of( "[" ); + auto rb = name.find_first_of( "]" ); + + if ( lb == none || rb == none ) + return result; + + result.emplace_back( name.substr( lb, rb - lb + 1 ) ); + + return tags( name.substr( rb + 1 ), result ); +} + +struct ptags : action +{ + std::set result; + + ptags( std::ostream & out ) : action( out ), result() {} + + ptags & operator()( test testing ) + { + for ( auto & tag : tags( testing.name ) ) + result.insert( tag ); + + return *this; + } + + ~ptags() + { + std::copy( result.begin(), result.end(), std::ostream_iterator( os, "\n" ) ); + } +}; + +struct count : action +{ + int n = 0; + + count( std::ostream & out ) : action( out ) {} + + count & operator()( test ) { ++n; return *this; } + + ~count() + { + os << n << " selected " << pluralise("test", n) << "\n"; + } +}; + +struct timer +{ + using time = std::chrono::high_resolution_clock; + + time::time_point start = time::now(); + + double elapsed_seconds() const + { + return 1e-6 * static_cast( std::chrono::duration_cast< std::chrono::microseconds >( time::now() - start ).count() ); + } +}; + +struct times : action +{ + env output; + int selected = 0; + int failures = 0; + + timer total; + + times( std::ostream & out, options option ) + : action( out ), output( out, option ), total() + { + os << std::setfill(' ') << std::fixed << std::setprecision( lest_FEATURE_TIME_PRECISION ); + } + + operator int() { return failures; } + + bool abort() { return output.abort() && failures > 0; } + + times & operator()( test testing ) + { + timer t; + + try + { + testing.behaviour( output( testing.name ) ); + } + catch( message const & ) + { + ++failures; + } + + os << std::setw(3) << ( 1000 * t.elapsed_seconds() ) << " ms: " << testing.name << "\n"; + + return *this; + } + + ~times() + { + os << "Elapsed time: " << std::setprecision(1) << total.elapsed_seconds() << " s\n"; + } +}; + +struct confirm : action +{ + env output; + int selected = 0; + int failures = 0; + + confirm( std::ostream & out, options option ) + : action( out ), output( out, option ) {} + + operator int() { return failures; } + + bool abort() { return output.abort() && failures > 0; } + + confirm & operator()( test testing ) + { + try + { + ++selected; testing.behaviour( output( testing.name ) ); + } + catch( message const & e ) + { + ++failures; report( os, e, output.context() ); + } + return *this; + } + + ~confirm() + { + if ( failures > 0 ) + { + os << failures << " out of " << selected << " selected " << pluralise("test", selected) << " " << colourise( "failed.\n" ); + } + else if ( output.pass() ) + { + os << "All " << selected << " selected " << pluralise("test", selected) << " " << colourise( "passed.\n" ); + } + } +}; + +template< typename Action > +bool abort( Action & perform ) +{ + return perform.abort(); +} + +template< typename Action > +Action && for_test( tests specification, texts in, Action && perform, int n = 1 ) +{ + for ( int i = 0; indefinite( n ) || i < n; ++i ) + { + for ( auto & testing : specification ) + { + if ( select( testing.name, in ) ) + if ( abort( perform( testing ) ) ) + return std::move( perform ); + } + } + return std::move( perform ); +} + +inline void sort( tests & specification ) +{ + auto test_less = []( test const & a, test const & b ) { return a.name < b.name; }; + std::sort( specification.begin(), specification.end(), test_less ); +} + +inline void shuffle( tests & specification, options option ) +{ + std::shuffle( specification.begin(), specification.end(), std::mt19937( option.seed ) ); +} + +// workaround MinGW bug, http://stackoverflow.com/a/16132279: + +inline int stoi( text num ) +{ + return static_cast( std::strtol( num.c_str(), nullptr, 10 ) ); +} + +inline bool is_number( text arg ) +{ + return std::all_of( arg.begin(), arg.end(), ::isdigit ); +} + +inline seed_t seed( text opt, text arg ) +{ + if ( is_number( arg ) ) + return static_cast( lest::stoi( arg ) ); + + if ( arg == "time" ) + return static_cast( std::chrono::high_resolution_clock::now().time_since_epoch().count() ); + + throw std::runtime_error( "expecting 'time' or positive number with option '" + opt + "', got '" + arg + "' (try option --help)" ); +} + +inline int repeat( text opt, text arg ) +{ + const int num = lest::stoi( arg ); + + if ( indefinite( num ) || num >= 0 ) + return num; + + throw std::runtime_error( "expecting '-1' or positive number with option '" + opt + "', got '" + arg + "' (try option --help)" ); +} + +inline auto split_option( text arg ) -> std::tuple +{ + auto pos = arg.rfind( '=' ); + + return pos == text::npos + ? std::make_tuple( arg, "" ) + : std::make_tuple( arg.substr( 0, pos ), arg.substr( pos + 1 ) ); +} + +inline auto split_arguments( texts args ) -> std::tuple +{ + options option; texts in; + + bool in_options = true; + + for ( auto & arg : args ) + { + if ( in_options ) + { + text opt, val; + std::tie( opt, val ) = split_option( arg ); + + if ( opt[0] != '-' ) { in_options = false; } + else if ( opt == "--" ) { in_options = false; continue; } + else if ( opt == "-h" || "--help" == opt ) { option.help = true; continue; } + else if ( opt == "-a" || "--abort" == opt ) { option.abort = true; continue; } + else if ( opt == "-c" || "--count" == opt ) { option.count = true; continue; } + else if ( opt == "-g" || "--list-tags" == opt ) { option.tags = true; continue; } + else if ( opt == "-l" || "--list-tests" == opt ) { option.list = true; continue; } + else if ( opt == "-t" || "--time" == opt ) { option.time = true; continue; } + else if ( opt == "-p" || "--pass" == opt ) { option.pass = true; continue; } + else if ( opt == "-z" || "--pass-zen" == opt ) { option.zen = true; continue; } + else if ( opt == "-v" || "--verbose" == opt ) { option.verbose = true; continue; } + else if ( "--version" == opt ) { option.version = true; continue; } + else if ( opt == "--order" && "declared" == val ) { /* by definition */ ; continue; } + else if ( opt == "--order" && "lexical" == val ) { option.lexical = true; continue; } + else if ( opt == "--order" && "random" == val ) { option.random = true; continue; } + else if ( opt == "--random-seed" ) { option.seed = seed ( "--random-seed", val ); continue; } + else if ( opt == "--repeat" ) { option.repeat = repeat( "--repeat" , val ); continue; } + else throw std::runtime_error( "unrecognised option '" + arg + "' (try option --help)" ); + } + in.push_back( arg ); + } + option.pass = option.pass || option.zen; + + return std::make_tuple( option, in ); +} + +inline int usage( std::ostream & os ) +{ + os << + "\nUsage: test [options] [test-spec ...]\n" + "\n" + "Options:\n" + " -h, --help this help message\n" + " -a, --abort abort at first failure\n" + " -c, --count count selected tests\n" + " -g, --list-tags list tags of selected tests\n" + " -l, --list-tests list selected tests\n" + " -p, --pass also report passing tests\n" + " -z, --pass-zen ... without expansion\n" + " -t, --time list duration of selected tests\n" + " -v, --verbose also report passing or failing sections\n" + " --order=declared use source code test order (default)\n" + " --order=lexical use lexical sort test order\n" + " --order=random use random test order\n" + " --random-seed=n use n for random generator seed\n" + " --random-seed=time use time for random generator seed\n" + " --repeat=n repeat selected tests n times (-1: indefinite)\n" + " --version report lest version and compiler used\n" + " -- end options\n" + "\n" + "Test specification:\n" + " \"@\", \"*\" all tests, unless excluded\n" + " empty all tests, unless tagged [hide] or [.optional-name]\n" +#if lest_FEATURE_REGEX_SEARCH + " \"re\" select tests that match regular expression\n" + " \"!re\" omit tests that match regular expression\n" +#else + " \"text\" select tests that contain text (case insensitive)\n" + " \"!text\" omit tests that contain text (case insensitive)\n" +#endif + ; + return 0; +} + +inline text compiler() +{ + std::ostringstream os; +#if defined (__clang__ ) + os << "clang " << __clang_version__; +#elif defined (__GNUC__ ) + os << "gcc " << __GNUC__ << "." << __GNUC_MINOR__ << "." << __GNUC_PATCHLEVEL__; +#elif defined ( _MSC_VER ) + os << "MSVC " << (_MSC_VER / 100 - 5 - (_MSC_VER < 1900)) << " (" << _MSC_VER << ")"; +#else + os << "[compiler]"; +#endif + return os.str(); +} + +inline int version( std::ostream & os ) +{ + os << "lest version " << lest_VERSION << "\n" + << "Compiled with " << compiler() << " on " << __DATE__ << " at " << __TIME__ << ".\n" + << "For more information, see https://github.com/martinmoene/lest.\n"; + return 0; +} + +inline int run( tests specification, texts arguments, std::ostream & os = std::cout ) +{ + try + { + options option; texts in; + std::tie( option, in ) = split_arguments( arguments ); + + if ( option.lexical ) { sort( specification ); } + if ( option.random ) { shuffle( specification, option ); } + + if ( option.help ) { return usage ( os ); } + if ( option.version ) { return version ( os ); } + if ( option.count ) { return for_test( specification, in, count( os ) ); } + if ( option.list ) { return for_test( specification, in, print( os ) ); } + if ( option.tags ) { return for_test( specification, in, ptags( os ) ); } + if ( option.time ) { return for_test( specification, in, times( os, option ) ); } + + return for_test( specification, in, confirm( os, option ), option.repeat ); + } + catch ( std::exception const & e ) + { + os << "Error: " << e.what() << "\n"; + return 1; + } +} + +inline int run( tests specification, int argc, char * argv[], std::ostream & os = std::cout ) +{ + return run( specification, texts( argv + 1, argv + argc ), os ); +} + +template< std::size_t N > +int run( test const (&specification)[N], texts arguments, std::ostream & os = std::cout ) +{ + std::cout.sync_with_stdio( false ); + return (std::min)( run( tests( specification, specification + N ), arguments, os ), exit_max_value ); +} + +template< std::size_t N > +int run( test const (&specification)[N], std::ostream & os = std::cout ) +{ + return run( tests( specification, specification + N ), {}, os ); +} + +template< std::size_t N > +int run( test const (&specification)[N], int argc, char * argv[], std::ostream & os = std::cout ) +{ + return run( tests( specification, specification + N ), texts( argv + 1, argv + argc ), os ); +} + +} // namespace lest + +#if defined (__clang__) +# pragma clang diagnostic pop +#elif defined (__GNUC__) +# pragma GCC diagnostic pop +#endif + +#endif // LEST_LEST_HPP_INCLUDED diff --git a/tests/unit/main.cpp b/tests/unit/main.cpp new file mode 100644 index 0000000..9d69633 --- /dev/null +++ b/tests/unit/main.cpp @@ -0,0 +1,33 @@ +// Copyright (c) 2025, The Monero Project +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "framework.test.h" + +int main(int argc, char* argv[]) +{ + return lest::run(lwsf_test::get_tests(), argc, argv); +} diff --git a/tests/unit/rpc.test.cpp b/tests/unit/rpc.test.cpp new file mode 100644 index 0000000..b857aaf --- /dev/null +++ b/tests/unit/rpc.test.cpp @@ -0,0 +1,159 @@ +// Copyright (c) 2025, The Monero Project +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#include "framework.test.h" +#include "rpc.h" + +LWS_CASE("rpc::subaddrs") +{ + SETUP("empty subaddrs") + { + lwsf::internal::rpc::subaddrs subaddr{}; + EXPECT(subaddr.value.empty()); + EXPECT(subaddr.is_valid()); + + SECTION("constructor") + { + subaddr = lwsf::internal::rpc::subaddrs{10}; + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 1); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 10); + } + + SECTION("is_valid") + { + subaddr.value = {{0, 0}}; + EXPECT(subaddr.is_valid()); + + subaddr.value = {{0, 1}, {2, 3}}; + EXPECT(subaddr.is_valid()); + + subaddr.value = {{1, 0}}; + EXPECT(!subaddr.is_valid()); + + subaddr.value = {{0, 1}, {1, 3}}; + EXPECT(!subaddr.is_valid()); + } + + SECTION("merge from empty") + { + subaddr.merge(100); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 1); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 100); + } + + SECTION("merge from one item prepend") + { + subaddr.value = {{10, 12}}; + subaddr.merge(9); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 2); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 9); + EXPECT(std::get<0>(*subaddr.value.nth(1)) == 10); + EXPECT(std::get<1>(*subaddr.value.nth(1)) == 12); + } + + SECTION("merge from one item front") + { + subaddr.value = {{10, 12}}; + subaddr.merge(10); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 1); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 12); + } + + SECTION("merge from one item middle") + { + subaddr.value = {{10, 12}}; + subaddr.merge(11); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 1); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 12); + } + + SECTION("merge from one item end") + { + subaddr.value = {{10, 12}}; + subaddr.merge(12); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 1); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 12); + } + + SECTION("merge from two item split directly") + { + subaddr.value = {{10, 12}, {14, 15}}; + subaddr.merge(13); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 2); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 13); + EXPECT(std::get<0>(*subaddr.value.nth(1)) == 14); + EXPECT(std::get<1>(*subaddr.value.nth(1)) == 15); + } + + SECTION("merge from two item split gap") + { + subaddr.value = {{10, 12}, {16, 17}}; + subaddr.merge(14); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 2); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 14); + EXPECT(std::get<0>(*subaddr.value.nth(1)) == 16); + EXPECT(std::get<1>(*subaddr.value.nth(1)) == 17); + } + + SECTION("merge from two item last") + { + subaddr.value = {{10, 12}, {14, 15}}; + subaddr.merge(14); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 1); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 15); + } + + SECTION("merge from two item append") + { + subaddr.value = {{10, 12}, {14, 15}}; + subaddr.merge(16); + EXPECT(subaddr.is_valid()); + EXPECT(subaddr.value.size() == 1); + EXPECT(std::get<0>(*subaddr.value.nth(0)) == 0); + EXPECT(std::get<1>(*subaddr.value.nth(0)) == 16); + } + } +} +