test(chat): regtest harness verifying on-chain memo round-trip (larger messages + Control)

Adds a dev-only harness that closes the "needs a live daemon" gap for the new
chat wire paths, fund-safely on an isolated regtest chain:
- tools/chat_regtest_smoke.cpp (+ an EXCLUDE_FROM_ALL CMake target): a small
  driver linking the chat library — derive identity / compose a message or a
  Control memo / extract + decrypt harvested memos. Secret-safe.
- tools/chat_regtest.py: starts a throwaway regtest dragonxd (own datadir,
  -regtest, no network — nothing touches ~/.hush), funds a shielded note, then
  drives a full round-trip through the real daemon: compose -> z_sendmany the
  memo outputs -> mine -> z_listreceivedbyaddress -> feed back to the driver.

Verified on dragonxd v1.3.0: a chunked 1529-byte message (8 memo outputs in one
tx) and a self-addressed Control(del) memo both round-trip verbatim and
reassemble/extract correctly — i.e. the real node accepts a multi-output 0-value
chat tx and returns its memos intact. Paths are env-overridable
(DRAGONX_REGTEST_DAEMON_DIR / DRAGONX_CHAT_SMOKE / DRAGONX_REGTEST_RUN).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 14:03:45 -05:00
co-authored by Claude Opus 4.8
parent 2d63a72098
commit 63c284684c
3 changed files with 309 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
// DragonX Wallet — regtest chat smoke driver (dev tool, EXCLUDE_FROM_ALL; never shipped).
//
// Does the CHAT-side work only — identity derivation, outgoing-memo compose, and
// harvest extract + decrypt — linking the real chat library. The daemon transport
// (z_sendmany / generate / z_listreceivedbyaddress) is driven by the surrounding
// regtest runner script, which pipes memos in and out of this tool. Together they
// verify the one thing unit tests cannot: that the real dragonxd accepts a multi-
// output 0-value chat tx and round-trips its memos verbatim so a chunked (tier-2)
// message and a self-addressed Control memo reassemble end to end.
//
// Modes (memos for `extract` arrive on stdin, one per line):
// pubkey <mnemonic>
// compose <mnemonic> <peerPubKeyHex> <peerZaddr> <myReplyZaddr> <cid> <plaintext>
// control <mnemonic> <selfZaddr> <cid> <op> <label>
// extract <mnemonic>
//
// SECRET-SAFE: never prints the mnemonic or any key; only public pubkeys, memos, and
// recovered plaintext (which the runner feeds back the same message it sent).
#include "chat/chat_crypto.h"
#include "chat/chat_identity.h"
#include "chat/chat_outgoing.h"
#include "chat/chat_protocol.h"
#include <cstdio>
#include <iostream>
#include <string>
using namespace dragonx::chat;
namespace {
bool derive(const char* mnemonic, ChatKeyPair& keys, std::string& pubHex)
{
auto r = deriveChatIdentityFromSecret(mnemonic, keys, /*featureEnabled=*/true);
if (r.status != ChatIdentityStatus::Ready) {
std::fprintf(stderr, "[chat-smoke] identity derive failed: %s\n", r.error_name);
return false;
}
pubHex = r.public_key_hex;
return true;
}
// Strip the fixed-512 memo's trailing NUL padding (and any stray whitespace) that the
// daemon's memoStr carries back, so the header JSON / chunk hex parses cleanly.
std::string trimMemo(std::string s)
{
while (!s.empty() && (s.back() == '\0' || s.back() == '\n' || s.back() == '\r' || s.back() == ' '))
s.pop_back();
return s;
}
void emit(const OutgoingChatMemos& memos)
{
std::printf("RECIP %s\n", memos.recipientZaddr.c_str());
std::printf("HEADER %s\n", memos.headerMemo.c_str());
for (const auto& p : memos.payloadMemos) std::printf("PAYLOAD %s\n", p.c_str());
std::fprintf(stderr, "[chat-smoke] composed %zu payload output(s)\n", memos.payloadMemos.size());
}
} // namespace
int main(int argc, char** argv)
{
if (argc < 2) { std::fprintf(stderr, "usage: chat_regtest_smoke <pubkey|compose|control|extract> ...\n"); return 2; }
const std::string mode = argv[1];
if (mode == "pubkey") {
if (argc < 3) return 2;
ChatKeyPair keys; std::string pub;
if (!derive(argv[2], keys, pub)) return 1;
std::printf("%s\n", pub.c_str());
return 0;
}
if (mode == "compose") {
if (argc < 8) { std::fprintf(stderr, "compose needs 6 args\n"); return 2; }
ChatKeyPair keys; std::string pub;
if (!derive(argv[2], keys, pub)) return 1;
OutgoingChatMemos memos;
const auto st = buildOutgoingMessage(keys, pub, /*myReply=*/argv[5], /*peerPub=*/argv[3],
/*peerZ=*/argv[4], /*cid=*/argv[6], /*plain=*/argv[7], memos);
if (st != ChatComposeStatus::Ok) { std::fprintf(stderr, "[chat-smoke] compose status=%d\n", (int)st); return 1; }
emit(memos);
return 0;
}
if (mode == "control") {
if (argc < 7) { std::fprintf(stderr, "control needs 5 args\n"); return 2; }
ChatKeyPair keys; std::string pub;
if (!derive(argv[2], keys, pub)) return 1;
OutgoingChatMemos memos;
const auto st = buildOutgoingControl(pub, /*selfZ=*/argv[3], /*cid=*/argv[4],
/*op=*/argv[5], /*label=*/argv[6], memos);
if (st != ChatComposeStatus::Ok) { std::fprintf(stderr, "[chat-smoke] control status=%d\n", (int)st); return 1; }
emit(memos);
return 0;
}
if (mode == "extract") {
if (argc < 3) return 2;
ChatKeyPair keys; std::string pub;
if (!derive(argv[2], keys, pub)) return 1;
HushChatTransactionInput tx; tx.txid = "regtest-tx";
std::string line; std::size_t pos = 0;
while (std::getline(std::cin, line)) {
const std::string m = trimMemo(line);
if (!m.empty()) tx.outputs.push_back({pos++, m});
}
auto ext = extractHushChatTransactionMetadata(tx, /*featureEnabled=*/true);
std::fprintf(stderr, "[chat-smoke] %zu memo output(s) in -> %zu metadata, %zu issue(s)\n",
tx.outputs.size(), ext.metadata.size(), ext.issues.size());
int recovered = 0;
for (const auto& md : ext.metadata) {
if (md.type == HushChatHeaderType::Message) {
std::string plain;
const auto cs = decryptIncoming(keys, md.sender_public_key_hex,
md.secretstream_header_hex, md.payload_memo, plain);
if (cs == ChatCryptoStatus::Ok) { std::printf("PLAINTEXT %s\n", plain.c_str()); ++recovered; }
else std::fprintf(stderr, "[chat-smoke] decrypt failed status=%d\n", (int)cs);
} else if (md.type == HushChatHeaderType::Control) {
std::printf("CONTROL op=%s cid=%s label=%s\n",
md.op.c_str(), md.conversation_id.c_str(), md.payload_memo.c_str());
++recovered;
}
}
return recovered > 0 ? 0 : 1;
}
std::fprintf(stderr, "unknown mode: %s\n", mode.c_str());
return 2;
}