feat(chat): wire the two-variant send transport (Phase 5)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 16:12:35 -05:00
parent 4db609fb52
commit e191680782
5 changed files with 129 additions and 9 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -33,6 +33,15 @@ bool present(const std::string& value) { return !value.empty(); }
} // namespace
std::array<ChatSendOutput, 2> 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,

View File

@@ -10,6 +10,7 @@
#include "chat_crypto.h" // ChatKeyPair
#include <array>
#include <string>
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<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
enum class ChatComposeStatus {
Ok,
EmptyBody,

View File

@@ -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<ChatSendOutput, 2> 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<ChatSendOutput, 2> 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();