From fbd694f55bad6e4512653f92c56c89055fc90fce Mon Sep 17 00:00:00 2001 From: Lee Clagett Date: Fri, 16 May 2025 21:00:21 -0400 Subject: [PATCH] Meta Update: * Completed Account Handling * Added QR Code Generation support * Added Tx Send Support WITHOUT address book * Bump version of lwsf --- external/lwsf | 2 +- src/components/table.cpp | 48 ++-- src/components/table.h | 4 +- src/events.cpp | 1 + src/events.h | 2 +- src/lwcli_config.h | 1 - src/views/CMakeLists.txt | 4 +- src/views/accounts.cpp | 164 ++++++++++-- src/views/history.cpp | 107 +++----- src/views/manager.cpp | 8 +- src/views/send.cpp | 532 +++++++++++++++++++++++++++++++++++++++ src/views/send.h | 44 ++++ src/views/settings.cpp | 5 +- src/views/wallet.cpp | 19 +- src/views/wallet.h | 9 +- 15 files changed, 814 insertions(+), 136 deletions(-) create mode 100644 src/views/send.cpp create mode 100644 src/views/send.h diff --git a/external/lwsf b/external/lwsf index 38f4477..68ccb25 160000 --- a/external/lwsf +++ b/external/lwsf @@ -1 +1 @@ -Subproject commit 38f44770b83ce36b528d79b497ca4c02f9c03ad2 +Subproject commit 68ccb258627b8ce879ffb07bc160f4053e5e24e1 diff --git a/src/components/table.cpp b/src/components/table.cpp index 0156a09..afdd910 100644 --- a/src/components/table.cpp +++ b/src/components/table.cpp @@ -41,7 +41,7 @@ namespace lwcli { namespace component class table_ final : public ftxui::ComponentBase { const table_generator generator_; - const table_enter enter_; + const table_on_key key_; const std::vector> title_; std::ptrdiff_t rows_; std::ptrdiff_t selected_; @@ -62,10 +62,10 @@ namespace lwcli { namespace component } public: - explicit table_(std::vector&& title, table_generator&& generator, table_enter&& enter) + explicit table_(std::vector&& title, table_generator&& generator, table_on_key&& key) : ftxui::ComponentBase(), generator_(std::move(generator)), - enter_(std::move(enter)), + key_(std::move(key)), title_(get_title_bar(std::move(title))), rows_(0), selected_(-1) @@ -80,18 +80,31 @@ namespace lwcli { namespace component rows_ = rows; } + bool can_increment() const noexcept + { return selected_ + min_row() < rows_; } + + bool can_decrement() const noexcept + { return min_row() <= selected_; } + bool OnEvent(ftxui::Event event) override final { - if (event == ftxui::Event::ArrowDown && selected_ + min_row() < rows_) + if (event.is_mouse()) + { + if (event.mouse().button == ftxui::Mouse::WheelDown && can_increment()) + ++selected_; + else if (event.mouse().button == ftxui::Mouse::WheelUp && can_decrement()) + --selected_;// = std::max(min_row(), selected_ - 1); + } + else if (event == ftxui::Event::ArrowDown && can_increment()) ++selected_; - else if (event == ftxui::Event::ArrowUp && min_row() <= selected_) + else if (event == ftxui::Event::ArrowUp && can_decrement()) --selected_; else if (event == ftxui::Event::PageDown) selected_ = std::min(rows_ + min_row(), selected_ + 15); else if (event == ftxui::Event::PageUp) selected_ = std::max(min_row(), selected_ - 15); - else if (event == ftxui::Event::Return && min_row() <= selected_ && selected_ < rows_) - return enter_(std::size_t(selected_)); + else if (min_row() <= selected_ && selected_ < rows_) + return key_(std::move(event), std::size_t(selected_)); return min_row() <= selected_ && selected_ - min_row() < rows_; } @@ -119,23 +132,24 @@ namespace lwcli { namespace component selected_ = min_row(); else if (rows_ <= selected_ - min_row()) selected_ = rows_ - 1 + min_row(); - - if (min_row() <= selected_ && selected_ < rows_) - { - auto row = table.SelectRow(std::size_t(selected_) + title_.size()); - row.Decorate(ftxui::inverted); - row.Decorate(ftxui::focus); - } } + + if (min_row() <= selected_ && selected_ < rows_) + { + auto row = table.SelectRow(std::size_t(selected_) + title_.size()); + row.Decorate(ftxui::inverted); + row.Decorate(ftxui::focus); + } + return table.Render(); } }; } // anonymous - ftxui::Component table(std::vector title, table_generator generator, table_enter enter) + ftxui::Component table(std::vector title, table_generator generator, table_on_key key) { - if (!generator || !enter) + if (!generator || !key) throw std::invalid_argument{"lwcli::components::table was given nullptr"}; - return std::make_shared(std::move(title), std::move(generator), std::move(enter)); + return std::make_shared(std::move(title), std::move(generator), std::move(key)); } }} // lwcli // view diff --git a/src/components/table.h b/src/components/table.h index a00b0a6..13fcfa0 100644 --- a/src/components/table.h +++ b/src/components/table.h @@ -39,8 +39,8 @@ namespace lwcli { namespace component { //! Shows Transaction History using table_generator = std::function>()>; - using table_enter = std::function; - ftxui::Component table(std::vector title, table_generator generator, table_enter enter); + using table_on_key = std::function; + ftxui::Component table(std::vector title, table_generator generator, table_on_key key); }} // lwscli // view diff --git a/src/events.cpp b/src/events.cpp index fd3fc97..6bdebb1 100644 --- a/src/events.cpp +++ b/src/events.cpp @@ -34,4 +34,5 @@ namespace lwcli { namespace event string optmization. */ const ftxui::Event lock_wallet = ftxui::Event::Special("lwcli.lockw"); const ftxui::Event refresh_wallet = ftxui::Event::Special("lwcli.refresh"); + const ftxui::Event send_async = ftxui::Event::Special("lwcli.sendasync"); }} diff --git a/src/events.h b/src/events.h index f4f4e4f..30a7e45 100644 --- a/src/events.h +++ b/src/events.h @@ -46,5 +46,5 @@ namespace lwcli { namespace event extern const ftxui::Event lock_wallet; extern const ftxui::Event refresh_wallet; - + extern const ftxui::Event send_async; }} // lwcli // event diff --git a/src/lwcli_config.h b/src/lwcli_config.h index f5f4c61..53a1fba 100644 --- a/src/lwcli_config.h +++ b/src/lwcli_config.h @@ -51,7 +51,6 @@ namespace lwcli { namespace config constexpr const std::string_view default_language{"English"}; constexpr const std::string_view default_account_name{"Untitled Account"}; - constexpr const std::string_view default_subaddress_name{"Untitled Address"}; //! Timeout interval for inactivity with open wallet constexpr const std::chrono::minutes wallet_timeout{2}; diff --git a/src/views/CMakeLists.txt b/src/views/CMakeLists.txt index 3f05fec..59711f9 100644 --- a/src/views/CMakeLists.txt +++ b/src/views/CMakeLists.txt @@ -26,8 +26,8 @@ # 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(lwcli-views_sources accounts.cpp history.cpp keys.cpp manager.cpp settings.cpp wallet.cpp) -set(lwscli-views_headers accounts.h history.h keys.h manager.h settings.h wallet.h) +set(lwcli-views_sources accounts.cpp history.cpp keys.cpp manager.cpp send.cpp settings.cpp wallet.cpp) +set(lwscli-views_headers accounts.h history.h keys.h manager.h send.h settings.h wallet.h) add_library(lwcli-views ${lwcli-views_sources} ${lwcli-views_headers}) target_link_libraries(lwcli-views PRIVATE component dom lwcli-components lwcli-decorate lwsf-api) diff --git a/src/views/accounts.cpp b/src/views/accounts.cpp index 7be3393..7827e67 100644 --- a/src/views/accounts.cpp +++ b/src/views/accounts.cpp @@ -54,6 +54,106 @@ namespace lwcli { namespace view ftxui::ButtonOption ascii() { return ftxui::ButtonOption::Ascii(); } + ftxui::Canvas make_qr_code(std::vector> raw) + { + const std::size_t size = raw.size(); + if (std::numeric_limits::max() / 4 < size) + throw std::runtime_error{"qrcode too large"}; + if (std::numeric_limits::max() / 4 < size) + throw std::runtime_error{"qrcode too large"}; + + const std::size_t canvas_size = (size * 2) + ((size % 2) * 2); + ftxui::Canvas out{int(canvas_size), int(canvas_size)}; + for (std::size_t y = 0; y < canvas_size; ++y) + for (std::size_t x = 0; x < canvas_size; ++x) + out.DrawBlockOff(x, y); + + for (std::size_t y = 0; y < size; ++y) + { + const auto& row = raw.at(y); + for (std::size_t x = 0; x < size; ++x) + { + if (row.at(x)) + { + const int real_x = x * 2; + const int real_y = y * 2; + out.DrawBlockOn(real_x, real_y); + out.DrawBlockOn(real_x + 1, real_y); + } + } + } + return out; + } + + class subaccount_ final : public ftxui::ComponentBase + { + std::string subaccount_name_; + const std::shared_ptr wal_; + const ftxui::Element title_; + const ftxui::Element desc_; + const ftxui::Canvas qr_code_raw_; + const ftxui::Element qr_code_; + ftxui::Component buttons_; + const ftxui::Component name_; + ftxui::Component ui_; + const std::uint32_t major_; + const std::uint32_t minor_; + + bool Focusable() const override final { return true; } + ftxui::Component ActiveChild() override final { return ui_; } + + public: + explicit subaccount_(std::shared_ptr&& wal, std::uint32_t major, std::uint32_t minor) + : ftxui::ComponentBase(), + subaccount_name_(wal->getSubaddressLabel(major, minor)), + wal_(std::move(wal)), + title_(ftxui::text(wal_->address(major, minor))), + desc_(ftxui::text(_("Name: "))), + qr_code_raw_(make_qr_code(lwsf::qrcode(wal_.get(), major, minor))), + qr_code_(ftxui::canvas(&qr_code_raw_)), + buttons_(), + name_(last_input(&subaccount_name_)), + ui_(), + major_(major), + minor_(minor) + { + buttons_ = ftxui::Container::Horizontal({ + ftxui::Button(_("Cancel"), [] () { throw event::close{}; }, ascii()), + ftxui::Button(_("Save"), [this] () { + wal_->setSubaddressLabel(major_, minor_, subaccount_name_); + throw event::close{}; + }, ascii()) + }); + + ui_ = ftxui::Container::Vertical({buttons_, name_}); + Add(ui_); + } + + bool OnEvent(ftxui::Event event) override final + { + if (event == ftxui::Event::CtrlQ) + throw event::close{}; + ui_->OnEvent(std::move(event)); + return true; + } + + ftxui::Element OnRender() override final + { + return ftxui::window(title_, ftxui::vbox({ + buttons_->Render() | ftxui::hcenter, + ftxui::separator(), + ftxui::hbox({desc_, name_->Render()}), + ftxui::separator(), + qr_code_ | ftxui::hcenter + })); + } + }; + + ftxui::Component subaccount(std::shared_ptr wal, std::uint32_t major, std::uint32_t minor) + { + return std::make_shared(std::move(wal), major, minor); + } + class account_detail final : public ftxui::ComponentBase { std::string account_name_; @@ -67,6 +167,7 @@ namespace lwcli { namespace view ftxui::Component buttons_; const ftxui::Component name_; ftxui::Component ui_; + ftxui::Element cached_; std::unordered_map row_map_; const std::uint32_t id_; @@ -85,13 +186,15 @@ namespace lwcli { namespace view wal_(std::move(wal)), acct_(acct), title_(ftxui::text(_("Account #") + std::to_string(id))), - address_({ftxui::text("Address: "), ftxui::text(wal_->address(id, 0))}), + address_({ftxui::text("Primary: "), ftxui::text(wal_->address(id, 0).substr(0, 30) + "...")}), desc_(ftxui::text(_("Name: "))), details_(), table_(), buttons_(), name_(last_input(&account_name_)), ui_(), + cached_(), + row_map_(), id_(id) { if (!acct_) @@ -105,25 +208,29 @@ namespace lwcli { namespace view wal_->setSubaddressLabel(id_, 0, account_name_); throw event::close{}; }, ascii()), - ftxui::Button(_("Add Subaddress"), [this] () { - acct_->addRow(id_, std::string{config::default_subaddress_name}); - }, ascii()) + ftxui::Button(_("Add Subaddress"), [this] () { acct_->addRow(id_, std::string{}); }, ascii()) }); table_ = component::table( {_("#"), _("Label"), _("Address")}, [this] () { return subaddress_list(); }, - [this] (std::size_t i) { return display_details(i); } + [this] (ftxui::Event e, std::size_t i) { return display_details(e, i); } ); ui_ = ftxui::Container::Vertical({buttons_, name_, table_}); Add(ui_); } - bool display_details(const std::size_t index) + bool display_details(const ftxui::Event& e, const std::size_t index) { - if (row_map_.find(index) == row_map_.end()) - throw std::logic_error{"DEBUG"}; + if (e != ftxui::Event::Return) + return true; + + const std::size_t minor = row_map_.at(index); + if (std::numeric_limits::max() < minor) + throw std::runtime_error{"account::display_details invalid minor"}; + details_ = subaccount(wal_, id_, std::uint32_t(minor)); + Add(details_); return true; } @@ -143,6 +250,7 @@ namespace lwcli { namespace view throw; details_->Detach(); details_.reset(); + account_name_ = wal_->getSubaddressLabel(id_, 0); } return true; } @@ -177,13 +285,18 @@ namespace lwcli { namespace view ftxui::Element OnRender() override final { - return ftxui::window(title_, ftxui::vbox({ - buttons_->Render() | ftxui::hcenter, - ftxui::separator(), - ftxui::gridbox({address_, {desc_, name_->Render()}}), - ftxui::separator(), - table_->Render() | ftxui::vscroll_indicator | ftxui::yframe | ftxui::center - })); + if (!details_) + { + cached_ = ftxui::window(title_, ftxui::vbox({ + buttons_->Render() | ftxui::hcenter, + ftxui::separator(), + ftxui::gridbox({address_, {desc_, name_->Render()}}), + ftxui::separator(), + table_->Render() | ftxui::vscroll_indicator | ftxui::yframe | ftxui::center + })); + return cached_; + } + return ftxui::dbox(cached_, decorate::overlay(details_->Render())); } }; @@ -194,6 +307,7 @@ namespace lwcli { namespace view Monero::SubaddressAccount* const wal_accounts_; std::uint32_t* const account_; const ftxui::Element title_; + const ftxui::Element instructions_; ftxui::Component details_; ftxui::Component table_; ftxui::Element table_cached_; @@ -216,6 +330,7 @@ namespace lwcli { namespace view wal_accounts_(wal_->subaddressAccount()), account_(account), title_(ftxui::text(_("Accounts"))), + instructions_(decorate::banner(ftxui::text("[l]oad account")) | ftxui::inverted), details_(), table_(), table_cached_(), @@ -233,18 +348,24 @@ namespace lwcli { namespace view table_ = component::table( {_("#"), _("Balance"), _("Label"), _("Address")}, [this] () { return accounts_list(); }, - [this] (std::size_t i) { return display_details(i); } + [this] (ftxui::Event e, std::size_t i) { return display_details(e, i); } ); ui_ = ftxui::Container::Vertical({buttons_, table_}); Add(ui_); } - bool display_details(const std::size_t index) + bool display_details(const ftxui::Event& e, const std::size_t index) { - if (details_) - details_->Detach(); - details_ = std::make_shared(wal_, wal_->subaddress(), row_map_.at(index)); + if (e == ftxui::Event::Return) + { + if (details_) + details_->Detach(); + details_ = std::make_shared(wal_, wal_->subaddress(), row_map_.at(index)); + Add(details_); + } + else if (e == ftxui::Event::l || e == ftxui::Event::L) + *account_ = row_map_.at(index); return true; } @@ -305,7 +426,8 @@ namespace lwcli { namespace view table_cached_ = ftxui::window(title_, ftxui::vbox({ buttons_->Render() | ftxui::hcenter, ftxui::separator(), - table_->Render() //| ftxui::vscroll_indicator | ftxui::yframe | ftxui::center + table_->Render() | ftxui::vscroll_indicator | ftxui::yframe | ftxui::center, + instructions_ })); return table_cached_; } diff --git a/src/views/history.cpp b/src/views/history.cpp index 1ee96df..bece3cd 100644 --- a/src/views/history.cpp +++ b/src/views/history.cpp @@ -44,61 +44,10 @@ namespace lwcli { namespace view { namespace { -// Notice for next two functions - -// Copyright (c) 2014-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. -// -// Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers - - constexpr const unsigned int default_decimal_point = 12; - void insert_money_decimal_point(std::string &s, unsigned int decimal_point) - { - if (decimal_point == (unsigned int)-1) - decimal_point = default_decimal_point; - if(s.size() < decimal_point+1) - { - s.insert(0, decimal_point+1 - s.size(), '0'); - } - if (decimal_point > 0) - s.insert(s.size() - decimal_point, "."); - } - std::string print_money(const std::uint64_t amount) - { - std::string s = std::to_string(amount); - insert_money_decimal_point(s, default_decimal_point); - return s; - } - std::string print_amount(std::uint64_t amount, int direction) { const bool negate = direction == Monero::TransactionInfo::Direction_Out; - return (negate ? "-" : "") + print_money(amount); + return (negate ? "-" : "") + lwsf::displayAmount(amount); } std::string print_amount(const Monero::TransactionInfo& tx) @@ -154,7 +103,7 @@ namespace lwcli { namespace view text = _("Transfers: "); grid.push_back({ ftxui::text(std::move(text)), - ftxui::text(print_money(transfer.amount) + address) + ftxui::text(lwsf::displayAmount(transfer.amount) + address) }); } @@ -212,7 +161,7 @@ namespace lwcli { namespace view } payment_id_ = {ftxui::text(_("Payment ID: ")), ftxui::text(info_->paymentId())}; amount_ = {ftxui::text(_("Amount: ")), ftxui::text(print_amount(*info_))}; - fee_ = {ftxui::text(_("Fee: ")), ftxui::text(print_money(info_->fee()))}; + fee_ = {ftxui::text(_("Fee: ")), ftxui::text(lwsf::displayAmount(info_->fee()))}; { std::string minors; for (const std::uint32_t minor : info_->subaddrIndex()) @@ -272,34 +221,35 @@ namespace lwcli { namespace view row_map_(), account_(account) { - Monero::Subaddress* subaddress = wallet_->subaddress(); - if (!subaddress) - throw std::runtime_error{"unexpected subaddress nullptr"}; - subaddress->refresh(account_); + if (!wallet_) + throw std::invalid_argument{"lwcli::view::history given nullptr"}; - std::string address = _("Account ") + std::to_string(account_); - const auto subaddresses = subaddress->getAll(); - if (!subaddresses.empty() && *subaddresses.begin()) - address += ": " + (*subaddresses.begin())->getAddress(); - - title_ = ftxui::text(std::move(address)); + { + std::string address = _("Account #") + std::to_string(account_); + address.append(" / ").append(wallet_->getSubaddressLabel(account_, 0)).append(" / "); + address.append(wallet_->address(account_, 0).substr(0, 20)).append("..."); + title_ = ftxui::text(std::move(address)); + } // perform transalation lookup once table_ = component::table( - {_("Date"), _("Amount"), _("Payment ID"), _("Desription"), _("Block"), _("Fee"), _("Hash")}, + {_("Date"), _("Amount"), _("Payment ID"), _("Label"), _("Desription"), _("Block"), _("Fee"), _("Hash")}, [this] () { return transaction_list(); }, - [this] (std::size_t i) { return add_overlay(i); } + [this] (ftxui::Event e, std::size_t i) { return add_overlay(e, i); } ); Add(table_); } - bool add_overlay(const std::size_t i) + bool add_overlay(const ftxui::Event& e, const std::size_t i) { - if (overlay_) - overlay_->Detach(); - overlay_ = std::make_shared(wallet_->history(), row_map_.at(i)); - Add(overlay_); + if (e == ftxui::Event::Return) + { + if (overlay_) + overlay_->Detach(); + overlay_ = std::make_shared(wallet_->history(), row_map_.at(i)); + Add(overlay_); + } return true; } @@ -390,13 +340,24 @@ namespace lwcli { namespace view char const* const extended_payment_id = 16 < payment_id.size() ? "..." : ""; + + std::string label; + const std::set subaddrs = tx->subaddrIndex(); + if (!subaddrs.empty()) + { + const std::uint32_t index = *subaddrs.begin(); + if (index) + label = wallet_->getSubaddressLabel(account_, index); + } + rows.push_back({ std::string{date}, print_amount(amount, direction), payment_id.substr(0, 16) + extended_payment_id, + std::move(label), tx->description(), std::to_string(tx->blockHeight()), - print_money(tx->fee()), + lwsf::displayAmount(tx->fee()), tx->hash().substr(0, 16) + "..." }); @@ -415,7 +376,7 @@ namespace lwcli { namespace view auto table = table_->Render(); // compute balance first in callback table_cached_ = ftxui::vbox({ title_, - ftxui::text(_("Balance: ") + print_money(balance_)), + ftxui::text(_("Balance: ") + lwsf::displayAmount(balance_)), std::move(table) | ftxui::vscroll_indicator | ftxui::yframe | ftxui::center | ftxui::flex }); return table_cached_; diff --git a/src/views/manager.cpp b/src/views/manager.cpp index 3cc6f87..b30f5d9 100644 --- a/src/views/manager.cpp +++ b/src/views/manager.cpp @@ -470,7 +470,7 @@ namespace lwcli { namespace view } public: - explicit start(std::shared_ptr&& wm, std::string&& file, std::shared_ptr* out) + explicit start(std::shared_ptr wm, std::string&& file, std::shared_ptr* out) : out_(out), title_(ftxui::text("wmcli")), help_(decorate::banner(ftxui::text(_("Ctrl-Q to close active window, Ctrl-C close app immediately")))), @@ -598,6 +598,7 @@ namespace lwcli { namespace view class manager_ final : public ftxui::ComponentBase { + const std::shared_ptr wm_; std::shared_ptr data_; const ftxui::Component start_; ftxui::Component wallet_; @@ -613,8 +614,9 @@ namespace lwcli { namespace view public: explicit manager_(std::shared_ptr&& wm, std::string&& file) : ftxui::ComponentBase(), + wm_(std::move(wm)), data_(nullptr), - start_(std::make_shared(std::move(wm), std::move(file), &data_)), + start_(std::make_shared(wm_, std::move(file), &data_)), wallet_(nullptr) {} @@ -633,7 +635,7 @@ namespace lwcli { namespace view return wallet_->OnEvent(std::move(event)); } else if (event != event::lock_wallet && start_->OnEvent(std::move(event)) && data_) - wallet_ = view::wallet(std::move(data_)); + wallet_ = view::wallet(wm_, std::move(data_)); data_.reset(); } catch (const event::close&) diff --git a/src/views/send.cpp b/src/views/send.cpp new file mode 100644 index 0000000..c857028 --- /dev/null +++ b/src/views/send.cpp @@ -0,0 +1,532 @@ +// Copyright (c) 2025, Cifro Codes LLC +// +// 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 "wallet.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "components/table.h" +#include "decorate/overlay.h" +#include "events.h" +#include "lwcli_config.h" +#include "translate.h" +#include "util.h" + +namespace lwcli { namespace view +{ + namespace + { + using dest_pair = std::pair; + using dest_group = std::pair, std::vector>; + + constexpr const std::array spinner{{'|', '/', '-', '\\'}}; + + ftxui::Component last_input(std::string* str) + { + auto opt = ftxui::InputOption::Default(); + opt.cursor_position = str->size(); + opt.multiline = false; + return ftxui::Input(str, std::move(opt)); + } + + ftxui::ButtonOption ascii() { return ftxui::ButtonOption::Ascii(); } + + struct confirmed final : public std::exception + { + confirmed() noexcept + : std::exception() + {} + + virtual ~confirmed() noexcept override = default; + virtual const char* what() const noexcept override final { return "confirmed send"; } + }; + + class confirm_ final : public ftxui::ComponentBase + { + const std::shared_ptr tx_; + const ftxui::Element title_; + ftxui::Element info_; + ftxui::Element error_; + ftxui::Component buttons_; + std::future sending_; + unsigned animation_; + bool sent_; + bool closing_; + + bool Focusable() const override final { return true; } + ftxui::Component ActiveChild() override final { return buttons_; } + + public: + explicit confirm_(std::shared_ptr&& tx, dest_group&& dests) + : ftxui::ComponentBase(), + tx_(std::move(tx)), + title_(ftxui::text(_("Sending Tx(es)"))), + info_(), + error_(), + buttons_(), + sending_(), + animation_(0), + sent_(false), + closing_(false) + { + { + std::vector> grid; + grid.reserve(dests.first.size() + 3); + + grid.push_back({ftxui::text(_("Sending: ")), ftxui::text(lwsf::displayAmount(tx_->amount()) + " XMR")}); + grid.push_back({ftxui::text(_("Fee: ")), ftxui::text(lwsf::displayAmount(tx_->fee()) + " XMR")}); + { + std::vector ids = tx_->txid(); + + ftxui::Elements rows; + rows.reserve(ids.size()); + + for (std::string& id : ids) + rows.push_back(ftxui::text(std::move(id))); + + grid.push_back({ftxui::text(_("TX IDs:")), ftxui::vbox(std::move(rows))}); + } + + for (std::size_t i = 0; i < dests.first.size(); ++i) + grid.push_back({ftxui::text(lwsf::displayAmount(dests.second.at(i)) + " XMR to "), ftxui::text(std::move(dests.first.at(i)))}); + + info_ = ftxui::gridbox(std::move(grid)); + } + buttons_ = ftxui::Container::Horizontal({ + ftxui::Button(_("Cancel"), [] () { throw event::close{}; }, ascii()), + ftxui::Button(_("Send/Confirm"), [this] () { send_tx(); }, ascii()) + }); + + Add(buttons_); + } + + void send_tx() + { + const auto tx_commit = [] (std::shared_ptr tx) + { + return tx->commit(); + }; + + sending_ = std::async(std::launch::async, tx_commit, tx_); + } + + bool OnEvent(ftxui::Event event) override final + { + const bool send_async = event == event::send_async; + if (!event.is_mouse() && !send_async) + error_.reset(); + + try + { + if (event == ftxui::Event::CtrlQ) + throw event::close{}; + else if (sent_ && send_async) + throw confirmed{}; + else if (closing_ && send_async) + throw event::close{}; + else if (!closing_) + buttons_->OnEvent(std::move(event)); + } + catch (const event::close&) + { + if (!sending_.valid()) + throw; + closing_ = true; + } + return true; + } + + ftxui::Element OnRender() override final + { + ftxui::Elements rows; + rows.reserve(4); + + bool animate = false; + if (sending_.valid()) + { + if (sending_.wait_for(std::chrono::seconds{0}) == std::future_status::ready) + { + sent_ = sending_.get(); + if (!sent_) + error_ = ftxui::text(tx_->errorString()); + else + error_.reset(); + + ftxui::ScreenInteractive* const active = ftxui::ScreenInteractive::Active(); + if (active) + active->PostEvent(event::send_async); + } + else + { + animate = true; + animation_ = (animation_ + 1) % spinner.size(); + error_ = ftxui::text(std::string{spinner[animation_]} + _(" Sending ") + spinner[animation_]); + ftxui::animation::RequestAnimationFrame(); + } + } + + if (!closing_ && !animate) + rows.push_back(buttons_->Render() | ftxui::hcenter); + + if (error_) + rows.push_back(decorate::banner(error_) | ftxui::inverted); + else + rows.push_back(ftxui::separator()); + + if (closing_) + rows.push_back(ftxui::text(_("...Waiting for Tx Send..."))); + rows.push_back(info_); + + return ftxui::window(title_, ftxui::vbox(std::move(rows))); + } + }; + + ftxui::Component confirm(std::shared_ptr tx, dest_group dests) + { + return std::make_shared(std::move(tx), std::move(dests)); + } + + ftxui::Component book(std::shared_ptr wm, std::shared_ptr wal, std::shared_ptr dest) + { + return nullptr; //return std::make_shared(std::move(wm), std::move(wal), std::move(dest)); + } + + class send_ final : public ftxui::ComponentBase + { + using buttons_tuple = + std::tuple; + + const std::shared_ptr wm_; + const std::shared_ptr wal_; + const ftxui::Element title_; + std::vector> dests_; + std::vector dests_ui_; + ftxui::Component overlay_; + ftxui::Component buttons_; + ftxui::Element error_; + ftxui::Component ui_; + ftxui::Element cached_; + std::future>>> oa_; + std::future, dest_group, std::string>> tx_; + const std::uint32_t account_; + unsigned animation_; + bool closing_; + + bool Focusable() const override final { return true; } + ftxui::Component ActiveChild() override final + { + if (overlay_) + return overlay_; + return ui_; + } + + public: + explicit send_(std::shared_ptr&& wm, std::shared_ptr&& wal, const std::uint32_t account) + : ftxui::ComponentBase(), + wm_(std::move(wm)), + wal_(std::move(wal)), + title_(ftxui::text(_("Send from account #") + std::to_string(account) + " (" + lwsf::displayAmount(wal_->unlockedBalance(account)) + " XMR available)")), + dests_(), + dests_ui_(), + overlay_(), + buttons_(), + error_(), + ui_(), + cached_(), + oa_(), + tx_(), + account_(account), + animation_(0), + closing_(false) + { + buttons_ = ftxui::Container::Horizontal({ + ftxui::Button(_("Cancel"), [] () { throw event::close{}; }, ascii()), + ftxui::Button(_("Add Dest"), [this] () { add_dest(); }, ascii()), + ftxui::Button(_("Construct Tx"), [this] () { try_construct(); }, ascii()) + }); + + add_dest(); + } + + void add_dest() + { + dests_.push_back(std::make_shared>()); + + const std::shared_ptr dest = dests_.back(); + const std::size_t elem = dests_ui_.size(); + dests_ui_.emplace_back( + last_input(&dests_.back()->first), + last_input(&dests_.back()->second), + ftxui::Button(_("Book"), [this, dest] () { overlay_ = book(wm_, wal_, dest); }, ascii()), + ftxui::Button(_("Remove"), [this, elem] () { remove_dest(elem); }, ascii()) + ); + + update_ui(); + } + + void remove_dest(const std::size_t elem) + { + dests_ui_.erase(dests_ui_.begin() + elem); + dests_.erase(dests_.begin() + elem); + + for (std::size_t i = 0; i < dests_ui_.size(); ++i) + std::get<3>(dests_ui_.at(i)) = ftxui::Button(_("Remove"), [this, i] () { remove_dest(i); }, ascii()); + + update_ui(); + } + + void update_ui() + { + ftxui::Components ui; + ui.reserve(dests_ui_.size() + 1); + ui.push_back(buttons_); + for (const auto& e : dests_ui_) + ui.push_back(ftxui::Container::Horizontal({std::get<0>(e), std::get<1>(e), std::get<2>(e), std::get<3>(e)})); + + if (ui_) + ui_->Detach(); + ui_ = ftxui::Container::Vertical(std::move(ui)); + Add(ui_); + } + + void try_construct() + { + if (oa_.valid() || tx_.valid()) + return; + + if (dests_.empty()) + { + error_ = ftxui::text("Must have one destination"); + return; + } + + dest_group dests; + + dests.first.reserve(dests_.size()); + dests.second.reserve(dests_.size()); + + for (const auto& dest : dests_) + { + const std::uint64_t amount = lwsf::amountFromString(dest->first); + if (!amount) + { + error_ = ftxui::text("Invalid amount"); + return; + } + dests.second.push_back(amount); + + if (!lwsf::addressValid(dest->second, wal_->nettype())) + { + if (dest->second.find_first_of(u8'.') == std::string::npos) + { + error_ = ftxui::text(_("Invalid Address/OpenAlias")); + return; + } + const auto oa_lookup = [dest] (std::shared_ptr wm, const std::string& uri) + -> std::optional>> + { + bool dnssec = false; + std::string result = wm->resolveOpenAlias(uri, dnssec); + if (!dnssec) + return std::nullopt; + return {{std::move(result), dest}}; + }; + + if (!wm_) + throw std::runtime_error{"WalletManager is nullptr"}; + oa_ = std::async(std::launch::async, oa_lookup, wm_, dest->second); + return; + } + dests.first.push_back(dest->second); + } + + const auto tx_construct = [] (std::shared_ptr wal, dest_group dests, const std::uint32_t account) + -> std::tuple, dest_group, std::string> + { + const auto dispose = [wal] (Monero::PendingTransaction* ptr) + { + if (ptr) + wal->disposeTransaction(ptr); + }; + + std::shared_ptr tx{ + wal->createTransactionMultDest(dests.first, {}, dests.second, 0 /*mixin_count*/, Monero::PendingTransaction::Priority_Default, account), + dispose + }; + if (!tx) + return {nullptr, {}, "Unexpected nullptr tx"}; + if (tx->status() == Monero::PendingTransaction::Status_Ok) + return {std::move(tx), std::move(dests), {}}; + return {nullptr, {}, tx->errorString()}; + }; + + tx_ = std::async(std::launch::async, tx_construct, wal_, std::move(dests), account_); + } + + bool OnEvent(ftxui::Event event) override final + { + const bool is_waiting = oa_.valid() || tx_.valid(); + try + { + if (!event.is_mouse() && event != event::send_async) + error_.reset(); + + if (overlay_) + return overlay_->OnEvent(std::move(event)); + else if (closing_ && event == event::send_async) + throw event::close{}; + else if (event == ftxui::Event::CtrlQ) + throw event::close{}; + else if (!is_waiting) + ui_->OnEvent(std::move(event)); + } + catch (const confirmed&) + { throw event::close{}; } + catch (const event::close&) + { + if (!overlay_ && !is_waiting) + throw; + if (overlay_) + overlay_->Detach(); + else + closing_ = true; + overlay_.reset(); + } + return true; + } + + ftxui::Element OnRender() override final + { + bool animate = false; + if (oa_.valid() || tx_.valid()) + { + animate = true; + if (oa_.valid() && oa_.wait_for(std::chrono::seconds{0}) == std::future_status::ready) + { + auto oa = oa_.get(); + error_.reset(); + + if (!oa) + error_ = ftxui::text(_("dnssec failure for domain")); + else if (oa->first.empty()) + error_ = ftxui::text(_("No XMR OpenAlias found")); + else if (!lwsf::addressValid(oa->first, wal_->nettype())) + error_ = ftxui::text(_("OpenAlias record is invalid")); + else if (!closing_) + { + oa->second->second = std::move(oa->first); + try_construct(); + } + + animate = oa_.valid(); + } + else if (tx_.valid() && tx_.wait_for(std::chrono::seconds{0}) == std::future_status::ready) + { + animate = false; + auto tx = tx_.get(); + if (std::get<0>(tx)) + overlay_ = confirm(std::move(std::get<0>(tx)), std::move(std::get<1>(tx))); + else + error_ = ftxui::text(std::move(std::get<2>(tx))); + } + + if (animate) + { + char const* const label = oa_.valid() ? + _(" OpenAlias Lookup ") : _(" Constructing Transaction "); + animation_ = (animation_ + 1) % spinner.size(); + error_ = ftxui::text(std::string{spinner[animation_]} + label + spinner[animation_]); + ftxui::animation::RequestAnimationFrame(); + } + else + { + ftxui::ScreenInteractive* const active = ftxui::ScreenInteractive::Active(); + if (active) + active->PostEvent(event::send_async); + } + } + + if (!overlay_) + { + ftxui::Elements rows; + rows.reserve(3); // dests_ui_.size() + 2); + + if (!animate) + rows.push_back(buttons_->Render() | ftxui::hcenter); + + if (error_) + rows.push_back(decorate::banner(error_) | ftxui::inverted); + else + rows.push_back(ftxui::separator()); + + if (!closing_) + { + std::vector> grid; + grid.reserve(dests_ui_.size()); + for (const auto& e : dests_ui_) + { + ftxui::Elements row; + row.reserve(6); + row.push_back(std::get<0>(e)->Render()); + row.push_back(ftxui::text(" XMR to ")); + row.push_back(std::get<1>(e)->Render()); + if (!animate) + { + row.push_back(ftxui::separator()); + row.push_back(std::get<2>(e)->Render()); + row.push_back(std::get<3>(e)->Render()); + } + grid.push_back(std::move(row)); + } + rows.push_back(ftxui::gridbox(std::move(grid))); + } + else + rows.push_back(ftxui::text(_("...Cleaning Up...")) | ftxui::hcenter); + + cached_ = ftxui::window(title_, ftxui::vbox(std::move(rows))); + return cached_; + } + return ftxui::dbox(cached_, decorate::overlay(overlay_->Render())); + } + }; + } + + ftxui::Component send(std::shared_ptr wm, std::shared_ptr wal, const std::uint32_t account) + { + if (!wal) + throw std::invalid_argument{"views::send cannot be given nullptr"}; + return std::make_shared(std::move(wm), std::move(wal), account); + } +}} // lwcli // view diff --git a/src/views/send.h b/src/views/send.h new file mode 100644 index 0000000..976fa7a --- /dev/null +++ b/src/views/send.h @@ -0,0 +1,44 @@ +// Copyright (c) 2025, Cifro Codes LLC +// +// 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 +// 9sMNg6xAhC15Y2r51xthUaHCuKDiFaojSBaSPUrAeBosZBYahFEii7dDq6y3pgaNUzBhvKmxPpWdnPquzsVLkDWB9JM3tiS +namespace Monero +{ + class Wallet; + class WalletManager; +} +namespace lwcli { namespace view +{ + //! Shows Transaction History + ftxui::Component send(std::shared_ptr wm, std::shared_ptr wallet, std::uint32_t account); +}} // lwscli // view + diff --git a/src/views/settings.cpp b/src/views/settings.cpp index 30cd97a..e3f2e2a 100644 --- a/src/views/settings.cpp +++ b/src/views/settings.cpp @@ -47,13 +47,12 @@ namespace lwcli { namespace view { bool set_proxy(Monero::Wallet& wal, const std::string& proxy) { - wal.setProxy(proxy); - return true; + return wal.setProxy(proxy); } bool set_url(Monero::Wallet& wal, const std::string& url) { - const bool is_ssl = bool(from_string(wal.getCacheAttribute(std::string{config::server::ssl}))); + const bool is_ssl = bool(from_string(wal.getCacheAttribute(std::string{config::server::ssl})).value_or(0)); wal.init(url, 0, "", "", is_ssl, true, wal.getCacheAttribute(std::string{config::server::proxy})); return true; } diff --git a/src/views/wallet.cpp b/src/views/wallet.cpp index 30779c6..803020e 100644 --- a/src/views/wallet.cpp +++ b/src/views/wallet.cpp @@ -41,6 +41,7 @@ #include "translate.h" #include "views/accounts.h" #include "views/history.h" +#include "views/send.h" #include "views/settings.h" namespace lwcli { namespace view @@ -51,6 +52,7 @@ namespace lwcli { namespace view struct wallet_state { + const std::shared_ptr wm; const std::shared_ptr wal; ftxui::Component overlay; std::uint32_t selected_account = 0; @@ -60,8 +62,7 @@ namespace lwcli { namespace view { const std::shared_ptr wal = state->wal; return ftxui::Container::Horizontal({ - ftxui::Button("[s]end", [] () {}, ascii()), - ftxui::Button("[b]ook", [] () {}, ascii()), + ftxui::Button("[s]end", [state] () { state->overlay = send(state->wm, state->wal, state->selected_account); }, ascii()), ftxui::Button("[a]ccounts", [state] () { state->overlay = accounts(state->wal, &state->selected_account); }, ascii()), ftxui::Button("[r]efresh", [wal] () { wal->refreshAsync(); }, ascii()), ftxui::Button("s[e]ttings", [state] () { state->overlay = settings(state->wal); }, ascii()) @@ -108,9 +109,9 @@ namespace lwcli { namespace view } public: - explicit wallet_(std::shared_ptr&& data) + explicit wallet_(std::shared_ptr&& wm, std::shared_ptr&& data) : ftxui::ComponentBase(), - state_{std::move(data)}, + state_{std::move(wm), std::move(data)}, title_(nullptr), active_account_(-1), bar_(), @@ -121,7 +122,7 @@ namespace lwcli { namespace view throw std::runtime_error{"Unexpected nullptr Monero wallet"}; state_.wal->setListener(this); bar_ = menu_bar(&state_); - title_ = ftxui::text(_("lwcli Wallet (Primary ") + state_.wal->mainAddress() + ")"); + title_ = ftxui::text(_("lwcli Wallet (Primary ") + state_.wal->mainAddress().substr(0, 40) + "...)"); update_account(); } @@ -158,9 +159,7 @@ namespace lwcli { namespace view else if (!ui_->OnEvent(event)) { if (event == ftxui::Event::s || event == ftxui::Event::S) - ; // state_.overlay = send(state_.wal); - else if (event == ftxui::Event::b || event == ftxui::Event::B) - ; // state_.overlay = book(state_.wal); + state_.overlay = send(state_.wm, state_.wal, state_.selected_account); else if (event == ftxui::Event::a || event == ftxui::Event::a) state_.overlay = accounts(state_.wal, &state_.selected_account); else if (event == ftxui::Event::r || event == ftxui::Event::R) @@ -214,8 +213,8 @@ namespace lwcli { namespace view }; } // anonymous - ftxui::Component wallet(std::shared_ptr data) + ftxui::Component wallet(std::shared_ptr wm, std::shared_ptr data) { - return std::make_shared(std::move(data)); + return std::make_shared(std::move(wm), std::move(data)); } }} // lwcli // view diff --git a/src/views/wallet.h b/src/views/wallet.h index 7f1fcce..4495d14 100644 --- a/src/views/wallet.h +++ b/src/views/wallet.h @@ -30,9 +30,14 @@ #include #include -namespace Monero { class Wallet; } +namespace Monero +{ + class Wallet; + class WalletManager; +} + namespace lwcli { namespace view { - ftxui::Component wallet(std::shared_ptr wallet); + ftxui::Component wallet(std::shared_ptr wm, std::shared_ptr wallet); }} // lwscli // view