From 6b93a8ab60908dbcb3b679c2b9ecb5498755ba3d Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 24 Oct 2026 11:16:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(chat):=20larger=20messages=20=E2=80=94=20m?= =?UTF-8?q?ulti-output=20chunking=20in=20one=20transaction=20(tier=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message whose ciphertext exceeds one 512-byte memo is now split across up to kHushChatMaxMessageParts (16) payload outputs in a SINGLE transaction, lifting the ~239-byte cap to a few KB while staying atomic, instant (0-conf), and one-fee. The crypto is untouched (one secretstream push/pull); chunking is a transport concern: - Header gains an optional part count `n` (emitted only when >1, so a 1-part message stays byte-identical on the wire); bounded in the parser. - Each chunk is a 2-hex part-index prefix + its ciphertext slice, so the receiver reorders them after the daemon's output shuffle. - OutgoingChatMemos carries a payload vector; chatSendOutputs emits header + N payloads (both broadcast call sites already iterate the result). - The grouper gains a multi-part pre-scan: a lone Message header with n>1 → collect the N indexed chunks, sort, strip, concat → one pair; anything else falls through to the classic single-pair path (older clients mis-pair and drop — graceful). A body beyond the cap is refused as TooLong (tier 3 later). Round-trip (shuffled outputs), 1-part byte-identical, and cap all unit-tested. Larger-messages tier 2; tier 3 (multi-tx) + the denser raw-memo encoding remain. Co-Authored-By: Claude Opus 4.8 --- src/chat/chat_outgoing.cpp | 63 +++++++++++++++++++++++-------- src/chat/chat_outgoing.h | 12 ++++-- src/chat/chat_protocol.cpp | 77 ++++++++++++++++++++++++++++++++++++++ src/chat/chat_protocol.h | 11 ++++++ tests/test_phase4.cpp | 75 +++++++++++++++++++++++++++++++------ 5 files changed, 207 insertions(+), 31 deletions(-) diff --git a/src/chat/chat_outgoing.cpp b/src/chat/chat_outgoing.cpp index e535642..747cf7a 100644 --- a/src/chat/chat_outgoing.cpp +++ b/src/chat/chat_outgoing.cpp @@ -18,7 +18,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr, const std::string& streamHeaderHex, const std::string& publicKeyHex, std::int64_t sentAt, - const std::string& op = "") + const std::string& op = "", + int parts = 1) { nlohmann::json header; header["h"] = 1; // header number (>= 1) @@ -29,21 +30,34 @@ std::string buildHeaderMemo(const std::string& replyZaddr, header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl) header["p"] = publicKeyHex; // my 64-hex crypto_kx public key if (!op.empty()) header["op"] = op; // Control only; alphabetically sorts to cid,e,h,op,p,t,v,z + if (parts > 1) header["n"] = parts; // multi-part Message only; omitted (==1) keeps 1-part byte-identical if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this return header.dump(); } +// The N 2-hex index prefix for a multi-part ciphertext chunk (index 0..kHushChatMaxMessageParts-1). +std::string partIndexPrefix(std::size_t i) +{ + static const char* kHex = "0123456789abcdef"; + std::string p; + p += kHex[(i >> 4) & 0xF]; + p += kHex[i & 0xF]; + return p; +} + bool present(const std::string& value) { return !value.empty(); } } // namespace -std::array chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix) +std::vector 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 }, - }}; + std::vector outs; + outs.reserve(1 + memos.payloadMemos.size()); + outs.push_back({ memos.recipientZaddr, prefix + memos.headerMemo }); // header (position doesn't matter; + for (const auto& p : memos.payloadMemos) // the receiver reassembles by index) + outs.push_back({ memos.recipientZaddr, prefix + p }); + return outs; } ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine, @@ -69,15 +83,32 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine, return ChatComposeStatus::EncryptFailed; } + // Chunk the ciphertext hex across payload memos. A single memo → the classic 1-part message, byte- + // identical on the wire (no "n", no index prefix). Longer → N index-prefixed chunks (tier 2), capped + // at kHushChatMaxMessageParts; beyond that it is refused as TooLong (tier 3 would lift the ceiling). + std::vector payloads; + int parts = 1; + if (ciphertextHex.size() <= kHushChatMemoByteLimit) { + payloads.push_back(ciphertextHex); + } else { + const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1); + const std::size_t n = (ciphertextHex.size() + chunkHex - 1) / chunkHex; + if (static_cast(n) > kHushChatMaxMessageParts) return ChatComposeStatus::TooLong; + parts = static_cast(n); + payloads.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + payloads.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex)); + } + } + OutgoingChatMemos memos; memos.recipientZaddr = peerZaddr; memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex, - static_cast(std::time(nullptr))); - memos.payloadMemo = ciphertextHex; - if (memos.headerMemo.size() > kHushChatMemoByteLimit || - memos.payloadMemo.size() > kHushChatMemoByteLimit) { - return ChatComposeStatus::TooLong; - } + static_cast(std::time(nullptr)), "", parts); + memos.payloadMemos = std::move(payloads); + if (memos.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong; + for (const auto& p : memos.payloadMemos) + if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong; out = std::move(memos); return ChatComposeStatus::Ok; } @@ -102,9 +133,9 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex, memos.recipientZaddr = peerZaddr; memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex, static_cast(std::time(nullptr))); - memos.payloadMemo = requestText; + memos.payloadMemos = { requestText }; if (memos.headerMemo.size() > kHushChatMemoByteLimit || - memos.payloadMemo.size() > kHushChatMemoByteLimit) { + memos.payloadMemos[0].size() > kHushChatMemoByteLimit) { return ChatComposeStatus::TooLong; } out = std::move(memos); @@ -131,9 +162,9 @@ ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex, memos.recipientZaddr = selfZaddr; memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex, static_cast(std::time(nullptr)), op); - memos.payloadMemo = label; + memos.payloadMemos = { label }; if (memos.headerMemo.size() > kHushChatMemoByteLimit || - memos.payloadMemo.size() > kHushChatMemoByteLimit) { + memos.payloadMemos[0].size() > kHushChatMemoByteLimit) { return ChatComposeStatus::TooLong; } out = std::move(memos); diff --git a/src/chat/chat_outgoing.h b/src/chat/chat_outgoing.h index 3af039c..22c78f3 100644 --- a/src/chat/chat_outgoing.h +++ b/src/chat/chat_outgoing.h @@ -12,13 +12,17 @@ #include #include +#include namespace dragonx::chat { struct OutgoingChatMemos { - std::string recipientZaddr; // the peer's z-address (recipient of both memo outputs) - std::string headerMemo; // JSON header — MUST occupy the lower memo position on the wire - std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest) + std::string recipientZaddr; // the peer's z-address (recipient of every memo output) + std::string headerMemo; // JSON header + // One payload (ciphertext hex for a Message, plaintext for a ContactRequest/Control), OR — for a + // multi-part Message (header "n" = N > 1) — N index-prefixed ciphertext chunks (a 2-hex part index + // then that chunk's ciphertext hex), reassembled by the receiver regardless of on-chain order. + std::vector payloadMemos; }; // One of the two 0-value memo outputs a HushChat send produces (amount is always 0). @@ -31,7 +35,7 @@ struct ChatSendOutput { // `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); +std::vector chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix); enum class ChatComposeStatus { Ok, diff --git a/src/chat/chat_protocol.cpp b/src/chat/chat_protocol.cpp index 2cfc9b7..8e8c41c 100644 --- a/src/chat/chat_protocol.cpp +++ b/src/chat/chat_protocol.cpp @@ -161,6 +161,13 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo) !isHexString(header.secretstream_header_hex)) { return fail("message header must include a 24 byte secretstream header encoded as hex"); } + // Optional part count ("n"); absent/1 = classic single-payload message. Bound it so a garbage or + // future-oversized header can't make the grouper try to collect an unbounded number of payloads. + if (auto it = object.find("n"); it != object.end() && it->is_number_integer()) { + const int n = it->get(); + if (n < 1 || n > kHushChatMaxMessageParts) return fail("message part count out of range"); + header.parts = n; + } } else if (!header.secretstream_header_hex.empty()) { return fail("contact request header must not include a secretstream header"); } @@ -189,6 +196,76 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector 1 means its ciphertext was chunked across N payload + // outputs, each prefixed with a 2-hex part index so order survives the daemon's output shuffle. + // Reassemble here and emit one pair. Only a clean one-header + exactly-N-chunks tx takes this path; + // anything else falls through to the classic single-pair logic. An older client lacks this branch, + // mis-pairs the first chunk, fails to decrypt, and drops it — graceful degradation. + { + std::optional multiHeader; + std::size_t multiHeaderPos = 0; + int validHeaders = 0; + bool anyOversize = false; + std::vector payloads; + for (const auto& entry : ordered) { + const auto& o = entry.output; + if (o.memo.size() > kHushChatMemoByteLimit) { anyOversize = true; break; } + if (!o.memo.empty() && o.memo.front() == '{') { + auto parsed = parseHushChatHeaderMemo(o.memo); + if (parsed.ok) { + ++validHeaders; + if (parsed.header.type == HushChatHeaderType::Message && parsed.header.parts > 1) { + multiHeader = std::move(parsed.header); + multiHeaderPos = o.position; + } + } + } else if (!o.memo.empty()) { + payloads.push_back(&o); + } + } + if (!anyOversize && validHeaders == 1 && multiHeader) { + const int want = multiHeader->parts; + auto hexVal = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + std::vector chunks(static_cast(want)); + std::vector seen(static_cast(want), false); + bool ok = (static_cast(payloads.size()) == want); + std::size_t lowPos = 0; bool haveLow = false; + for (const auto* p : payloads) { + if (!ok) break; + const std::string& m = p->memo; + if (m.size() < kHushChatPartIndexHexLen || (m.size() % 2) != 0 || !isHexString(m)) { ok = false; break; } + const int idx = hexVal(m[0]) * 16 + hexVal(m[1]); + if (idx < 0 || idx >= want || seen[static_cast(idx)]) { ok = false; break; } + seen[static_cast(idx)] = true; + chunks[static_cast(idx)] = m.substr(kHushChatPartIndexHexLen); + if (!haveLow || p->position < lowPos) { lowPos = p->position; haveLow = true; } + } + if (ok) { + std::string full; + for (const auto& c : chunks) full += c; + if (!full.empty() && (full.size() % 2) == 0) { + HushChatMemoPair pair; + pair.header = std::move(*multiHeader); + pair.header_position = multiHeaderPos; + pair.payload_position = haveLow ? lowPos : 0; + pair.payload_memo = std::move(full); // the reassembled full ciphertext hex + result.pairs.push_back(std::move(pair)); + return result; + } + } + result.issues.push_back(HushChatMemoGroupingIssueInfo{ + HushChatMemoGroupingIssue::MissingPayload, multiHeaderPos, + "multi-part message did not reassemble"}); + return result; + } + } std::optional pending_header_output; std::optional pending_header; // A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo diff --git a/src/chat/chat_protocol.h b/src/chat/chat_protocol.h index f10e909..6663c2e 100644 --- a/src/chat/chat_protocol.h +++ b/src/chat/chat_protocol.h @@ -25,6 +25,10 @@ struct HushChatHeader { std::string op; // Control only ("del" / "res" / "sensitive" / "unsensitive" / future ops); empty otherwise std::string secretstream_header_hex; std::string public_key_hex; + // Message only: how many payload outputs this message's ciphertext is split across (header "n"). + // 1 = the classic single-payload message (field omitted on the wire). >1 means the payloads are + // index-prefixed ciphertext chunks reassembled in order (larger-messages tier 2, one transaction). + int parts = 1; // Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the // receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time. std::int64_t sent_at = 0; @@ -98,6 +102,13 @@ struct HushChatTransactionExtractionResult { constexpr int kHushChatSupportedVersion = 0; constexpr std::size_t kHushChatMemoByteLimit = 512; +// Larger-messages tier 2: the most payload outputs a single message may be split across (one tx). +// Keeps the chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost. +// A message that would need more parts than this is refused at compose time (tier 3 would lift it). +constexpr int kHushChatMaxMessageParts = 16; +// Each multi-part payload chunk is prefixed with a 2-hex-char (1-byte) part index so the receiver can +// reorder chunks after the daemon shuffles a tx's outputs. 2 hex covers indices 0..255 (>> the cap). +constexpr std::size_t kHushChatPartIndexHexLen = 2; constexpr std::size_t kHushChatPublicKeyHexLength = 64; constexpr std::size_t kHushChatSecretstreamHeaderHexLength = 48; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index fa14a6c..a449188 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15098,7 +15098,7 @@ void testHushChatControlMemo() EXPECT_EQ(cm.recipientZaddr, std::string("zs-self")); HushChatTransactionInput rt; rt.txid = "txid-ctrl-rt"; rt.outputs.push_back({0, cm.headerMemo}); - rt.outputs.push_back({1, cm.payloadMemo}); + rt.outputs.push_back({1, cm.payloadMemos[0]}); auto rx = extractHushChatTransactionMetadata(rt, true); EXPECT_EQ((int)rx.metadata.size(), 1); EXPECT_TRUE(rx.metadata[0].type == HushChatHeaderType::Control); @@ -15132,7 +15132,7 @@ void testHushChatControlReducer() auto feed = [&](const OutgoingChatMemos& m, const std::string& txid, std::int64_t ts) { HushChatTransactionInput tx; tx.txid = txid; tx.outputs.push_back({0, m.headerMemo}); - tx.outputs.push_back({1, m.payloadMemo}); + tx.outputs.push_back({1, m.payloadMemos[0]}); auto ex = extractHushChatTransactionMetadata(tx, true); return svc.ingest(ex.metadata, {{txid, ts}}, ts); }; @@ -15409,7 +15409,7 @@ void testHushChatSensitiveService() EXPECT_TRUE(buildOutgoingMessage(peer, rp.public_key_hex, "zs-peer", rm.public_key_hex, "zs-me", cid, body, m) == ChatComposeStatus::Ok); HushChatTransactionInput tx; tx.txid = txid; - tx.outputs.push_back({0, m.headerMemo}); tx.outputs.push_back({1, m.payloadMemo}); + tx.outputs.push_back({0, m.headerMemo}); tx.outputs.push_back({1, m.payloadMemos[0]}); auto ex = extractHushChatTransactionMetadata(tx, true); const std::int64_t t = static_cast(std::time(nullptr)); return svc.ingest(ex.metadata, {{txid, t}}, t); @@ -15694,6 +15694,58 @@ void testHushChatDeleteConversation() // Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and // ChatService compose/recordOutgoing echoes into the store. +// Larger messages (tier 2): a long message is chunked across N index-prefixed payload outputs in ONE +// tx and reassembled on receipt regardless of on-chain output order; the cap refuses an over-long body. +void testHushChatLargeMessage() +{ + using namespace dragonx::chat; + ChatKeyPair alice, bob; + ChatIdentityResult ra = deriveChatIdentityFromSecret("lm-alice", alice, true); + ChatIdentityResult rb = deriveChatIdentityFromSecret("lm-bob", bob, true); + EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready && rb.status == ChatIdentityStatus::Ready); + const std::string cid = "conv-lm"; + + std::string big; // well beyond one memo (~239 bytes) + for (int i = 0; i < 1500; ++i) big += static_cast('A' + (i % 26)); + + OutgoingChatMemos memos; + EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob", + cid, big, memos) == ChatComposeStatus::Ok); + EXPECT_TRUE((int)memos.payloadMemos.size() > 1); // actually chunked + EXPECT_TRUE(memos.headerMemo.find("\"n\":") != std::string::npos); // carries the part count + + // Build the tx (header + all payloads) and REVERSE the output order to mimic the daemon's + // ShuffleOutputs — payloads before the header, indices descending. Reassembly must still work. + std::vector wire; + wire.push_back(memos.headerMemo); + for (const auto& p : memos.payloadMemos) wire.push_back(p); + std::reverse(wire.begin(), wire.end()); + HushChatTransactionInput tx; tx.txid = "lm1"; + for (std::size_t i = 0; i < wire.size(); ++i) tx.outputs.push_back({i, wire[i]}); + + auto ext = extractHushChatTransactionMetadata(tx, true); + EXPECT_EQ((int)ext.metadata.size(), 1); + EXPECT_TRUE(ext.metadata[0].type == HushChatHeaderType::Message); + std::string plain; + EXPECT_TRUE(decryptIncoming(bob, ext.metadata[0].sender_public_key_hex, + ext.metadata[0].secretstream_header_hex, + ext.metadata[0].payload_memo, plain) == ChatCryptoStatus::Ok); + EXPECT_EQ(plain, big); + + // A short message stays single-payload and byte-identical (no "n" field, no index prefix). + OutgoingChatMemos small; + EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob", + cid, "hi", small) == ChatComposeStatus::Ok); + EXPECT_EQ((int)small.payloadMemos.size(), 1); + EXPECT_TRUE(small.headerMemo.find("\"n\":") == std::string::npos); + + // Cap: a body needing more than kHushChatMaxMessageParts chunks is refused. + std::string huge(6000, 'x'); + OutgoingChatMemos tooBig; + EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob", + cid, huge, tooBig) == ChatComposeStatus::TooLong); +} + void testHushChatOutgoing() { using namespace dragonx::chat; @@ -15717,7 +15769,7 @@ void testHushChatOutgoing() HushChatTransactionInput tx; tx.txid = "txout1"; tx.outputs.push_back({0, memos.headerMemo}); // header at the lower position - tx.outputs.push_back({1, memos.payloadMemo}); + tx.outputs.push_back({1, memos.payloadMemos[0]}); auto extracted = extractHushChatTransactionMetadata(tx, true); EXPECT_EQ((int)extracted.metadata.size(), 1); @@ -15742,7 +15794,7 @@ void testHushChatOutgoing() HushChatTransactionInput tx2; tx2.txid = "txout2"; tx2.outputs.push_back({0, creq.headerMemo}); - tx2.outputs.push_back({1, creq.payloadMemo}); + tx2.outputs.push_back({1, creq.payloadMemos[0]}); auto ex2 = extractHushChatTransactionMetadata(tx2, true); EXPECT_EQ((int)ex2.metadata.size(), 1); EXPECT_TRUE(ex2.metadata[0].type == HushChatHeaderType::ContactRequest); @@ -15817,18 +15869,18 @@ void testHushChatTransport() "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); + auto 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); + EXPECT_EQ(fn[1].memo, std::string("utf8:") + memos.payloadMemos[0]); // Lite: raw, no prefix. - std::array lt = chatSendOutputs(memos, /*utf8Prefix=*/false); + auto lt = chatSendOutputs(memos, /*utf8Prefix=*/false); EXPECT_EQ(lt[0].memo, memos.headerMemo); - EXPECT_EQ(lt[1].memo, memos.payloadMemo); + EXPECT_EQ(lt[1].memo, memos.payloadMemos[0]); // 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). @@ -15863,7 +15915,7 @@ void testHushChatShuffledReceive() "zs-bob", "cid-shuf", "shuffled hi", memos) == ChatComposeStatus::Ok); HushChatTransactionInput tx; tx.txid = "txshuf"; - tx.outputs.push_back({0, memos.payloadMemo}); // payload at the LOWER position + tx.outputs.push_back({0, memos.payloadMemos[0]}); // payload at the LOWER position tx.outputs.push_back({1, memos.headerMemo}); // header at the HIGHER position auto extracted = extractHushChatTransactionMetadata(tx, true); EXPECT_EQ((int)extracted.metadata.size(), 1); @@ -15877,7 +15929,7 @@ void testHushChatShuffledReceive() HushChatTransactionInput tx2; tx2.txid = "txshuf2"; tx2.outputs.push_back({0, std::string()}); // change output: empty memo (ignored) - tx2.outputs.push_back({1, memos2.payloadMemo}); // payload + tx2.outputs.push_back({1, memos2.payloadMemos[0]}); // payload tx2.outputs.push_back({2, memos2.headerMemo}); // header last auto ex2 = extractHushChatTransactionMetadata(tx2, true); EXPECT_EQ((int)ex2.metadata.size(), 1); @@ -20303,6 +20355,7 @@ int main() testChatStoreTxidIndex(); testChatStoreLatestAndMove(); testHushChatOutgoing(); + testHushChatLargeMessage(); testHushChatTransport(); testHushChatShuffledReceive(); testWalletFileProbe();