From e191680782f751902d1a09b7fa1be41fb16486f2 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 6 Jul 2026 16:12:35 -0500 Subject: [PATCH] feat(chat): wire the two-variant send transport (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make composed messages actually reach the wire: broadcastChatMemos sends the header + payload as two 0-value memo outputs to the peer's z-address (header first, the lower memo position), on both variants. - chat_outgoing: chatSendOutputs(memos, utf8Prefix) — the pure, testable memo encoder. Full-node memos get a "utf8:" prefix (dragonxd rejects raw JSON and then UTF-8-encodes on-chain, byte-identical to SilentDragonXLite's Memo::from_str); the payload is NOT double-hex. Lite memos are raw UTF-8 (the backend does Memo::from_str directly). Header is always output 0. - App::broadcastChatMemos: full-node builds a two-recipient z_sendmany array (amount 0, from the spendable reply z-address, default fee) via submitZSendMany with markFeeGapRetry=true — deliberately, to suppress the fee-gap auto-retry, which rebuilds a single-recipient tx and would drop the payload output. Lite routes to broadcastChatMemosLite (two 0-value LiteSendRecipients, raw memos; the backend accepts duplicate addresses + 0-value for exactly this pattern). - Encoding + design established by a four-codebase mapping (wallet, daemon, SDXL, lite backend) and an adversarial review of the wiring (0 confirmed findings). - Tests: chatSendOutputs (utf8:-prefix + header-first for full-node, raw for lite) + on-chain round-trip (strip "utf8:" -> the harvest parser re-pairs and decrypts). Remaining as a LIVE test (cannot be proven from source): that dragonxd returns the memo under memoStr verbatim, that recipient-array order maps to note position, that a 0-value memo-only tx relays, and full SDXL<->DragonX interop. Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.h | 8 ++--- src/app_network.cpp | 65 +++++++++++++++++++++++++++++++++++--- src/chat/chat_outgoing.cpp | 9 ++++++ src/chat/chat_outgoing.h | 13 ++++++++ tests/test_phase4.cpp | 43 +++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 9 deletions(-) diff --git a/src/app.h b/src/app.h index b56de79..a71ac92 100644 --- a/src/app.h +++ b/src/app.h @@ -170,9 +170,8 @@ public: wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); } // HushChat service (identity + in-memory message store); the Chat tab reads its store. chat::ChatService& chatService() { return chat_service_; } - // HushChat composing (Phase 4): construct + locally echo an outgoing message (to a conversation - // whose peer key we know) / a new-conversation contact request. Network delivery is wired in a - // later phase — see broadcastChatMemos. + // HushChat composing: construct, broadcast (broadcastChatMemos), and locally echo an outgoing + // message (to a conversation whose peer key we know) / a new-conversation contact request. void sendChatMessage(const std::string& conversationId, const std::string& text); void startChatConversation(const std::string& peerZaddr, const std::string& text); // Reason the lite wallet failed to auto-open this session (empty if none / opened OK). @@ -596,7 +595,8 @@ private: void provisionChatIdentityFromSecret(std::string secret); std::string chatReplyZaddr() const; // a stable wallet z-addr for outgoing headers std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid - void broadcastChatMemos(const chat::OutgoingChatMemos& memos); // Phase-5 seam (network delivery) + void broadcastChatMemos(const chat::OutgoingChatMemos& memos); // full-node z_sendmany / lite + void broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send // Lite first-run welcome prompt: dismissed for the session once the user picks an action. bool lite_firstrun_dismissed_ = false; // Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked. diff --git a/src/app_network.cpp b/src/app_network.cpp index b0e183f..3fb1b42 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -2491,13 +2491,68 @@ std::string App::generateChatLocalId(const char* prefix, int numBytes) const return id; } -// Phase-5 seam: broadcast the header + payload as two 0-value memo outputs to memos.recipientZaddr -// (full-node z_sendmany via submitZSendMany with a two-recipient array; lite via the controller). -// Not wired yet — the composed message is recorded locally so the UI reflects it; over-the-wire -// delivery + real-SDXL interop verification land in the next phase. +// Broadcast the header + payload as two 0-value memo outputs to memos.recipientZaddr (header first, +// the lower memo position). Routes to the lite controller in lite builds, else full-node z_sendmany. +// +// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim +// on receive, that recipient-array order maps to note position (the receive pairing needs the header +// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop. void App::broadcastChatMemos(const chat::OutgoingChatMemos& memos) { - (void)memos; + if (memos.recipientZaddr.empty()) return; + + if (lite_wallet_) { + broadcastChatMemosLite(memos); + return; + } + + if (!state_.connected || !rpc_ || !worker_) { + ui::Notifications::instance().error("Not connected — chat message not sent."); + return; + } + const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address + if (from.empty()) { + ui::Notifications::instance().error("No spendable z-address to send chat from."); + return; + } + + // Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected). + const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true); + nlohmann::json recipients = nlohmann::json::array(); + for (const auto& out : outputs) { + nlohmann::json recipient; + recipient["address"] = out.address; + recipient["amount"] = util::formatAmountFixed(0.0); // 0-value memo output + recipient["memo"] = out.memo; + recipients.push_back(std::move(recipient)); + } + + const double fee = settings_ ? settings_->getDefaultFee() : 0.0001; + // markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a + // SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output. + submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients, + "HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{}); +} + +// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does +// Memo::from_str directly — no "utf8:" prefix). The backend accepts duplicate addresses + 0-value +// outputs for exactly this HushChat pattern. Fire-and-forget: the result surfaces via the lite +// broadcast log; the message is already echoed locally. +void App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos) +{ + if (!lite_wallet_) return; + const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/false); + wallet::LiteSendRequest req; + for (const auto& out : outputs) { + wallet::LiteSendRecipient recipient; + recipient.address = out.address; + recipient.amountZatoshis = 0; + recipient.memo = out.memo; + req.recipients.push_back(std::move(recipient)); + } + if (!lite_wallet_->sendTransaction(req)) { + ui::Notifications::instance().error("A send is already in progress, or no wallet is open."); + } } void App::sendChatMessage(const std::string& conversationId, const std::string& text) diff --git a/src/chat/chat_outgoing.cpp b/src/chat/chat_outgoing.cpp index f6da527..cba1764 100644 --- a/src/chat/chat_outgoing.cpp +++ b/src/chat/chat_outgoing.cpp @@ -33,6 +33,15 @@ bool present(const std::string& value) { return !value.empty(); } } // namespace +std::array chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix) +{ + const std::string prefix = utf8Prefix ? "utf8:" : ""; + return {{ + { memos.recipientZaddr, prefix + memos.headerMemo }, // header — the lower memo position + { memos.recipientZaddr, prefix + memos.payloadMemo }, + }}; +} + ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine, const std::string& myPublicKeyHex, const std::string& myReplyZaddr, diff --git a/src/chat/chat_outgoing.h b/src/chat/chat_outgoing.h index 4f7de3b..42644ae 100644 --- a/src/chat/chat_outgoing.h +++ b/src/chat/chat_outgoing.h @@ -10,6 +10,7 @@ #include "chat_crypto.h" // ChatKeyPair +#include #include namespace dragonx::chat { @@ -20,6 +21,18 @@ struct OutgoingChatMemos { std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest) }; +// One of the two 0-value memo outputs a HushChat send produces (amount is always 0). +struct ChatSendOutput { + std::string address; // the peer's z-address + std::string memo; // memo encoded for the target transport (utf8:-prefixed or raw) +}; + +// The two outputs for a HushChat send, HEADER FIRST (it must occupy the lower memo position). +// `utf8Prefix` prepends the daemon's "utf8:" marker required by full-node z_sendmany (which then +// UTF-8-encodes the bytes on-chain, byte-identical to SDXLite's Memo::from_str); lite backends take +// raw UTF-8, so pass false there. +std::array chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix); + enum class ChatComposeStatus { Ok, EmptyBody, diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 45fa47f..00c747a 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -5932,6 +5932,48 @@ void testHushChatOutgoing() EXPECT_EQ(conv2.back().body, std::string("reply!")); } +// Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for +// lite — and that the on-chain form (after the daemon strips "utf8:") round-trips through receive. +void testHushChatTransport() +{ + using namespace dragonx::chat; + + ChatKeyPair alice, bob; + ChatIdentityResult ra = deriveChatIdentityFromSecret("tx-alice", alice, true); + ChatIdentityResult rb = deriveChatIdentityFromSecret("tx-bob", bob, true); + OutgoingChatMemos memos; + EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, + "zs-bob", "cid-tx", "over the wire", memos) == ChatComposeStatus::Ok); + + // Full-node: header at index 0, both to the peer, each "utf8:"-prefixed. + std::array fn = chatSendOutputs(memos, /*utf8Prefix=*/true); + EXPECT_EQ(fn[0].address, std::string("zs-bob")); + EXPECT_EQ(fn[1].address, std::string("zs-bob")); + EXPECT_TRUE(fn[0].memo.rfind("utf8:", 0) == 0); + EXPECT_TRUE(fn[1].memo.rfind("utf8:", 0) == 0); + EXPECT_EQ(fn[0].memo, std::string("utf8:") + memos.headerMemo); + EXPECT_EQ(fn[1].memo, std::string("utf8:") + memos.payloadMemo); + + // Lite: raw, no prefix. + std::array lt = chatSendOutputs(memos, /*utf8Prefix=*/false); + EXPECT_EQ(lt[0].memo, memos.headerMemo); + EXPECT_EQ(lt[1].memo, memos.payloadMemo); + + // On-chain form round-trips: the daemon stores the UTF-8 text (the string after "utf8:"), which + // is exactly what the harvest parser reads back (header first / lower position). + auto strip = [](const std::string& m) { return m.rfind("utf8:", 0) == 0 ? m.substr(5) : m; }; + HushChatTransactionInput tx; + tx.txid = "txwire"; + tx.outputs.push_back({0, strip(fn[0].memo)}); + tx.outputs.push_back({1, strip(fn[1].memo)}); + auto extracted = extractHushChatTransactionMetadata(tx, true); + EXPECT_EQ((int)extracted.metadata.size(), 1); + ChatService bobSvc; + bobSvc.setIdentity(bob); + EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 9), 1); + EXPECT_EQ(bobSvc.store().conversation("cid-tx")[0].body, std::string("over the wire")); +} + } // namespace int main() @@ -6027,6 +6069,7 @@ int main() testHushChatService(); testHushChatDatabase(); testHushChatOutgoing(); + testHushChatTransport(); testAddressChecksumValidation(); testLiteServerProbeLive(); testXmrigLiveInstall();