diff --git a/utility/cli/options.cpp b/utility/cli/options.cpp index 1ec70ae28d..74ddc3d73c 100644 --- a/utility/cli/options.cpp +++ b/utility/cli/options.cpp @@ -16,6 +16,8 @@ #include #include +#include +#include #include "core/block_crypt.h" #include "core/ecc.h" #include "utility/string_helpers.h" @@ -150,6 +152,10 @@ namespace beam const char* NEW_ADDRESS_COMMENT = "comment"; const char* EXPIRATION_TIME = "expiration_time"; const char* SEND = "send"; + const char* SLATEPACK = "slatepack"; + const char* SLATEPACK_FILE = "slatepack_file"; + const char* SLATEPACK_SAVE = "save"; + const char* SLATEPACK_EXPORT = "slatepack_export"; const char* INFO = "info"; const char* TX_HISTORY = "tx_history"; const char* UTXO_LIST = "utxo_list"; @@ -464,6 +470,9 @@ namespace beam (cli::RECEIVER_ADDR_FULL, po::value(), "receiver address or token") (cli::NODE_ADDR_FULL, po::value(), "beam node address") (cli::WALLET_STORAGE, po::value()->default_value("wallet.db"), "path to the wallet database file") + (cli::SLATEPACK, po::bool_switch(), "with 'send': produce a Slatepack for manual copy-paste transfer instead of sending over SBBS") + (cli::SLATEPACK_FILE, po::value(), "with 'slatepack': path to a Slatepack file to import (omit to paste interactively)") + (cli::SLATEPACK_SAVE, po::value()->implicit_value(""), "with 'send'/'slatepack'/'slatepack_export': save the produced pack to a file (bare = /.slatepack, or =)") (cli::CONFIRMATIONS_COUNT, po::value>()->default_value(Nonnegative(0)), "count of confirmations before you can't spend coin") (cli::TX_HISTORY, "print transaction history (should be used with info command)") (cli::UTXO_LIST, "print the list of UTXOs (should be used with info command)") @@ -993,6 +1002,34 @@ namespace beam return read_secret_impl(pass, "Enter password: ", cli::PASS, vm); } + bool read_slatepack(std::string& out, const po::variables_map& vm) + { + out.clear(); + if (vm.count(cli::SLATEPACK_FILE)) + { + std::ifstream f(vm[cli::SLATEPACK_FILE].as()); + if (!f) + return false; + std::stringstream ss; + ss << f.rdbuf(); + out = ss.str(); + } + else + { + std::cout << "Paste the Slatepack:" << std::endl; + std::string line; + while (std::getline(std::cin, line)) + { + out += line; + out += '\n'; + // The armor is self-delimiting; stop as soon as the end marker arrives. + if (out.find("ENDSLATEPACK.") != std::string::npos) + break; + } + } + return !out.empty(); + } + bool confirm_wallet_pass(const SecString& pass) { SecString passConfirm; diff --git a/utility/cli/options.h b/utility/cli/options.h index 963e393d0d..509f5aec6f 100644 --- a/utility/cli/options.h +++ b/utility/cli/options.h @@ -120,6 +120,10 @@ namespace beam extern const char* PAYMENT_PROOF_VERIFY; extern const char* PAYMENT_PROOF_DATA; extern const char* SEND; + extern const char* SLATEPACK; + extern const char* SLATEPACK_FILE; + extern const char* SLATEPACK_SAVE; + extern const char* SLATEPACK_EXPORT; extern const char* INFO; extern const char* NEW_ADDRESS_COMMENT; extern const char* EXPIRATION_TIME; @@ -493,6 +497,7 @@ namespace beam void SetNetworkStrict(Rules&, const std::string&); bool read_wallet_pass(SecString& pass, const po::variables_map& vm); + bool read_slatepack(std::string& out, const po::variables_map& vm); bool confirm_wallet_pass(const SecString& pass); void read_password(const char* prompt, beam::SecString& out); diff --git a/wallet/cli/cli.cpp b/wallet/cli/cli.cpp index ecc2d5a538..64f134fd56 100644 --- a/wallet/cli/cli.cpp +++ b/wallet/cli/cli.cpp @@ -20,6 +20,7 @@ #include "wallet/core/wallet_db.h" #include "wallet/core/wallet_network.h" #include "wallet/core/simple_transaction.h" +#include "wallet/core/slatepack_endpoint.h" #include "wallet/core/secstring.h" #include "wallet/core/strings_resources.h" #include "wallet/core/contracts/shaders_manager.h" @@ -72,6 +73,7 @@ #include #include #include +#include #include #include @@ -126,6 +128,37 @@ namespace beam namespace { + // The CLI runs one command per process against one wallet, so a single + // process-wide handle to the manual-transport endpoint is safe. Mirrors + // WalletClient's m_slatepackEndpoint weak_ptr member. + std::weak_ptr g_cliSlatepack; + + // Emit a produced/stored Slatepack: print it, or write it to a file when --save is given + // (bare --save -> /.slatepack, --save=, or --save=). + void OutputSlatepack(const po::variables_map& vm, const TxID& txID, const std::string& armored) + { + if (vm.count(cli::SLATEPACK_SAVE)) + { + namespace fs = boost::filesystem; + const std::string opt = vm[cli::SLATEPACK_SAVE].as(); + const std::string name = std::to_string(txID) + ".slatepack"; + fs::path out; + if (opt.empty()) // bare --save -> wallet dir + out = fs::path(vm[cli::WALLET_STORAGE].as()).parent_path() / name; + else if (fs::is_directory(opt)) // --save= + out = fs::path(opt) / name; + else // --save= + out = fs::path(opt); + std::ofstream f(out.string(), std::ios::binary | std::ios::trunc); + f << armored; + std::cout << "Saved Slatepack to " << fs::absolute(out).string() << std::endl; + } + else + { + std::cout << armored << std::endl; + } + } + std::string interpretStatusCliImpl(const beam::wallet::TxDescription& tx) { #ifdef BEAM_ATOMIC_SWAP_SUPPORT @@ -1846,6 +1879,33 @@ namespace return 0; } + int ExportSlatepack(const po::variables_map& vm) + { + auto txId = GetTxID(vm); + if (!txId) + { + return -1; + } + + auto walletDB = OpenDataBase(vm); + if (!walletDB->getTx(*txId)) + { + BEAM_LOG_ERROR() << boost::format(kErrorTxWithIdNotFound) % vm[cli::TX_ID].as(); + return -1; + } + + // Read the armored Slatepack the send/reply step stored on the tx (see SlatepackEndpoint). + std::string armored; + if (!storage::getTxParameter(*walletDB, *txId, TxParameterID::SlatepackOutgoing, armored) || armored.empty()) + { + BEAM_LOG_ERROR() << "No stored Slatepack for this transaction (only manually-transported sends keep one, and it is dropped on cancel)."; + return -1; + } + + OutputSlatepack(vm, *txId, armored); + return 0; + } + int VerifyPaymentProof(const po::variables_map& vm) { const auto& pprofData = vm[cli::PAYMENT_PROOF_DATA]; @@ -2395,6 +2455,19 @@ namespace auto wnet = make_shared(*wallet, nnet, walletDB); wallet->AddMessageEndpoint(wnet); + + // Manual (Slatepack) transport: prints the produced pack, or saves it to a + // file when --save is given, then stops the reactor. Only fires for + // ManualTransport-flagged txs, so normal sends are unaffected. + auto slatepackEndpoint = make_shared(*wallet, walletDB, + [&vm](const TxID& txID, const std::string& armored) + { + OutputSlatepack(vm, txID, armored); + io::Reactor::get_Current().stop(); + }); + wallet->AddMessageEndpoint(slatepackEndpoint); + g_cliSlatepack = slatepackEndpoint; + wallet->SetNodeEndpoint(nnet); wallet->ResumeAllTransactions(); @@ -2451,11 +2524,76 @@ namespace .SetParameter(TxParameterID::AssetID, assetId) .SetParameter(TxParameterID::PreselectedCoins, GetPreselectedCoinIDs(vm)); + if (vm.count(cli::SLATEPACK) && vm[cli::SLATEPACK].template as()) + params.SetParameter(TxParameterID::ManualTransport, true); + currentTxID = wallet->StartTransaction(params); return 0; }); } + int Slatepack(const po::variables_map& vm) + { + return DoWalletFunc(vm, [](auto&& vm, auto&& wallet, auto&& walletDB, auto& currentTxID) -> int + { + std::string armored; + if (!read_slatepack(armored, vm)) + { + std::cout << "No Slatepack provided." << std::endl; + return -1; + } + + auto ep = g_cliSlatepack.lock(); + if (!ep) + { + std::cout << "Slatepack transport not available." << std::endl; + return -1; + } + + std::string error; + SlatepackEndpoint::ImportInfo info; + if (!ep->Preview(armored, error, info)) + { + std::cout << "Cannot import Slatepack: " << error << std::endl; + return -1; + } + + std::cout << (info.m_IsSend ? "You are sending " : "You are receiving ") + << PrintableAmount(info.m_Amount, true, info.m_AssetID) + << (info.m_IsSend ? " to " : " from ") + << (info.m_IsSend ? info.m_AddressTo : info.m_AddressFrom) << "\n" + << "Fee: " << PrintableAmount(info.m_Fee, true, Asset::s_BeamID) << "\n" + << "Transaction: " << info.m_TxID << "\n" + << "Proceed? (y/n)" << std::endl; + + std::string s; + std::cin >> s; + if (s != "y" && s != "Y") + { + ep->CancelPending(info.m_TxID); + std::cout << "Cancelled." << std::endl; + return -1; // non-zero: skips DoWalletFunc's reactor.run(), exits cleanly + } + + // Track this tx so onTxCompleteAction stops the reactor when it finalizes + // (the reply case is stopped earlier by the outgoing handler instead). + auto txIdVec = from_hex(info.m_TxID); + if (txIdVec.size() >= 16) + { + TxID t; + std::copy_n(txIdVec.begin(), 16, t.begin()); + currentTxID = t; + } + + if (!ep->Commit(info.m_TxID, error)) + { + std::cout << "Cannot process Slatepack: " << error << std::endl; + return -1; + } + return 0; // reactor runs: outgoing handler (reply) or tx-completed (finalize) stops it + }); + } + void CompileShader(ByteBuffer& res, const char* sz, bvm2::Processor::Kind kind, Wasm::Compiler::DebugInfo* pDbgInfo = nullptr) { std::FStream fs; @@ -3349,6 +3487,8 @@ int main(int argc, char* argv[]) {cli::HID_ENUM, EnumHid, "Enumerate attached HW wallets"}, {cli::HID_INSTALL, HidInstall, "Install Beam app on the attached HW wallet"}, {cli::SEND, Send, "send BEAM"}, + {cli::SLATEPACK, Slatepack, "import a Slatepack: review a received one (produces the reply) or finalize a reply"}, + {cli::SLATEPACK_EXPORT, ExportSlatepack, "re-print the stored outgoing Slatepack for a transaction (--tx_id; --save optional)"}, {cli::SHADER_INVOKE, ShaderInvoke, "Invoke a wallet-side shader"}, {cli::SHADER_WIDGET, ShaderWidget, "Set the wallet widget shader"}, {cli::LISTEN, Listen, "listen to the node (the wallet won't close till halted"}, diff --git a/wallet/client/wallet_client.cpp b/wallet/client/wallet_client.cpp index 456004e4ea..bd7261b4d8 100644 --- a/wallet/client/wallet_client.cpp +++ b/wallet/client/wallet_client.cpp @@ -14,6 +14,7 @@ #include "wallet_client.h" #include "wallet/core/simple_transaction.h" +#include "wallet/core/slatepack_endpoint.h" #ifdef BEAM_ASSET_SWAP_SUPPORT #include "wallet/transactions/dex/dex_tx.h" #endif // BEAM_ASSET_SWAP_SUPPORT @@ -341,6 +342,21 @@ struct WalletModelBridge : public Bridge call_async(&IWalletModelAsync::exportPaymentProof, id); } + void importSlatepack(const std::string& text) override + { + call_async(&IWalletModelAsync::importSlatepack, text); + } + + void commitSlatepack(const std::string& txId) override + { + call_async(&IWalletModelAsync::commitSlatepack, txId); + } + + void cancelSlatepack(const std::string& txId) override + { + call_async(&IWalletModelAsync::cancelSlatepack, txId); + } + void checkNetworkAddress(const std::string& addr) override { call_async(&IWalletModelAsync::checkNetworkAddress, addr); @@ -729,6 +745,14 @@ namespace beam::wallet wallet->SetNodeEndpoint(nodeNetwork); wallet->AddMessageEndpoint(walletNetwork); + auto slatepackEndpoint = make_shared(*wallet, m_walletDB, + [this](const TxID& txID, const std::string& armored) + { + onSlatepackReady(txID, armored); + }); + m_slatepackEndpoint = slatepackEndpoint; + wallet->AddMessageEndpoint(slatepackEndpoint); + wallet->ResumeAllTransactions(); updateMaxPrivacyStatsImpl(getStatus()); @@ -2116,6 +2140,30 @@ namespace beam::wallet onPaymentProofExported(id, storage::ExportPaymentProof(*m_walletDB, id)); } + void WalletClient::importSlatepack(const std::string& text) + { + // Runs on the wallet thread; decrypts a pasted Slatepack and previews it. The tx does + // not proceed until the user confirms via commitSlatepack. + std::string error; + SlatepackEndpoint::ImportInfo info; + auto ep = m_slatepackEndpoint.lock(); + const bool ok = ep && ep->Preview(text, error, info); + onSlatepackImportResult(ok, error, info); + } + + void WalletClient::commitSlatepack(const std::string& txId) + { + std::string error; + if (auto ep = m_slatepackEndpoint.lock()) + ep->Commit(txId, error); + } + + void WalletClient::cancelSlatepack(const std::string& txId) + { + if (auto ep = m_slatepackEndpoint.lock()) + ep->CancelPending(txId); + } + void WalletClient::checkNetworkAddress(const std::string& addr) { io::Address nodeAddr; diff --git a/wallet/client/wallet_client.h b/wallet/client/wallet_client.h index 6a63857592..24714a5ccc 100644 --- a/wallet/client/wallet_client.h +++ b/wallet/client/wallet_client.h @@ -23,6 +23,7 @@ #include "wallet/core/private_key_keeper.h" #include "wallet/core/common_utils.h" #include "wallet/core/contracts/i_shaders_manager.h" +#include "wallet/core/slatepack_endpoint.h" #include "wallet_model_async.h" #include "changes_collector.h" #include "extensions/notifications/notification_observer.h" @@ -59,6 +60,8 @@ namespace beam namespace beam::wallet { + class SlatepackEndpoint; + constexpr char SEED_PARAM_NAME[] = "SavedSeed"; #ifdef BEAM_ASSET_SWAP_SUPPORT constexpr char ASSET_SWAP_PARAMS_NAME[] = "LastAssetSwapParams"; @@ -204,6 +207,10 @@ namespace beam::wallet virtual void onCantSendToExpired() {} virtual void onPaymentProofExported(const TxID& txID, const ByteBuffer& proof) {} virtual void onCoinsByTx(const std::vector& coins) {} + // A manually-transported (Slatepack) negotiation message is ready to hand off, and the + // result of importing a pasted Slatepack. + virtual void onSlatepackReady(const TxID& txID, const std::string& armored) {} + virtual void onSlatepackImportResult(bool ok, const std::string& error, const SlatepackEndpoint::ImportInfo& info) {} virtual void onAddressChecked(const std::string& addr, bool isValid) {} virtual void onImportRecoveryProgress(uint64_t done, uint64_t total) {} virtual void onNoDeviceConnected() {} @@ -324,6 +331,9 @@ namespace beam::wallet void rescan() override; void exportPaymentProof(const TxID& id) override; + void importSlatepack(const std::string& text) override; + void commitSlatepack(const std::string& txId) override; + void cancelSlatepack(const std::string& txId) override; void checkNetworkAddress(const std::string& addr) override; void importRecovery(const std::string& path) override; void importDataFromJson(const std::string& data) override; @@ -415,6 +425,7 @@ namespace beam::wallet IWalletModelAsync::Ptr m_async; std::weak_ptr m_nodeNetwork; std::weak_ptr m_walletNetwork; + std::weak_ptr m_slatepackEndpoint; std::weak_ptr m_wallet; #ifdef BEAM_IPFS_SUPPORT diff --git a/wallet/client/wallet_model_async.h b/wallet/client/wallet_model_async.h index 9b87062681..5b02e35d80 100644 --- a/wallet/client/wallet_model_async.h +++ b/wallet/client/wallet_model_async.h @@ -100,6 +100,9 @@ namespace beam::wallet virtual void getNetworkStatus() = 0; virtual void rescan() = 0; virtual void exportPaymentProof(const TxID& id) = 0; + virtual void importSlatepack(const std::string& text) = 0; + virtual void commitSlatepack(const std::string& txId) = 0; + virtual void cancelSlatepack(const std::string& txId) = 0; virtual void checkNetworkAddress(const std::string& addr) = 0; virtual void importRecovery(const std::string& path) = 0; diff --git a/wallet/core/CMakeLists.txt b/wallet/core/CMakeLists.txt index 2fdc638115..fd9f840e01 100644 --- a/wallet/core/CMakeLists.txt +++ b/wallet/core/CMakeLists.txt @@ -17,6 +17,8 @@ target_sources(wallet_core node_network.cpp wallet_db.cpp base58.cpp + slatepack.cpp + slatepack_endpoint.cpp version.cpp exchange_rate.cpp currency.cpp @@ -33,6 +35,8 @@ target_sources(wallet_core simple_transaction.h base_transaction.h private_key_keeper.h + slatepack.h + slatepack_endpoint.h ) target_link_libraries(wallet_core diff --git a/wallet/core/common.h b/wallet/core/common.h index 94862dba32..a9cdac7a5f 100644 --- a/wallet/core/common.h +++ b/wallet/core/common.h @@ -343,6 +343,8 @@ namespace beam::wallet MACRO(AssetMetadata, 116, std::string)\ MACRO(DexOrderID, 117, DexOrderID) \ MACRO(ExternalDexOrderID, 118, DexOrderID) \ + /* routes negotiation via Slatepack armor instead of SBBS; public so the peer responds manually too */ \ + MACRO(ManualTransport, 119, bool) \ MACRO(ExchangeRates, 120, std::vector) \ MACRO(OriginalToken, 121, std::string) \ /* Lelantus */ \ @@ -385,6 +387,10 @@ namespace beam::wallet UserConfirmationToken = 143, + // Manual transport (Slatepack): last armored negotiation message produced for this tx, + // kept so the user can re-copy/re-save it after dismissing the produce dialog. + SlatepackOutgoing = 144, + Status = 151, KernelID = 152, MyAddressID = 158, // in case the address used in the tx is eventually deleted, the user should still be able to prove it was owned diff --git a/wallet/core/simple_transaction.cpp b/wallet/core/simple_transaction.cpp index ce69a62361..369b981e20 100644 --- a/wallet/core/simple_transaction.cpp +++ b/wallet/core/simple_transaction.cpp @@ -89,6 +89,12 @@ namespace beam::wallet .AddParameter(TxParameterID::PeerProtoVersion, s_ProtoVersion) .AddParameter(TxParameterID::PeerMaxHeight, hMax); + // Propagate manual (Slatepack) transport so the peer routes its replies the same way + // instead of falling back to SBBS. + bool manualTransport = false; + if (GetParameter(TxParameterID::ManualTransport, manualTransport) && manualTransport) + msg.AddParameter(TxParameterID::ManualTransport, true); + if (m_IsSender) { msg @@ -131,6 +137,7 @@ namespace beam::wallet case TxParameterID::Lifetime: case TxParameterID::PaymentConfirmation: case TxParameterID::PeerProtoVersion: + case TxParameterID::ManualTransport: // peer tells us to route replies via Slatepack, not SBBS case TxParameterID::MyEndpoint: case TxParameterID::PeerEndpoint: case TxParameterID::PeerMaxHeight: diff --git a/wallet/core/slatepack.cpp b/wallet/core/slatepack.cpp new file mode 100644 index 0000000000..181dc7a62e --- /dev/null +++ b/wallet/core/slatepack.cpp @@ -0,0 +1,138 @@ +// Copyright 2018-2026 The Beam Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "wallet/core/slatepack.h" +#include "wallet/core/base58.h" +#include "core/ecc_native.h" // ECC::Hash, Blob +#include "utility/serialize.h" +#include + +namespace beam::wallet::slatepack +{ + namespace + { + const char kBegin[] = "BEGINSLATEPACK."; + const char kEnd[] = "ENDSLATEPACK."; + const size_t kWordLen = 5; + const size_t kWordsPerLine = 12; + const size_t kHeaderSize = 2; // version + type + const size_t kChecksumSize = 4; + + // 4-byte error-detection code: leading bytes of double-SHA256 over 'data'. + void ComputeChecksum(const ByteBuffer& data, uint8_t out[kChecksumSize]) + { + ECC::Hash::Value hv; + ECC::Hash::Processor() << Blob(data.data(), static_cast(data.size())) >> hv; + ECC::Hash::Processor() << hv >> hv; + std::memcpy(out, hv.m_pData, kChecksumSize); + } + } + + std::string Armor(PayloadType type, const ByteBuffer& payload) + { + ByteBuffer body; + body.reserve(kHeaderSize + payload.size() + kChecksumSize); + body.push_back(kVersion); + body.push_back(static_cast(type)); + body.insert(body.end(), payload.begin(), payload.end()); + + uint8_t cs[kChecksumSize]; + ComputeChecksum(body, cs); + body.insert(body.end(), cs, cs + kChecksumSize); + + const std::string b58 = EncodeToBase58(body); + + std::string out(kBegin); + out += ' '; + size_t words = 0; + for (size_t i = 0; i < b58.size(); i += kWordLen) + { + out += b58.substr(i, kWordLen); + out += (++words % kWordsPerLine == 0) ? '\n' : ' '; + } + if (!out.empty() && (out.back() == ' ' || out.back() == '\n')) + out.pop_back(); + out += ". "; + out += kEnd; + return out; + } + + bool Unarmor(const std::string& text, PayloadType& type, ByteBuffer& payload, std::string& error) + { + const size_t begin = text.find(kBegin); + if (begin == std::string::npos) { error = "not a Slatepack"; return false; } + const size_t bodyStart = begin + std::strlen(kBegin); + const size_t end = text.find(kEnd, bodyStart); + if (end == std::string::npos) { error = "not a Slatepack"; return false; } + + // Reassemble the base58 body, dropping the whitespace and '.' separators the armor + // inserted for readability. + std::string b58; + b58.reserve(end - bodyStart); + for (size_t i = bodyStart; i < end; ++i) + { + const char c = text[i]; + if (c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '.') continue; + b58 += c; + } + + const ByteBuffer body = DecodeBase58(b58); + if (body.size() < kHeaderSize + kChecksumSize) { error = "damaged Slatepack (encoding)"; return false; } + + const ByteBuffer data(body.begin(), body.end() - kChecksumSize); + uint8_t cs[kChecksumSize]; + ComputeChecksum(data, cs); + if (std::memcmp(cs, body.data() + body.size() - kChecksumSize, kChecksumSize) != 0) + { + error = "damaged Slatepack (checksum)"; + return false; + } + + if (data[0] != kVersion) { error = "Slatepack is from a newer wallet version"; return false; } + if (data[1] < static_cast(PayloadType::TxNegotiation) || + data[1] > static_cast(PayloadType::ProofOfFunds)) + { + error = "unknown Slatepack type"; + return false; + } + + type = static_cast(data[1]); + payload.assign(data.begin() + kHeaderSize, data.end()); + return true; + } + + ByteBuffer ToBytes(const TxNegotiation& n) + { + Serializer ser; + ser & n; + const SerializeBuffer sb = ser.buffer(); + const uint8_t* p = reinterpret_cast(sb.first); + return ByteBuffer(p, p + sb.second); + } + + bool FromBytes(TxNegotiation& n, const ByteBuffer& b) + { + try + { + Deserializer der; + der.reset(b.data(), b.size()); + der & n; + return true; + } + catch (const std::exception&) + { + return false; + } + } +} diff --git a/wallet/core/slatepack.h b/wallet/core/slatepack.h new file mode 100644 index 0000000000..06af33b636 --- /dev/null +++ b/wallet/core/slatepack.h @@ -0,0 +1,58 @@ +// Copyright 2018-2026 The Beam Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "wallet/core/common.h" // WalletID, ByteBuffer +#include + +// Slatepack: an armored, copy-pastable envelope for node-independent transport of wallet +// data — base58 framing + a checksum and version/type discriminators over a binary payload. +namespace beam::wallet::slatepack +{ + static const uint8_t kVersion = 1; + + enum class PayloadType : uint8_t + { + TxNegotiation = 1, // an in-flight transaction-negotiation message (P1) + KeyBundle = 2, // view key / watch bundle (reserved, P2+) + ProofOfFunds = 3, // point-in-time balance attestation (reserved, P4) + }; + + // Wrap a payload as BEGINSLATEPACK. . ENDSLATEPACK. — the body is + // version(1) + type(1) + payload + checksum(4), base58-encoded. + std::string Armor(PayloadType type, const ByteBuffer& payload); + + // Extract+validate the first BEGINSLATEPACK..ENDSLATEPACK block in text (tolerating + // surrounding words and reflowed whitespace). On success fills 'type'/'payload' and returns + // true; on failure returns false with a short user-facing reason in 'error'. + bool Unarmor(const std::string& text, PayloadType& type, ByteBuffer& payload, std::string& error); + + // A queued negotiation message routed manually instead of over SBBS: peer WalletID plus the + // already SBBS-encrypted body (armor adds no crypto — a public Slatepack reveals only type/size). + struct TxNegotiation + { + WalletID m_Peer; + ByteBuffer m_Ciphertext; + + template + void serialize(Archive& ar) + { + ar & m_Peer & m_Ciphertext; + } + }; + + ByteBuffer ToBytes(const TxNegotiation&); + bool FromBytes(TxNegotiation&, const ByteBuffer&); +} diff --git a/wallet/core/slatepack_endpoint.cpp b/wallet/core/slatepack_endpoint.cpp new file mode 100644 index 0000000000..0601230871 --- /dev/null +++ b/wallet/core/slatepack_endpoint.cpp @@ -0,0 +1,218 @@ +// Copyright 2018-2026 The Beam Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "wallet/core/slatepack_endpoint.h" +#include "wallet/core/slatepack.h" +#include "utility/logger.h" + +namespace beam::wallet +{ + SlatepackEndpoint::SlatepackEndpoint(IWalletMessageConsumer& wallet, const IWalletDB::Ptr& walletDB, OutgoingHandler handler) + : BaseMessageEndpoint(wallet, walletDB) + , m_WalletDB(walletDB) + , m_OnOutgoing(std::move(handler)) + { + Subscribe(); + m_WalletDB->Subscribe(this); + + // Drain Slatepacks queued while the wallet couldn't decrypt them (read-only path). A hot + // wallet decrypts inline, so this is normally empty. + for (const auto& m : m_WalletDB->getIncomingWalletMessages()) + { + proto::BbsMsg msg; + msg.m_Channel = m.m_Channel; + msg.m_TimePosted = getTimestamp(); + msg.m_Message = m.m_Message; + ProcessMessage(msg); + m_WalletDB->deleteIncomingWalletMessage(m.m_ID); + } + } + + SlatepackEndpoint::~SlatepackEndpoint() + { + try + { + m_WalletDB->Unsubscribe(this); + Unsubscribe(); + } + catch (const std::exception& e) + { + BEAM_LOG_UNHANDLED_EXCEPTION() << "what = " << e.what(); + } + catch (...) + { + BEAM_LOG_UNHANDLED_EXCEPTION(); + } + } + + bool SlatepackEndpoint::AcceptsMessage(const TxID& txID) + { + // Inverse of the base endpoint: handle ONLY transactions flagged for manual transport. + bool isManual = false; + storage::getTxParameter(*m_WalletDB, txID, TxParameterID::ManualTransport, isManual); + if (isManual) + // Stash the txID for the SendRawMessage that follows synchronously in this Send() + // call (it sees only the encrypted body, but must label the produced Slatepack). + m_CurrentTxID = txID; + return isManual; + } + + void SlatepackEndpoint::Send(const WalletID& peerID, const SetTxParameter& msg) + { + // A manual tx torn down (cancel/expire) notifies the peer with a FailureReason — nothing + // useful to hand-deliver, so don't armor it. Also drop the stored outgoing Slatepack: the + // tx is terminal, so its armored negotiation message is dead data. + for (const auto& p : msg.m_Parameters) + if (p.first == TxParameterID::FailureReason) + { + m_WalletDB->delTxParameter(m_CurrentTxID, kDefaultSubTxID, TxParameterID::SlatepackOutgoing); + return; + } + + m_LiveSend = true; + BaseMessageEndpoint::Send(peerID, msg); + m_LiveSend = false; + } + + void SlatepackEndpoint::SendRawMessage(const WalletID& peerID, ByteBuffer&& encrypted) + { + // Armor only a live send routed through Send() above. ProcessStoredMessages replays every + // stored SBBS message to every endpoint on startup; those aren't ours to armor. + if (!m_LiveSend) + return; + + slatepack::TxNegotiation n; + n.m_Peer = peerID; + n.m_Ciphertext = std::move(encrypted); + + const std::string armored = slatepack::Armor(slatepack::PayloadType::TxNegotiation, slatepack::ToBytes(n)); + + // Persist the latest outgoing Slatepack so the user can re-copy it after dismissing the + // produce dialog; survives a wallet restart (manual transfers are long-lived). Notify so + // the tx list reloads with the param now, not only on the next tx change (the peer reply). + storage::setTxParameter(*m_WalletDB, m_CurrentTxID, TxParameterID::SlatepackOutgoing, armored, true); + + if (m_OnOutgoing) + m_OnOutgoing(m_CurrentTxID, armored); + } + + bool SlatepackEndpoint::Preview(const std::string& armoredText, std::string& error, ImportInfo& info) + { + slatepack::PayloadType type; + ByteBuffer payload; + if (!slatepack::Unarmor(armoredText, type, payload, error)) + return false; + + if (type != slatepack::PayloadType::TxNegotiation) + { + error = "unsupported Slatepack type"; + return false; + } + + slatepack::TxNegotiation n; + if (!slatepack::FromBytes(n, payload)) + { + error = "damaged Slatepack (payload)"; + return false; + } + + proto::BbsMsg msg; + n.m_Peer.m_Channel.Export(msg.m_Channel); + msg.m_TimePosted = getTimestamp(); + msg.m_Message = std::move(n.m_Ciphertext); + + // Decrypt with a subscribed own-address key but do NOT hand it to the wallet yet — the + // user confirms first. A Slatepack none of our addresses can decrypt is for another wallet. + SetTxParameter decrypted; + WalletID myAddr = Zero; + if (!ProcessMessage(msg, &decrypted, &myAddr, false)) + { + error = "This Slatepack isn't addressed to your wallet."; + return false; + } + + info.m_TxID = std::to_string(decrypted.m_TxID); + + if (auto tx = m_WalletDB->getTx(decrypted.m_TxID)) + { + // Our side of the tx already exists (e.g. we sent S1 and are importing the reply). + info.m_Amount = tx->m_amount; + info.m_AssetID = tx->m_assetId; + info.m_Fee = tx->m_fee; + info.m_IsSend = tx->m_sender; + info.m_AddressFrom = tx->getAddressFrom(); + info.m_AddressTo = tx->getAddressTo(); + } + else + { + // First look at an incoming invitation — summarise straight from the message. + decrypted.GetParameter(TxParameterID::Amount, info.m_Amount); + decrypted.GetParameter(TxParameterID::AssetID, info.m_AssetID); + decrypted.GetParameter(TxParameterID::Fee, info.m_Fee); + info.m_IsSend = false; + info.m_AddressFrom = std::to_string(decrypted.m_From); + info.m_AddressTo = std::to_string(myAddr); + } + + // Hold the message until the user confirms (Commit) or discards (CancelPending). + m_PendingImports[info.m_TxID] = std::move(msg); + return true; + } + + bool SlatepackEndpoint::Commit(const std::string& txId, std::string& error) + { + auto it = m_PendingImports.find(txId); + if (it == m_PendingImports.end()) + { + error = "no pending Slatepack to confirm"; + return false; + } + ProcessMessage(it->second); // deliver to the wallet — the transaction proceeds + m_PendingImports.erase(it); + return true; + } + + void SlatepackEndpoint::CancelPending(const std::string& txId) + { + m_PendingImports.erase(txId); + } + + void SlatepackEndpoint::onAddressChanged(ChangeAction action, const std::vector& items) + { + // Keep the set of listening channels current as the user creates/expires addresses, + // mirroring WalletNetworkViaBbs so a receive address created after startup still works. + switch (action) + { + case ChangeAction::Added: + case ChangeAction::Updated: + for (const auto& address : items) + { + if (!address.isOwn()) + continue; + if (!address.isExpired()) + AddOwnAddress(address); + else + DeleteOwnAddress(address.m_BbsAddr); + } + break; + case ChangeAction::Removed: + for (const auto& address : items) + if (address.isOwn()) + DeleteOwnAddress(address.m_BbsAddr); + break; + default: + break; + } + } +} diff --git a/wallet/core/slatepack_endpoint.h b/wallet/core/slatepack_endpoint.h new file mode 100644 index 0000000000..a57ff4ecd6 --- /dev/null +++ b/wallet/core/slatepack_endpoint.h @@ -0,0 +1,81 @@ +// Copyright 2018-2026 The Beam Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "wallet/core/wallet_network.h" // BaseMessageEndpoint +#include "wallet/core/wallet_db.h" // IWalletDbObserver, IWalletDB, WalletAddress +#include +#include +#include + +namespace beam::wallet +{ + // Manual, node-independent transaction transport: queues each negotiation message and + // surfaces it as an armored Slatepack instead of posting to SBBS; pasted Slatepacks re-enter + // through the same path an SBBS message would. Handles only ManualTransport-flagged txs; the + // SBBS endpoint skips those (BaseMessageEndpoint::AcceptsMessage), so both can run at once. + class SlatepackEndpoint + : public BaseMessageEndpoint + , private IWalletDbObserver + { + public: + // Called on the wallet reactor thread when a new outgoing Slatepack is produced. + using OutgoingHandler = std::function; + + SlatepackEndpoint(IWalletMessageConsumer&, const IWalletDB::Ptr&, OutgoingHandler); + ~SlatepackEndpoint() override; + + // Structured summary of what an imported Slatepack contained, for the UI to render. + struct ImportInfo + { + Amount m_Amount = 0; + Asset::ID m_AssetID = 0; + Amount m_Fee = 0; + bool m_IsSend = false; // our role in the imported tx + std::string m_AddressFrom; // sender address + std::string m_AddressTo; // receiver address + std::string m_TxID; // hex transaction id + }; + + // Decrypt a pasted Slatepack and preview it WITHOUT continuing the transaction: fills + // 'info', stashes the message pending confirmation (keyed by info.m_TxID), returns true. + // A Slatepack none of our addresses can decrypt is a failure here, not a silent no-op. + bool Preview(const std::string& armoredText, std::string& error, ImportInfo& info); + + // Confirm a previewed Slatepack: hand the stashed message to the wallet so the + // transaction proceeds. Returns false if no pending import matches txId. + bool Commit(const std::string& txId, std::string& error); + + // Discard a previewed-but-unconfirmed Slatepack. + void CancelPending(const std::string& txId); + + private: + // BaseMessageEndpoint + bool AcceptsMessage(const TxID& txID) override; + void Send(const WalletID& peerID, const SetTxParameter& msg) override; + void SendRawMessage(const WalletID& peerID, ByteBuffer&&) override; + // IWalletDbObserver + void onAddressChanged(ChangeAction action, const std::vector& items) override; + + IWalletDB::Ptr m_WalletDB; + OutgoingHandler m_OnOutgoing; + TxID m_CurrentTxID = {}; + // True only during a live Send(), so SendRawMessage can tell a real negotiation message + // from a ProcessStoredMessages replay (which fans every stored SBBS message to all endpoints). + bool m_LiveSend = false; + // Slatepacks decrypted for preview, awaiting the user's confirm/cancel, keyed by txID. + std::map m_PendingImports; + }; +} diff --git a/wallet/core/wallet_network.cpp b/wallet/core/wallet_network.cpp index 0cbe73000a..9ed7cda847 100644 --- a/wallet/core/wallet_network.cpp +++ b/wallet/core/wallet_network.cpp @@ -57,10 +57,11 @@ namespace beam::wallet { DeleteAddr(m_Addresses.begin()->get_ParentObj()); } - void BaseMessageEndpoint::ProcessMessage(const proto::BbsMsg& msg) + bool BaseMessageEndpoint::ProcessMessage(const proto::BbsMsg& msg, SetTxParameter* pDecrypted, WalletID* pMyAddr, bool deliver) { Addr::Channel key; key.m_Value = msg.m_Channel; + bool delivered = false; for (ChannelSet::iterator it = m_Channels.lower_bound(key); ; ++it) { @@ -75,7 +76,7 @@ namespace beam::wallet { // read-only wallet m_WalletDB->saveIncomingWalletMessage(msg.m_Channel, msg.m_Message); OnIncomingMessage(); - return; + return true; } ByteBuffer buf = msg.m_Message; // duplicate, copy @@ -88,7 +89,10 @@ namespace beam::wallet { continue; if (x.m_Wid.m_pHandler) + { x.m_Wid.m_pHandler->OnMsg(Blob(pMsg, nSize)); + delivered = true; + } else { SetTxParameter msgWallet; @@ -106,11 +110,17 @@ namespace beam::wallet { if (bValid) { - m_Wallet.OnWalletMessage(it->get_ParentObj().m_Wid.m_Value, msgWallet); - break; + if (pDecrypted) + *pDecrypted = msgWallet; + if (pMyAddr) + *pMyAddr = it->get_ParentObj().m_Wid.m_Value; + if (deliver) + m_Wallet.OnWalletMessage(it->get_ParentObj().m_Wid.m_Value, msgWallet); + return true; } } } + return delivered; } BaseMessageEndpoint::Addr* BaseMessageEndpoint::CreateAddr(const WalletID& wid, IHandler* pHandler) @@ -209,11 +219,23 @@ namespace beam::wallet { return true; } + bool BaseMessageEndpoint::AcceptsMessage(const TxID& txID) + { + // An endpoint handles a tx unless it's flagged for manual (Slatepack) transport. + // Unknown/unflagged txs -> accepted, preserving existing SBBS behavior. + bool isManual = false; + storage::getTxParameter(*m_WalletDB, txID, TxParameterID::ManualTransport, isManual); + return !isManual; + } + void BaseMessageEndpoint::Send(const WalletID& peerID, const SetTxParameter& msg) { if (!m_pKdfSbbs) return; + if (!AcceptsMessage(msg.m_TxID)) + return; + Serializer ser; ser & msg; SerializeBuffer sb = ser.buffer(); diff --git a/wallet/core/wallet_network.h b/wallet/core/wallet_network.h index 9c1a2b504f..46353e9446 100644 --- a/wallet/core/wallet_network.h +++ b/wallet/core/wallet_network.h @@ -70,12 +70,23 @@ namespace beam::wallet void AddOwnAddress(const WalletAddress& address); void DeleteOwnAddress(const WalletID&); protected: - void ProcessMessage(const proto::BbsMsg& msg); + // Returns true if one of our subscribed addresses decrypted the message. pDecrypted/pMyAddr + // (if set) receive the decrypted message and the own address it targeted. deliver=false + // decrypts without handing to the wallet — used to preview a Slatepack import before confirm. + bool ProcessMessage(const proto::BbsMsg& msg, SetTxParameter* pDecrypted = nullptr, + WalletID* pMyAddr = nullptr, bool deliver = true); void Subscribe(); void Unsubscribe(); virtual void OnChannelAdded(BbsChannel channel) {}; virtual void OnChannelDeleted(BbsChannel channel) {}; virtual void OnIncomingMessage() {}; + + // Return false to skip a tx's messages. Base skips ManualTransport txs (Slatepack, not + // SBBS); SlatepackEndpoint overrides to the inverse. + virtual bool AcceptsMessage(const TxID& txID); + + // Protected so SlatepackEndpoint can wrap it (e.g. drop failure/cancel notifications). + void Send(const WalletID& peerID, const SetTxParameter& msg) override; private: Addr* FindAddr(const WalletID&, IHandler*); void DeleteAddr(const Addr&); @@ -84,7 +95,6 @@ namespace beam::wallet Addr* CreateAddr(const WalletID&, IHandler* ); // IWalletMessageEndpoint - void Send(const WalletID& peerID, const SetTxParameter& msg) override; void Send(const WalletID& peerID, const Blob&) override; void Listen(const WalletID&, const ECC::Scalar::Native&, IHandler*) override; void Unlisten(const WalletID&, IHandler*) override; diff --git a/wallet/unittests/CMakeLists.txt b/wallet/unittests/CMakeLists.txt index 1205a450b0..6287ae6bc6 100644 --- a/wallet/unittests/CMakeLists.txt +++ b/wallet/unittests/CMakeLists.txt @@ -30,6 +30,8 @@ configure_file("../../bvm/Shaders/pbft/pbft_stat.wasm" "${CMAKE_CURRENT_BINARY_D add_test_snippet(wallet_test wallet node mnemonic wallet_client wallet_api wallet_test_node) add_test_snippet(wallet_db_test wallet_core) +add_test_snippet(slatepack_test wallet_core) +add_test_snippet(slatepack_exchange_test wallet node mnemonic wallet_client wallet_api wallet_test_node) add_test_snippet(wallet_api_test wallet_api) add_test_snippet(wallet_assets_test core node wallet_core pow assets wallet_test_node) add_test_snippet(news_channels_test wallet_client node) diff --git a/wallet/unittests/slatepack_exchange_test.cpp b/wallet/unittests/slatepack_exchange_test.cpp new file mode 100644 index 0000000000..c7056cbd2c --- /dev/null +++ b/wallet/unittests/slatepack_exchange_test.cpp @@ -0,0 +1,168 @@ +// Copyright 2018-2026 The Beam Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// End-to-end test: two node-connected wallets complete a simple transfer over Slatepacks +// with NO SBBS endpoint. Proves the manual transport carries a full S1->S2->finalize +// negotiation when the only message endpoint is a SlatepackEndpoint. + +#ifndef LOG_VERBOSE_ENABLED + #define LOG_VERBOSE_ENABLED 0 +#endif + +#include "utility/logger.h" +#include "node/node.h" +#include "core/unittest/mini_blockchain.h" +#include "utility/test_helpers.h" +#include "test_helpers.h" +#include "wallet_test_node.h" +#include "wallet/core/slatepack_endpoint.h" +#include + +WALLET_TEST_INIT +#include "wallet_test_environment.cpp" + +using namespace beam; +using namespace beam::wallet; +using namespace std; + +// Declared in core/block_crypt.h but intentionally not defined in libcore (built with +// -fvisibility=hidden); every beam executable that uses Rules defines it in its own main TU. +thread_local const beam::Rules* beam::Rules::s_pInstance = nullptr; + +namespace +{ + void TestSlatepackExchange() + { + cout << "\nTesting Slatepack manual exchange (no SBBS)...\n"; + + io::Reactor::Ptr mainReactor{ io::Reactor::create() }; + io::Reactor::Scope scope(*mainReactor); + + int completed = 0; + auto onCompleted = [&](auto) { ++completed; mainReactor->stop(); }; + + TestNode node; + + // Regular rigs give funded, node-connected wallets (and an SBBS endpoint). We add a + // SlatepackEndpoint to each; the ManualTransport flag routes our tx to Slatepack while + // SBBS skips it (via AcceptsMessage), so this also exercises the transport filter. + TestWalletRig sender(createSenderWalletDB(), onCompleted); + TestWalletRig receiver(createReceiverWalletDB(), onCompleted); + + vector fromSender, fromReceiver; + + auto showSlatepack = [](const char* who, const char* slate, const string& s) + { + cout << "\n " << who << " produced " << slate << " -> Slatepack (" << s.size() << " bytes):\n" + << " ----------------------------------------------------------------\n" + << s << "\n" + << " ----------------------------------------------------------------\n"; + }; + auto senderBp = make_shared(*sender.m_Wallet, sender.m_WalletDB, + [&](const TxID&, const string& s) { showSlatepack("SENDER", "S1", s); fromSender.push_back(s); mainReactor->stop(); }); + auto receiverBp = make_shared(*receiver.m_Wallet, receiver.m_WalletDB, + [&](const TxID&, const string& s) { showSlatepack("RECEIVER", "S2", s); fromReceiver.push_back(s); mainReactor->stop(); }); + sender.m_Wallet->AddMessageEndpoint(senderBp); + receiver.m_Wallet->AddMessageEndpoint(receiverBp); + + const Amount amount = 3; + sender.m_Wallet->StartTransaction(CreateSimpleTransactionParameters() + .SetParameter(TxParameterID::PeerAddr, receiver.m_BbsAddr) + .SetParameter(TxParameterID::Amount, amount) + .SetParameter(TxParameterID::Fee, Amount(1)) + .SetParameter(TxParameterID::Lifetime, Height(200)) + .SetParameter(TxParameterID::ManualTransport, true)); + + // Driver: run the reactor until it stops (a Slatepack popped out, a tx completed, or + // the per-leg watchdog fired), courier any produced Slatepack to the other wallet, + // and repeat until both txs report Completed. The watchdog turns a stalled + // negotiation into a failed assertion instead of a hang. + io::Timer::Ptr watchdog = io::Timer::create(io::Reactor::get_Current()); + string err; + int couriered = 0; + for (int leg = 0; leg < 16 && completed < 2; ++leg) + { + watchdog->start(8000, false, [&mainReactor] { mainReactor->stop(); }); + mainReactor->run(); + watchdog->cancel(); + + while (!fromSender.empty()) + { + const string s = fromSender.back(); fromSender.pop_back(); + cout << " >> couriering S1 to RECEIVER and injecting it\n"; + WALLET_CHECK(receiverBp->Inject(s, err)); + ++couriered; + } + while (!fromReceiver.empty()) + { + const string s = fromReceiver.back(); fromReceiver.pop_back(); + cout << " >> couriering S2 back to SENDER and injecting it\n"; + WALLET_CHECK(senderBp->Inject(s, err)); + ++couriered; + } + } + + auto sh = sender.m_WalletDB->getTxHistory(); + auto rh = receiver.m_WalletDB->getTxHistory(); + Amount received = 0; + for (const auto& c : receiver.GetCoins()) + received += c.m_ID.m_Value; + + auto statusStr = [](const vector& h) + { + return h.empty() ? "none" + : h[0].m_status == TxStatus::Completed ? "Completed" : "in-progress"; + }; + cout << "\n ==================== exchange summary ====================\n" + << " amount sent : " << amount << "\n" + << " amount received : " << received << "\n" + << " Slatepacks couriered : " << couriered << " (S1 + S2)\n" + << " SBBS messages : 0\n" + << " sender tx status : " << statusStr(sh) << "\n" + << " receiver tx status : " << statusStr(rh) << "\n" + << " =========================================================\n\n"; + + // At least S1 (sender->receiver) and S2 (receiver->sender) must have crossed the gap. + WALLET_CHECK(couriered >= 2); + WALLET_CHECK(sh.size() == 1); + WALLET_CHECK(rh.size() == 1); + WALLET_CHECK(!sh.empty() && sh[0].m_status == TxStatus::Completed); + WALLET_CHECK(!rh.empty() && rh[0].m_status == TxStatus::Completed); + WALLET_CHECK(received == amount); + } +} + +int main() +{ + const int logLevel = BEAM_LOG_LEVEL_WARNING; + const auto path = boost::filesystem::system_complete("logs"); + auto logger = beam::Logger::create(logLevel, logLevel, logLevel, "slatepack_exchange_test", path.string()); + + ECC::PseudoRandomGenerator prg; + prg.m_hv = 125U; + + beam::Rules r; + beam::Rules::Scope scopeRules(r); + r.m_Consensus = Rules::Consensus::FakePoW; + r.pForks[1].m_Height = 100500; + r.DisableForksFrom(2); + r.UpdateChecksum(); + + wallet::g_AssetsEnabled = true; + storage::HookErrors(); + + TestSlatepackExchange(); + + return WALLET_CHECK_RESULT; +} diff --git a/wallet/unittests/slatepack_test.cpp b/wallet/unittests/slatepack_test.cpp new file mode 100644 index 0000000000..130003c442 --- /dev/null +++ b/wallet/unittests/slatepack_test.cpp @@ -0,0 +1,111 @@ +// Copyright 2018-2026 The Beam Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "wallet/core/slatepack.h" +#include "core/ecc_native.h" // ECC::GenRandom +#include "test_helpers.h" // WALLET_TEST_INIT / WALLET_CHECK / WALLET_CHECK_RESULT +#include + +WALLET_TEST_INIT + +using namespace beam; +using namespace beam::wallet; +using namespace std; + +namespace +{ + ByteBuffer RandomBytes(size_t n) + { + ByteBuffer b(n); + if (n) + ECC::GenRandom(&b.front(), static_cast(b.size())); + return b; + } + + void TestArmorRoundTrip() + { + const ByteBuffer payload = RandomBytes(300); + const string s = slatepack::Armor(slatepack::PayloadType::TxNegotiation, payload); + WALLET_CHECK(s.rfind("BEGINSLATEPACK.", 0) == 0); + WALLET_CHECK(s.find("ENDSLATEPACK.") != string::npos); + + // Survives being pasted inside other text with reflowed whitespace. + const string wrapped = "hey, here is the tx:\n\n" + s + "\n\nthanks!"; + slatepack::PayloadType type; + ByteBuffer out; + string err; + WALLET_CHECK(slatepack::Unarmor(wrapped, type, out, err)); + WALLET_CHECK(type == slatepack::PayloadType::TxNegotiation); + WALLET_CHECK(out == payload); + } + + void TestArmorRejects() + { + const ByteBuffer payload{ 1, 2, 3, 4, 5 }; + const string s = slatepack::Armor(slatepack::PayloadType::TxNegotiation, payload); + + slatepack::PayloadType type; + ByteBuffer out; + string err; + + // Corruption: flip one body char to a different (still valid) base58 char. + string bad = s; + const size_t pos = bad.find("BEGINSLATEPACK.") + strlen("BEGINSLATEPACK.") + 2; + bad[pos] = (bad[pos] == 'A') ? 'B' : 'A'; + WALLET_CHECK(!slatepack::Unarmor(bad, type, out, err)); + + // Truncation. + WALLET_CHECK(!slatepack::Unarmor(s.substr(0, s.size() / 2), type, out, err)); + + // Not a Slatepack at all. + WALLET_CHECK(!slatepack::Unarmor("hello world", type, out, err)); + + // Empty input. + WALLET_CHECK(!slatepack::Unarmor("", type, out, err)); + } + + void TestTxNegotiationRoundTrip() + { + slatepack::TxNegotiation n; + n.m_Peer.m_Channel = 12345U; + ECC::GenRandom(n.m_Peer.m_Pk); + n.m_Ciphertext = ByteBuffer{ 9, 8, 7, 6, 5, 4, 3, 2, 1 }; + + const ByteBuffer bytes = slatepack::ToBytes(n); + slatepack::TxNegotiation r; + WALLET_CHECK(slatepack::FromBytes(r, bytes)); + WALLET_CHECK(r.m_Peer.m_Channel == n.m_Peer.m_Channel); + WALLET_CHECK(r.m_Peer.m_Pk == n.m_Peer.m_Pk); + WALLET_CHECK(r.m_Ciphertext == n.m_Ciphertext); + + // And it survives a full armor round-trip as a TxNegotiation payload. + const string s = slatepack::Armor(slatepack::PayloadType::TxNegotiation, bytes); + slatepack::PayloadType type; + ByteBuffer out; + string err; + WALLET_CHECK(slatepack::Unarmor(s, type, out, err)); + slatepack::TxNegotiation r2; + WALLET_CHECK(slatepack::FromBytes(r2, out)); + WALLET_CHECK(r2.m_Peer.m_Pk == n.m_Peer.m_Pk); + WALLET_CHECK(r2.m_Ciphertext == n.m_Ciphertext); + } +} + +int main() +{ + TestArmorRoundTrip(); + TestArmorRejects(); + TestTxNegotiationRoundTrip(); + return WALLET_CHECK_RESULT; +}