feat(chat): tier-3 larger messages — protocol + split + cross-tx reassembly core

Foundation for messages too large for a single transaction (tier 2's 16-output
ceiling, ~4000 chars). A long message is now split across SEVERAL transactions
that share a random message id; each carries a slice of the one ciphertext with
GLOBAL chunk indices, and the receiver reassembles across the siblings.

Protocol: the Message header gains an optional "mid" (message id); "n" is reused
as the TOTAL chunk count and its bound raised to kHushChatMaxMessageChunks (64 ⇒
up to ~16 KB / 4 txs, tunable, ≤256 for the 2-hex index). The grouper, when a
tx's header carries "mid", emits a PARTIAL (its global-indexed chunks + message
id + total) instead of reassembling in-place; HushChatTransactionMetadata
carries those partials through. Crypto is untouched — one secretstream session
for the whole message, chunking is transport-only.

Compose: splitOutgoingMessage() returns one OutgoingChatMemos per transaction
(one element = the classic tier-1/2 single tx, byte-identical; several = tier 3).
buildOutgoingMessage (single-tx) is unchanged. Old clients bound "n" to 16 so a
tier-3 header fails to parse and its memos are dropped — graceful degradation.

Unit-tested (testHushChatLargeMessageTier3): a ~9000-char message splits into 3
txs, every output shuffled, slices gathered across siblings, reassembled and
decrypted back to the original; short message stays single-tx (no mid); the
total-chunk cap is enforced. The stateful accumulator (persisted, confirm-gated),
sequential broadcast, and progressive/incomplete UI follow in later commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:20:11 -05:00
co-authored by Claude Opus 4.8
parent 1729f7dfad
commit cc08854237
5 changed files with 263 additions and 6 deletions
+29 -4
View File
@@ -1,7 +1,9 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#ifndef DRAGONX_ENABLE_CHAT
@@ -27,8 +29,13 @@ struct HushChatHeader {
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).
// index-prefixed ciphertext chunks reassembled in order. For tier 2 all N chunks ride ONE tx; for
// tier 3 (message_id set) "n" is the TOTAL chunk count spread across several sibling transactions.
int parts = 1;
// Message only, larger-messages tier 3: when a message needs more chunks than one tx can carry, it
// is split across several transactions that all share this random id (header "mid"); each tx's
// payloads carry their GLOBAL chunk index (0..parts-1). Empty = a single-tx message (tier 1/2).
std::string message_id;
// 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;
@@ -50,6 +57,12 @@ struct HushChatMemoPair {
std::size_t header_position = 0;
std::size_t payload_position = 0;
std::string payload_memo;
// Larger-messages tier 3: a single transaction that carries only PART of a bigger message (the rest
// ride sibling txs sharing message_id). payload_memo is then empty; partial_chunks holds this tx's
// (global index, ciphertext-hex chunk) pairs and total_parts is the whole message's chunk count.
std::string message_id;
int total_parts = 0;
std::vector<std::pair<int, std::string>> partial_chunks;
};
enum class HushChatMemoGroupingIssue {
@@ -91,6 +104,13 @@ struct HushChatTransactionMetadata {
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
// Larger-messages tier 3 (a multi-transaction Message): message_id ties this tx's slice to its
// siblings, total_parts is the whole message's chunk count, and partial_chunks are this tx's
// (global index, ciphertext-hex) chunks. Empty message_id = an ordinary single-tx message (payload
// in payload_memo). The chat service accumulates partials by message_id and reassembles when whole.
std::string message_id;
int total_parts = 0;
std::vector<std::pair<int, std::string>> partial_chunks;
};
struct HushChatTransactionExtractionResult {
@@ -102,10 +122,15 @@ 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).
// Larger-messages tier 2: the most payload outputs a single transaction may carry (one tx). Keeps the
// chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost. A message
// needing more chunks than this spills into sibling transactions (tier 3), up to kHushChatMaxMessageChunks.
constexpr int kHushChatMaxMessageParts = 16;
// Larger-messages tier 3: the most chunks a single message may span IN TOTAL, across all its sibling
// transactions (ceil(chunks / kHushChatMaxMessageParts) txs, each a separate 0-value chat send + fee).
// Bounds the on-chain cost / broadcast time of one message; beyond this compose returns TooLong. The
// 2-hex global part index (below) tops out at 256, so this must stay ≤ 256.
constexpr int kHushChatMaxMessageChunks = 64;
// 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;