// 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" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "hex.h" // monero/contrib/include #include "rapidjson/document.h" // monero/external/rapidjson/include #include "rapidjson/stringbuffer.h" // monero/external/rapidjson/incldue #include "rapidjson/writer.h" // monero/external/rapidjson/incldue #include "ringct/rctOps.h" // monero/src namespace rapidjson { std::ostream& operator<<(std::ostream& out, const Document& src) { StringBuffer buffer; Writer writer{buffer}; src.Accept(writer); return out << buffer.GetString(); } } namespace { using client_request = boost::beast::http::request; template std::string to_hex(const T& arg) { return epee::to_hex::string(epee::as_byte_span(arg)); } template T from_hex(const boost::string_ref hex) { T out{}; if (!epee::from_hex::to_buffer(epee::as_mut_byte_span(out), hex)) throw std::runtime_error{"invalid hex"}; return out; } struct empty_slice { operator epee::byte_slice() const { return {}; } }; struct empty_decoys { operator lwsf::internal::rpc::get_random_outs_request() const { return {}; } }; struct response_loop : boost::asio::coroutine { struct frame { lest::env& lest_env; boost::asio::ip::tcp::socket server; boost::beast::flat_buffer buffer; boost::optional request; boost::beast::http::response response; std::deque> requests; std::deque> responses; explicit frame(lest::env& lest_env, boost::asio::io_context& io) : lest_env(lest_env), server(io), buffer(), request(), response(), requests(), responses() {} }; std::shared_ptr self; explicit response_loop(std::shared_ptr in) : boost::asio::coroutine(), self(in) {} void operator()(boost::system::error_code = {}, std::size_t = {}) { auto& lest_env = self->lest_env; LWSF_VERIFY(self); BOOST_ASIO_CORO_REENTER(*this) { while (!self->responses.empty()) { self->request.emplace(); BOOST_ASIO_CORO_YIELD boost::beast::http::async_read(self->server, self->buffer, *self->request, *this); EXPECT(self->request->keep_alive()); EXPECT(self->requests.size() == self->responses.size()); EXPECT(self->requests.front().first == self->request->target()); if (!self->requests.front().second.empty()) { rapidjson::Document expected; expected.Parse(self->requests.front().second.c_str()); EXPECT(!expected.HasParseError()); rapidjson::Document actual; actual.Parse(self->request->body().c_str()); EXPECT(!expected.HasParseError()); EXPECT(expected == actual); } self->requests.pop_front(); { LWSF_VERIFY(!self->responses.empty()); const auto string = boost::get(std::addressof(self->responses.front())); if (string) self->response = {boost::beast::http::status::ok, 11, *string}; else self->response = {boost::get(self->responses.front()), 11, ""}; } self->response.keep_alive(true); self->response.prepare_payload(); BOOST_ASIO_CORO_YIELD boost::beast::http::async_write(self->server, self->response, *this); self->responses.pop_front(); } } } }; std::string get_response(const boost::string_view target) { static constexpr const char get_address_txs[] = R"({ "total_received": "5000", "scanned_height": 150, "scanned_block_height": 150, "start_height": 10, "blockchain_height": 200, "transactions": [] })"; static constexpr const char get_unspent_outs[] = R"({"per_byte_fee": 5, "fee_mask": 100, "amount": "200", "outputs": []})"; static constexpr const char import_wallet_request[] = R"({"lookahead": {"maj_i": 50, "min_i": 200}, "status": "OK", "new_request": true, "request_fulfilled": true})"; static constexpr const std::array, 10> endpoints = {{ {"/get_address_txs", get_address_txs}, {"/get_random_outs", R"({"amount_outs": []})"}, {"/get_subaddrs", R"({"all_subaddrs": []})"}, {"/get_unspent_outs", get_unspent_outs}, {"/get_version", R"({"max_subaddresses": 10000})"}, {"/import_wallet_request", import_wallet_request}, {"/login", R"({"new_address": true})"}, {"/provision_subaddrs", R"({"new_subaddrs": [], "all_subaddrs": []})"}, {"/submit_raw_tx", R"({"status": "OK"})"}, {"/upsert_subaddrs", "{}"} }}; const auto found = std::lower_bound(endpoints.begin(), endpoints.end(), std::make_pair(target, boost::string_view{})); if (found != endpoints.end() && found->first == target) return std::string{found->second}; throw std::runtime_error{"Unexpected target: " + std::string{target}}; } struct dummy_server : boost::asio::coroutine { struct frame { boost::asio::ip::tcp::socket server; boost::beast::flat_buffer buffer; boost::optional request; boost::beast::http::response response; std::mt19937 eng; std::uniform_int_distribution dist; explicit frame(boost::asio::io_context& io) : server(io), buffer(), request(), response(), eng(crypto::rand()), dist(0, 99) {} }; std::shared_ptr self; explicit dummy_server(std::shared_ptr self) : boost::asio::coroutine(), self(std::move(self)) {} void operator()(const boost::system::error_code error, std::size_t = {}) { if (error) { if (error != boost::asio::error::operation_aborted) throw boost::system::system_error{error, "server failure"}; return; } LWSF_VERIFY(self); BOOST_ASIO_CORO_REENTER(*this) { while (true) { self->request.emplace(); BOOST_ASIO_CORO_YIELD boost::beast::http::async_read(self->server, self->buffer, *self->request, *this); LWSF_VERIFY(self->request->keep_alive()); if (self->dist(self->eng)) self->response = {boost::beast::http::status::ok, 11, get_response(self->request->target())}; else self->response = {boost::beast::http::status::internal_server_error, 11, ""}; self->response.keep_alive(true); self->response.prepare_payload(); BOOST_ASIO_CORO_YIELD boost::beast::http::async_write(self->server, self->response, *this); } } } }; } 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); } } } LWS_CASE("backend::wallet") { using bwallet = lwsf::internal::backend::wallet; using tcp = boost::asio::ip::tcp; const auto no_ssl = epee::net_utils::ssl_support_t::e_ssl_support_disabled; SETUP("base empty wallet and basic server") { boost::asio::io_context io; const auto wallet = std::make_shared(io); crypto::generate_keys(wallet->primary.view.pub, wallet->primary.view.sec); crypto::generate_keys(wallet->primary.spend.pub, wallet->primary.spend.sec); wallet->primary.address = wallet->get_spend_address({0, 0}); tcp::acceptor acceptor{io, tcp::endpoint(tcp::v4(), 0)}; acceptor.listen(); SECTION("uninitialized http client") { using namespace std::placeholders; const std::array)>, std::string>, 5> funcs1 {{ {std::bind(bwallet::refresh, wallet, true, _1), "refresh"}, {std::bind(bwallet::register_subaccount, wallet, 0, _1), "register_subaccount"}, {std::bind(bwallet::register_subaddress, wallet, 0, 0, _1), "register_subaddress"}, {std::bind(bwallet::set_lookahead, wallet, 0, 0, _1), "set_lookahead"}, {std::bind(bwallet::send_tx, wallet, empty_slice{}, _1), "send_tx"} }}; for (const auto& func : funcs1) { std::error_code actual{}; func.first([&] (auto result) { actual = result; }); io.restart(); io.run(); SECTION(func.second) EXPECT(actual == common_error::kInvalidArgument); } std::error_code actual = {}; const auto func = [&](auto result) { actual = result.error(); }; bwallet::login_is_new(wallet, func); io.restart(); io.run(); EXPECT(actual == common_error::kInvalidArgument); bwallet::restore_height_raw(wallet, 0, func); io.restart(); io.run(); EXPECT(actual == common_error::kInvalidArgument); actual = {}; bwallet::get_decoys(wallet, lwsf::internal::rpc::get_random_outs_request{}, func); io.restart(); io.run(); EXPECT(actual == common_error::kInvalidArgument); } SECTION("login_is_new") { const auto frame = std::make_shared(lest_env, io); wallet->client.init(io, "127.0.0.1", "", acceptor.local_endpoint().port(), no_ssl); bool done = false; expect result = false; const auto record_response = [&] (expect val) { done = true; result = val; }; bwallet::login_is_new(wallet, record_response); wallet->passed_login = false; acceptor.async_accept(frame->server, response_loop{frame}); SECTION("new address -> true") { frame->requests = {{"/login", ""}, {"/upsert_subaddrs", ""}}; frame->responses = {R"({"new_address": true})", ""}; while (!done) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(result); EXPECT(*result); EXPECT(wallet->passed_login); EXPECT(wallet->primary.restore_height == 0); EXPECT(wallet->server_lookahead.major == 0); EXPECT(wallet->server_lookahead.minor == 0); } SECTION("new address -> true with queued") { frame->requests = {{"/login", ""}, {"/upsert_subaddrs", ""}}; frame->responses = {R"({"new_address": true})", ""}; bool done2 = true; expect result2 = false; bwallet::login_is_new(wallet, [&](auto val) { done2 = true; result2 = val; }); while (!done || !done2) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(result); EXPECT(*result); EXPECT(result2); EXPECT(*result2); EXPECT(wallet->passed_login); EXPECT(wallet->primary.restore_height == 0); EXPECT(wallet->server_lookahead.major == 0); EXPECT(wallet->server_lookahead.minor == 0); } SECTION("new address -> false + start_height") { frame->requests = {{"/login", ""}, {"/upsert_subaddrs", ""}}; frame->responses = {R"({"new_address": false, "start_height": 100})", ""}; while (!done) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(result); EXPECT(!*result); EXPECT(wallet->passed_login); EXPECT(wallet->primary.restore_height == 100); EXPECT(wallet->server_lookahead.major == 0); EXPECT(wallet->server_lookahead.minor == 0); wallet->primary.restore_height = 0; } SECTION("new address -> true with lookahead") { frame->requests = {{"/login", ""}}; frame->responses = { R"({"new_address": true, "lookahead": {"maj_i": 100, "min_i": 50}})" }; while (!done) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(result); EXPECT(*result); EXPECT(wallet->passed_login); EXPECT(wallet->primary.restore_height == 0); EXPECT(wallet->server_lookahead.major == 100); EXPECT(wallet->server_lookahead.minor == 50); } SECTION("address is disabled") { frame->requests = {{"/login", ""}}; frame->responses = {boost::beast::http::status::forbidden}; while (!done) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(result == lwsf::error::approval); EXPECT(!wallet->passed_login); } SECTION("404 (bad server entirely)") { frame->requests = {{"/login", ""}, {"/login", ""}}; frame->responses = { boost::beast::http::status::not_found, boost::beast::http::status::not_found }; while (!done) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(!result); EXPECT(result == lwsf::internal::http::error(404)); EXPECT(!wallet->passed_login); } } SECTION("refresh") { const auto frame = std::make_shared(lest_env, io); wallet->client.init(io, "127.0.0.1", "", acceptor.local_endpoint().port(), no_ssl); bool done = false; std::error_code result{}; const auto record_response = [&] (auto val) { done = true; result = val; }; bwallet::refresh(wallet, true, record_response); wallet->passed_login = false; acceptor.async_accept(frame->server, response_loop{frame}); SECTION("account is disabled") { frame->requests = {{"/login", ""}}; frame->responses = {boost::beast::http::status::forbidden}; while (!done) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(result == lwsf::error::approval); EXPECT(!wallet->passed_login); EXPECT(wallet->refresh_error == lwsf::error::approval); } SECTION("get_address_txs unexpectedly fails with queued") { frame->requests = { {"/login", ""}, {"/upsert_subaddrs", ""}, {"/get_address_txs", ""} }; frame->responses = { R"({"new_address": true})", "", boost::beast::http::status::forbidden }; bool done2 = false; std::error_code result2{}; bwallet::refresh(wallet, true, [&] (auto val) { done2 = true; result2 = val; }); while (!done || !done2) { io.restart(); io.run_one(); } EXPECT(frame->responses.size() == 0); EXPECT(result == lwsf::error::approval); EXPECT(result2 == lwsf::error::approval); EXPECT(!wallet->passed_login); EXPECT(wallet->refresh_error == lwsf::error::approval); } SECTION("0.3 server") { frame->requests = { {"/login", ""}, {"/upsert_subaddrs", ""}, {"/get_address_txs", ""}, {"/get_unspent_outs", ""}, {"/get_version", ""} }; frame->responses = { R"({"new_address": true})", "", R"({ "total_received": "5000", "scanned_height": 150, "scanned_block_height": 150, "start_height": 10, "blockchain_height": 200, "transactions": [{ "id": 1, "hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "total_received": "5000", "total_sent": "10000", "unlock_time": 0, "spent_outputs": [{ "amount": "10000", "key_image": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee2", "tx_pub_key": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee3", "out_index": 0, "mixin": 15 }], "coinbase": false, "mempool": true, "mixin": 15 }] })", R"({ "per_byte_fee": 5, "fee_mask": 100, "amount": "200", "outputs": [{ "amount": "5000", "index": 5, "global_index": "50000", "rct": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee4", "tx_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "tx_prefix_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee5", "public_key": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee6", "tx_pub_key": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee3", "spend_key_images": ["deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee2"], "timestamp": "2026-01-01T00:00:00Z", "height": 0 }] })", boost::beast::http::status::not_found }; while (!done) { io.restart(); io.run_one(); } const lwsf::internal::rpc::address_meta recipient{0, 0}; EXPECT(frame->responses.size() == 0); EXPECT(result == lwsf::error::subaddr_upgrade); EXPECT(wallet->passed_login); EXPECT(wallet->refresh_error == lwsf::error::subaddr_upgrade); EXPECT(wallet->lookahead_error == lwsf::error::subaddr_upgrade); EXPECT(wallet->primary.restore_height == 10); EXPECT(wallet->primary.scan_height == 150); EXPECT(wallet->primary.requested_start == 10); EXPECT(wallet->blockchain_height == 200); EXPECT(wallet->fee_mask == 100); EXPECT(wallet->per_byte_fee.size() == 1); EXPECT(wallet->per_byte_fee.at(0) == 5); EXPECT(wallet->primary.txes.size() == 1); auto tx = wallet->primary.txes.find(from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1")); EXPECT(tx != wallet->primary.txes.end()); EXPECT(tx->second != nullptr); EXPECT(tx->second->spends.size() == 0); EXPECT(tx->second->receives.size() == 1); EXPECT(tx->second->receives.nth(0)->first == from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee6")); EXPECT(tx->second->receives.nth(0)->second.tx_pub == from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee3")); EXPECT(tx->second->receives.nth(0)->second.amount == 5000); EXPECT(tx->second->receives.nth(0)->second.recipient == recipient); EXPECT(tx->second->receives.nth(0)->second.index == 5); EXPECT(tx->second->receives.nth(0)->second.global_index == 50000); EXPECT(bool(tx->second->receives.nth(0)->second.rct_mask)); EXPECT(wallet->primary.subaccounts.size() == 1); EXPECT(wallet->primary.subaccounts.at(0).last == 0); } SECTION("1.0 server with good lookahead") { frame->requests = { {"/login", ""}, {"/get_address_txs", ""}, {"/get_unspent_outs", ""}, }; frame->responses = { R"({"new_address": true, "lookahead": {"maj_i": 50, "min_i": 200}})", R"({ "total_received": "4000", "scanned_height": 175, "scanned_block_height": 175, "start_height": 10, "blockchain_height": 210, "lookahead": {"maj_i": 50, "min_i": 200}, "transactions": [{ "id": 1, "hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "total_received": "4000", "total_sent": "10000", "unlock_time": 0, "spent_outputs": [{ "amount": "10000", "key_image": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee2", "tx_pub_key": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee3", "out_index": 0, "mixin": 15 }], "coinbase": false, "mempool": true, "mixin": 15 }] })", R"({ "per_byte_fee": 5, "fee_mask": 100, "fees": [110, 500, 700], "amount": "4000", "outputs": [{ "amount": "4000", "index": 0, "global_index": "60000", "rct": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee4", "tx_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "tx_prefix_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee5", "public_key": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee6", "tx_pub_key": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee3", "spend_key_images": ["deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee2"], "timestamp": "2026-01-01T00:00:00Z", "height": 0, "recipient": {"maj_i": 1, "min_i": 2} }] })" }; while (!done) { io.restart(); io.run_one(); } const lwsf::internal::rpc::address_meta recipient{1, 2}; EXPECT(frame->responses.size() == 0); EXPECT(result == std::error_code{}); EXPECT(wallet->passed_login); EXPECT(wallet->refresh_error == std::error_code{}); EXPECT(wallet->lookahead_error == std::error_code{}); EXPECT(wallet->subaddress_error == std::error_code{}); EXPECT(wallet->import_error == std::error_code{}); EXPECT(wallet->primary.restore_height == 10); EXPECT(wallet->primary.scan_height == 175); EXPECT(wallet->primary.requested_start == 10); EXPECT(wallet->blockchain_height == 210); EXPECT(wallet->fee_mask == 100); EXPECT(wallet->per_byte_fee.size() == 3); EXPECT(wallet->per_byte_fee.at(0) == 110); EXPECT(wallet->per_byte_fee.at(1) == 500); EXPECT(wallet->per_byte_fee.at(2) == 700); EXPECT(wallet->primary.txes.size() == 1); auto tx = wallet->primary.txes.find(from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1")); EXPECT(tx != wallet->primary.txes.end()); EXPECT(tx->second != nullptr); EXPECT(tx->second->spends.size() == 0); EXPECT(tx->second->receives.size() == 1); EXPECT(tx->second->receives.nth(0)->first == from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee6")); EXPECT(tx->second->receives.nth(0)->second.tx_pub == from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee3")); EXPECT(tx->second->receives.nth(0)->second.amount == 4000); EXPECT(tx->second->receives.nth(0)->second.recipient == recipient); EXPECT(tx->second->receives.nth(0)->second.index == 0); EXPECT(tx->second->receives.nth(0)->second.global_index == 60000); EXPECT(bool(tx->second->receives.nth(0)->second.rct_mask)); EXPECT(wallet->primary.subaccounts.size() == 2); EXPECT(wallet->primary.subaccounts.at(0).last == 0); EXPECT(wallet->primary.subaccounts.at(1).last == 2); } SECTION("1.0 server with lookahead fail and limited max subaddresses") { std::string subs; for (unsigned i = 0; i < 50; ++i) subs += R"({"key":)" + std::to_string(i) + R"(, "value": [[0, 199]]},)"; subs.pop_back(); const std::string upsert_body = R"({"address":")" + wallet->get_spend_address({0, 0}) + R"(","view_key":")" + to_hex(unwrap(unwrap(wallet->primary.view.sec))) + R"(","subaddrs":[{"key":1,"value":[[0,2]]}],"get_all":false})"; rct::key ringct{}; crypto::key_image image{}; lwsf::internal::backend::keypair tx_key{}; lwsf::internal::backend::keypair output_key{}; crypto::generate_keys(tx_key.pub, tx_key.sec); { crypto::secret_key scalar{}; crypto::key_derivation derived{}; EXPECT(crypto::generate_key_derivation(tx_key.pub, wallet->primary.view.sec, derived)); EXPECT(crypto::derive_public_key(derived, 3, wallet->primary.spend.pub, output_key.pub)); crypto::derive_secret_key(derived, 3, wallet->primary.spend.sec, output_key.sec); crypto::generate_key_image(output_key.pub, output_key.sec, image); crypto::derivation_to_scalar(derived, 3, scalar); rct::ecdhTuple commitment{}; rct::ecdhDecode(commitment, rct::sk2rct(scalar), true); ringct = commitment.mask; } EXPECT(wallet->primary.subaccounts.size() == 2); // from last section frame->requests = { {"/login", ""}, {"/upsert_subaddrs", upsert_body}, {"/get_address_txs", ""}, {"/get_unspent_outs", ""}, {"/get_version", ""}, {"/get_subaddrs", ""} }; frame->responses = { R"({"new_address": true, "lookahead": {"maj_i": 50, "min_i": 200}})", "", R"({ "total_received": "4000", "scanned_height": 175, "scanned_block_height": 175, "start_height": 5, "blockchain_height": 210, "lookahead": {"maj_i": 50, "min_i": 200}, "lookahead_fail": 150, "transactions": [{ "id": 1, "hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "total_received": "4000", "total_sent": "10000", "unlock_time": 0, "spent_outputs": [{ "amount": "10000", "key_image": ")" + to_hex(image) + R"(", "tx_pub_key": ")" + to_hex(tx_key.pub) + R"(", "out_index": 3, "mixin": 15 }], "coinbase": false, "mempool": true, "mixin": 15 }] })", R"({ "per_byte_fee": 5, "fee_mask": 100, "fees": [110, 500, 700], "amount": "4000", "lookahead_fail": 150, "outputs": [{ "amount": "4000", "index": 3, "global_index": "60000", "rct": "recomputed", "tx_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "tx_prefix_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee5", "public_key": ")" + to_hex(output_key.pub) + R"(", "tx_pub_key": ")" + to_hex(tx_key.pub) + R"(", "spend_key_images": [")" + to_hex(image) + R"("], "timestamp": "2026-01-01T00:00:00Z", "height": 150, "recipient": {"maj_i": 1, "min_i": 2} }] })", R"({"max_subaddresses": 10000})", R"({"all_subaddrs": [)" + subs + "]}" }; while (!done) { io.restart(); io.run_one(); } const lwsf::internal::rpc::address_meta sender{0, 0}; const lwsf::internal::rpc::address_meta recipient{1, 2}; EXPECT(frame->responses.size() == 0); EXPECT(result == lwsf::error::subaddr_ahead); EXPECT(wallet->passed_login); EXPECT(wallet->refresh_error == lwsf::error::subaddr_ahead); EXPECT(wallet->lookahead_error == lwsf::error::subaddr_ahead); EXPECT(wallet->subaddress_error == std::error_code{}); EXPECT(wallet->import_error == std::error_code{}); EXPECT(wallet->primary.restore_height == 5); EXPECT(wallet->primary.scan_height == 175); EXPECT(wallet->primary.requested_start == 5); EXPECT(wallet->blockchain_height == 210); EXPECT(wallet->fee_mask == 100); EXPECT(wallet->per_byte_fee.size() == 3); EXPECT(wallet->per_byte_fee.at(0) == 110); EXPECT(wallet->per_byte_fee.at(1) == 500); EXPECT(wallet->per_byte_fee.at(2) == 700); EXPECT(wallet->primary.txes.size() == 1); auto tx = wallet->primary.txes.find(from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1")); EXPECT(tx != wallet->primary.txes.end()); EXPECT(tx->second != nullptr); EXPECT(tx->second->spends.size() == 1); EXPECT(tx->second->spends.nth(0)->first == image); EXPECT(tx->second->spends.nth(0)->second.amount == 10000); EXPECT(tx->second->spends.nth(0)->second.sender == sender); EXPECT(tx->second->spends.nth(0)->second.tx_pub == tx_key.pub); EXPECT(tx->second->spends.nth(0)->second.output_pub == output_key.pub); EXPECT(tx->second->receives.size() == 1); EXPECT(tx->second->receives.nth(0)->first == output_key.pub); EXPECT(tx->second->receives.nth(0)->second.tx_pub == tx_key.pub); EXPECT(tx->second->receives.nth(0)->second.amount == 4000); EXPECT(tx->second->receives.nth(0)->second.recipient == recipient); EXPECT(tx->second->receives.nth(0)->second.index == 3); EXPECT(tx->second->receives.nth(0)->second.global_index == 60000); EXPECT(bool(tx->second->receives.nth(0)->second.rct_mask)); EXPECT(*tx->second->receives.nth(0)->second.rct_mask == ringct); EXPECT(wallet->primary.subaccounts.size() == 2); EXPECT(wallet->primary.subaccounts.at(0).last == 0); EXPECT(wallet->primary.subaccounts.at(1).last == 2); } SECTION("1.0 server with lookahead fail and max subaddress availability") { std::string subs; for (unsigned i = 0; i < 50; ++i) subs += R"({"key":)" + std::to_string(i) + R"(, "value": [[0, 199]]},)"; subs.pop_back(); const std::string upsert_body = R"({"address":")" + wallet->get_spend_address({0, 0}) + R"(","view_key":")" + to_hex(unwrap(unwrap(wallet->primary.view.sec))) + R"(","subaddrs":[{"key":1,"value":[[0,2]]}],"get_all":false})"; const std::string import_body = R"({"address":")" + wallet->get_spend_address({0, 0}) + R"(","view_key":")" + to_hex(unwrap(unwrap(wallet->primary.view.sec))) + R"(","from_height":150,"lookahead":{"maj_i":50,"min_i":200}})"; rct::key ringct{}; crypto::key_image image{}; lwsf::internal::backend::keypair tx_key{}; lwsf::internal::backend::keypair output_key{}; crypto::generate_keys(tx_key.pub, tx_key.sec); { crypto::secret_key scalar{}; crypto::key_derivation derived{}; EXPECT(crypto::generate_key_derivation(tx_key.pub, wallet->primary.view.sec, derived)); EXPECT(crypto::derive_public_key(derived, 3, wallet->primary.spend.pub, output_key.pub)); crypto::derive_secret_key(derived, 3, wallet->primary.spend.sec, output_key.sec); crypto::generate_key_image(output_key.pub, output_key.sec, image); crypto::derivation_to_scalar(derived, 3, scalar); rct::ecdhTuple commitment{}; rct::ecdhDecode(commitment, rct::sk2rct(scalar), true); ringct = commitment.mask; } EXPECT(wallet->primary.subaccounts.size() == 2); // from last section frame->requests = { {"/login", ""}, {"/upsert_subaddrs", upsert_body}, {"/get_address_txs", ""}, {"/get_unspent_outs", ""}, {"/get_version", ""}, {"/get_subaddrs", ""}, {"/import_wallet_request", import_body} }; frame->responses = { R"({"new_address": true, "lookahead": {"maj_i": 50, "min_i": 200}})", "", R"({ "total_received": "4000", "scanned_height": 175, "scanned_block_height": 175, "start_height": 5, "blockchain_height": 210, "lookahead": {"maj_i": 50, "min_i": 200}, "lookahead_fail": 150, "transactions": [{ "id": 1, "hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "total_received": "4000", "total_sent": "10000", "unlock_time": 0, "spent_outputs": [{ "amount": "10000", "key_image": ")" + to_hex(image) + R"(", "tx_pub_key": ")" + to_hex(tx_key.pub) + R"(", "out_index": 3, "mixin": 15 }], "coinbase": false, "mempool": true, "mixin": 15 }] })", R"({ "per_byte_fee": 5, "fee_mask": 100, "fees": [110, 500, 700], "amount": "4000", "lookahead_fail": 150, "outputs": [{ "amount": "4000", "index": 3, "global_index": "60000", "rct": "recomputed", "tx_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "tx_prefix_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee5", "public_key": ")" + to_hex(output_key.pub) + R"(", "tx_pub_key": ")" + to_hex(tx_key.pub) + R"(", "spend_key_images": [")" + to_hex(image) + R"("], "timestamp": "2026-01-01T00:00:00Z", "height": 150, "recipient": {"maj_i": 1, "min_i": 2} }] })", R"({"max_subaddresses": 30000})", R"({"all_subaddrs": [)" + subs + "]}", R"({"lookahead": {"maj_i": 50, "min_i": 200}, "status": "OK", "new_request": true, "request_fulfilled": true})" }; while (!done) { io.restart(); io.run_one(); } const lwsf::internal::rpc::address_meta sender{0, 0}; const lwsf::internal::rpc::address_meta recipient{1, 2}; EXPECT(frame->responses.size() == 0); EXPECT(result == std::error_code{}); EXPECT(wallet->passed_login); EXPECT(wallet->refresh_error == std::error_code{}); EXPECT(wallet->lookahead_error == std::error_code{}); EXPECT(wallet->subaddress_error == std::error_code{}); EXPECT(wallet->import_error == std::error_code{}); EXPECT(wallet->primary.restore_height == 5); EXPECT(wallet->primary.scan_height == 175); EXPECT(wallet->primary.requested_start == 5); EXPECT(wallet->blockchain_height == 210); EXPECT(wallet->fee_mask == 100); EXPECT(wallet->per_byte_fee.size() == 3); EXPECT(wallet->per_byte_fee.at(0) == 110); EXPECT(wallet->per_byte_fee.at(1) == 500); EXPECT(wallet->per_byte_fee.at(2) == 700); EXPECT(wallet->primary.txes.size() == 1); auto tx = wallet->primary.txes.find(from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1")); EXPECT(tx != wallet->primary.txes.end()); EXPECT(tx->second != nullptr); EXPECT(tx->second->spends.size() == 1); EXPECT(tx->second->spends.nth(0)->first == image); EXPECT(tx->second->spends.nth(0)->second.amount == 10000); EXPECT(tx->second->spends.nth(0)->second.sender == sender); EXPECT(tx->second->spends.nth(0)->second.tx_pub == tx_key.pub); EXPECT(tx->second->spends.nth(0)->second.output_pub == output_key.pub); EXPECT(tx->second->receives.size() == 1); EXPECT(tx->second->receives.nth(0)->first == output_key.pub); EXPECT(tx->second->receives.nth(0)->second.tx_pub == tx_key.pub); EXPECT(tx->second->receives.nth(0)->second.amount == 4000); EXPECT(tx->second->receives.nth(0)->second.recipient == recipient); EXPECT(tx->second->receives.nth(0)->second.index == 3); EXPECT(tx->second->receives.nth(0)->second.global_index == 60000); EXPECT(bool(tx->second->receives.nth(0)->second.rct_mask)); EXPECT(*tx->second->receives.nth(0)->second.rct_mask == ringct); EXPECT(wallet->primary.subaccounts.size() == 2); EXPECT(wallet->primary.subaccounts.at(0).last == 0); EXPECT(wallet->primary.subaccounts.at(1).last == 2); } SECTION("1.0 server with lookahead fail and new start") { wallet->primary.requested_start = 1; std::string subs; for (unsigned i = 0; i < 50; ++i) subs += R"({"key":)" + std::to_string(i) + R"(, "value": [[0, 199]]},)"; subs.pop_back(); const std::string upsert_body = R"({"address":")" + wallet->get_spend_address({0, 0}) + R"(","view_key":")" + to_hex(unwrap(unwrap(wallet->primary.view.sec))) + R"(","subaddrs":[{"key":1,"value":[[0,2]]}],"get_all":false})"; const std::string import_body = R"({"address":")" + wallet->get_spend_address({0, 0}) + R"(","view_key":")" + to_hex(unwrap(unwrap(wallet->primary.view.sec))) + R"(","from_height":1,"lookahead":{"maj_i":50,"min_i":200}})"; rct::key ringct{}; crypto::key_image image{}; lwsf::internal::backend::keypair tx_key{}; lwsf::internal::backend::keypair output_key{}; crypto::generate_keys(tx_key.pub, tx_key.sec); { crypto::secret_key scalar{}; crypto::key_derivation derived{}; EXPECT(crypto::generate_key_derivation(tx_key.pub, wallet->primary.view.sec, derived)); EXPECT(crypto::derive_public_key(derived, 3, wallet->primary.spend.pub, output_key.pub)); crypto::derive_secret_key(derived, 3, wallet->primary.spend.sec, output_key.sec); crypto::generate_key_image(output_key.pub, output_key.sec, image); crypto::derivation_to_scalar(derived, 3, scalar); rct::ecdhTuple commitment{}; rct::ecdhDecode(commitment, rct::sk2rct(scalar), true); ringct = commitment.mask; } EXPECT(wallet->primary.subaccounts.size() == 2); // from last section frame->requests = { {"/login", ""}, {"/upsert_subaddrs", upsert_body}, {"/get_address_txs", ""}, {"/get_unspent_outs", ""}, {"/import_wallet_request", import_body} }; frame->responses = { R"({"new_address": true, "lookahead": {"maj_i": 50, "min_i": 200}})", "", R"({ "total_received": "4000", "scanned_height": 175, "scanned_block_height": 175, "start_height": 5, "blockchain_height": 210, "lookahead": {"maj_i": 50, "min_i": 200}, "lookahead_fail": 150, "transactions": [{ "id": 1, "hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "total_received": "4000", "total_sent": "10000", "unlock_time": 0, "spent_outputs": [{ "amount": "10000", "key_image": ")" + to_hex(image) + R"(", "tx_pub_key": ")" + to_hex(tx_key.pub) + R"(", "out_index": 3, "mixin": 15 }], "coinbase": false, "mempool": true, "mixin": 15 }] })", R"({ "per_byte_fee": 5, "fee_mask": 100, "fees": [110, 500, 700], "amount": "4000", "lookahead_fail": 150, "outputs": [{ "amount": "4000", "index": 3, "global_index": "60000", "rct": "recomputed", "tx_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1", "tx_prefix_hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee5", "public_key": ")" + to_hex(output_key.pub) + R"(", "tx_pub_key": ")" + to_hex(tx_key.pub) + R"(", "spend_key_images": [")" + to_hex(image) + R"("], "timestamp": "2026-01-01T00:00:00Z", "height": 150, "recipient": {"maj_i": 1, "min_i": 2} }] })", R"({"lookahead": {"maj_i": 50, "min_i": 200}, "status": "OK", "new_request": true, "request_fulfilled": true})" }; while (!done) { io.restart(); io.run_one(); } const lwsf::internal::rpc::address_meta sender{0, 0}; const lwsf::internal::rpc::address_meta recipient{1, 2}; EXPECT(frame->responses.size() == 0); EXPECT(result == std::error_code{}); EXPECT(wallet->passed_login); EXPECT(wallet->refresh_error == std::error_code{}); EXPECT(wallet->lookahead_error == std::error_code{}); EXPECT(wallet->subaddress_error == std::error_code{}); EXPECT(wallet->import_error == std::error_code{}); EXPECT(wallet->primary.restore_height == 1); EXPECT(wallet->primary.scan_height == 175); EXPECT(wallet->primary.requested_start == 1); EXPECT(wallet->blockchain_height == 210); EXPECT(wallet->fee_mask == 100); EXPECT(wallet->per_byte_fee.size() == 3); EXPECT(wallet->per_byte_fee.at(0) == 110); EXPECT(wallet->per_byte_fee.at(1) == 500); EXPECT(wallet->per_byte_fee.at(2) == 700); EXPECT(wallet->primary.txes.size() == 1); auto tx = wallet->primary.txes.find(from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbee1")); EXPECT(tx != wallet->primary.txes.end()); EXPECT(tx->second != nullptr); EXPECT(tx->second->spends.size() == 1); EXPECT(tx->second->spends.nth(0)->first == image); EXPECT(tx->second->spends.nth(0)->second.amount == 10000); EXPECT(tx->second->spends.nth(0)->second.sender == sender); EXPECT(tx->second->spends.nth(0)->second.tx_pub == tx_key.pub); EXPECT(tx->second->spends.nth(0)->second.output_pub == output_key.pub); EXPECT(tx->second->receives.size() == 1); EXPECT(tx->second->receives.nth(0)->first == output_key.pub); EXPECT(tx->second->receives.nth(0)->second.tx_pub == tx_key.pub); EXPECT(tx->second->receives.nth(0)->second.amount == 4000); EXPECT(tx->second->receives.nth(0)->second.recipient == recipient); EXPECT(tx->second->receives.nth(0)->second.index == 3); EXPECT(tx->second->receives.nth(0)->second.global_index == 60000); EXPECT(bool(tx->second->receives.nth(0)->second.rct_mask)); EXPECT(*tx->second->receives.nth(0)->second.rct_mask == ringct); EXPECT(wallet->primary.subaccounts.size() == 2); EXPECT(wallet->primary.subaccounts.at(0).last == 0); EXPECT(wallet->primary.subaccounts.at(1).last == 2); } } SECTION("call rpcs in randomized order with threads") { static constexpr const unsigned high_level_calls = 1000; const auto frame = std::make_shared(io); wallet->client.init(io, "127.0.0.1", "", acceptor.local_endpoint().port(), no_ssl); wallet->primary.subaccounts.emplace_back().last = 1; acceptor.async_accept(frame->server, dummy_server{frame}); std::atomic count{0}; const auto completed = [&] (auto) { ++count; }; const std::array, 8> rpcs {{ std::bind(bwallet::login_is_new, wallet, completed), std::bind(bwallet::refresh, wallet, true, completed), std::bind(bwallet::register_subaccount, wallet, 1, completed), std::bind(bwallet::register_subaddress, wallet, 1, 0, completed), std::bind(bwallet::set_lookahead, wallet, 1, 1, completed), std::bind(bwallet::restore_height_raw, wallet, 0, completed), std::bind(bwallet::get_decoys, wallet, empty_decoys{}, completed), std::bind(bwallet::send_tx, wallet, empty_slice{}, completed) }}; struct joiner { boost::thread thread; joiner(const joiner&) = delete; joiner(joiner&&) = default; ~joiner() noexcept { if (thread.joinable()) thread.join(); } }; std::vector threads; struct on_exit_ { boost::asio::io_context& io; ~on_exit_() noexcept { io.stop(); } } on_exit{io}; std::string asio_fail; boost::mutex sync_asio; const auto run_asio = [&] { try { (io.run()); } catch (const std::exception& e) { io.stop(); const boost::lock_guard lock{sync_asio}; asio_fail = e.what(); } }; const auto run_client = [&] { static_assert(high_level_calls % 2 == 0); static_assert(0 < rpcs.size()); static_assert(rpcs.size() <= std::numeric_limits::max()); std::mt19937 eng(crypto::rand()); std::uniform_int_distribution dist(0, rpcs.size() - 1); unsigned i = high_level_calls / 2; while (i--) { rpcs.at(dist(eng))(); // the above call is much quicker than the response threads boost::this_thread::sleep_for(boost::chrono::milliseconds{1}); } }; threads.push_back(joiner{boost::thread{run_asio}}); threads.push_back(joiner{boost::thread{run_asio}}); threads.push_back(joiner{boost::thread{run_client}}); run_client(); while (count < high_level_calls && !io.stopped()) boost::this_thread::sleep_for(boost::chrono::milliseconds{50}); const boost::lock_guard lock{sync_asio}; EXPECT(asio_fail == ""); EXPECT(count == high_level_calls); EXPECT(!io.stopped()); } } }